Compare commits

..
10 Commits
Author SHA1 Message Date
Expo CI 71cf0aea74 v24.6.0 2026-09-15 16:14:36 +00:00
Will Schurman 🦦 f6a3028356 [eas-cli] Remove broad primaryAccount querying to support scoped token access (#4380) 2026-09-15 08:38:14 -07:00
Will Schurman 🦦 3182070f4c [eas-cli] Regenerate graphql and fix types (#4379) 2026-09-15 08:14:01 -07:00
Tomasz Mazur 097b58c7a9 [eas-cli] Display build logs when --wait flag is set (#4303)
* [eas-cli] Display build logs when --wait flag is set

* [eas-cli] use custom fetch

* [eas-cli] add timeouts to builds logs watch
2026-09-15 16:57:22 +02:00
Expo CI 9584d05708 bump STAGING and PRODUCTION to 24.5.0 in cli-versions.json 2026-09-15 09:48:19 +00:00
Expo CI b9ac59b336 update CHANGELOG.md for v24.5.0 2026-09-15 09:44:37 +00:00
Expo CI 43068db22a v24.5.0 2026-09-15 09:41:35 +00:00
Stanisław Chmiela 2b7f1954fc [build-tools] Execute sandbox commands over MCP connection (#4347)
## Why

Clients are telling sandboxes through MCP server what to do. Let's handle the requests!

## How

The endeavour starts from a (relatively, after all) complex task of starting commands and reading/writing to them.

Since commands may live longer than a tool call (request followed by a response), we need some kind of manager of running commands — Shell Session Manager. (Session is the running command.)

This is where most of the complexity lays, the rest is mostly a wrapper.

## Test plan

CI should pass. Tested manually.
2026-09-15 11:34:05 +02:00
Expo CI 3b64599aea bump STAGING and PRODUCTION to 24.4.2 in cli-versions.json 2026-09-15 05:07:36 +00:00
Expo CI 96fe9a239e update CHANGELOG.md for v24.4.2 2026-09-15 05:05:39 +00:00
74 changed files with 3432 additions and 1100 deletions

No files matched your search

+19 -2
View File
@@ -8,12 +8,29 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Display logs in `eas build --wait`. ([#4303](https://github.com/expo/eas-cli/pull/4303) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [build-tools] Skip project source refresh for jobs with no sources. ([#4389](https://github.com/expo/eas-cli/pull/4389) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Stop fetching the primary account in the login query. Commands that need it now fetch it on demand, so scoped access tokens without primary account access no longer fail at login. ([#4380](https://github.com/expo/eas-cli/pull/4380) by [@wschurman](https://github.com/wschurman))
### 🧹 Chores
- [eas-cli] Regenerate GraphQL types and update Observe tests for the new `firstSeenAt` and `lastSeenAt` event-name fields. ([#4379](https://github.com/expo/eas-cli/pull/4379) by [@wschurman](https://github.com/wschurman))
## [24.5.0](https://github.com/expo/eas-cli/releases/tag/v24.5.0) - 2026-09-15
### 🎉 New features
- [build-tools] Let connected sandboxes run shell commands through the Expo MCP server. ([#4347](https://github.com/expo/eas-cli/pull/4347) by [@sjchmiela](https://github.com/sjchmiela))
## [24.4.2](https://github.com/expo/eas-cli/releases/tag/v24.4.2) - 2026-09-15
### 🐛 Bug fixes
- [eas-cli] Support Xcode 27, which replaces Simulator.app with Device Hub, in `build:dev`, `build:run`, and `run` for iOS simulator builds. ([#4405](https://github.com/expo/eas-cli/pull/4405) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
## [24.4.1](https://github.com/expo/eas-cli/releases/tag/v24.4.1) - 2026-09-15
### 🧹 Chores
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.4.1",
"PRODUCTION": "24.4.1"
"STAGING": "24.5.0",
"PRODUCTION": "24.5.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "24.4.2",
"version": "24.6.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "24.4.0",
"version": "24.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -42,14 +42,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "24.0.0",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"@expo/env": "^0.4.0",
"@expo/logger": "24.0.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": "24.3.0",
"@expo/steps": "24.5.0",
"@expo/template-file": "24.0.0",
"@expo/turtle-spawn": "24.0.0",
"@expo/xcpretty": "^4.3.1",
@@ -67,6 +67,7 @@
"lodash": "^4.18.1",
"node-fetch": "^2.7.0",
"node-forge": "^1.3.1",
"node-pty": "1.2.0-beta.15",
"node-stream-zip": "1.15.0",
"nullthrows": "^1.1.1",
"plist": "^3.1.0",
@@ -59,19 +59,47 @@ describe('sandbox build functions', () => {
fn.fn!({ logger: {} } as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
signal: controller.signal,
})
).rejects.toThrow();
).rejects.toMatchObject({ name: 'AbortError' });
expect(mutation).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
} finally {
start.mockRestore();
}
});
it('provides one long-running step', () => {
const ctx = { job: {} } as any;
expect(createStartSandboxBuildFunction(ctx).getFullId()).toBe('eas/start_sandbox');
it('excludes the MCP token from the command environment', async () => {
const env = {
PREPARED_BY_EARLIER_STEP: 'value',
EXPO_TOKEN: 'expo-token',
__EAS_SANDBOX_MCP_TOKEN: 'token',
};
const startupError = new Error('stop after checking options');
const start = jest
.spyOn(sandboxDaemon, 'startSandboxDaemonAsync')
.mockRejectedValue(startupError);
const fn = createStartSandboxBuildFunction({
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
mcpServerUrl: 'ws://localhost:8787',
} as any);
try {
await expect(
fn.fn!({ logger: {} } as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env,
})
).rejects.toBe(startupError);
expect(start.mock.calls[0][0]).toMatchObject({ credential: 'token' });
expect(start.mock.calls[0][0].env).toEqual({
PREPARED_BY_EARLIER_STEP: 'value',
EXPO_TOKEN: 'expo-token',
});
expect(env.__EAS_SANDBOX_MCP_TOKEN).toBe('token');
} finally {
start.mockRestore();
}
});
it('throws a system error when the sandbox token is missing', async () => {
@@ -100,9 +128,9 @@ describe('sandbox build functions', () => {
fn.fn!({} as any, {
inputs: { sandbox_id: { value: 'sandbox-id' } },
outputs: {},
env: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'sandbox-token' },
})
).rejects.toBeInstanceOf(SystemError);
).rejects.toThrow('MCP server URL is required to start the sandbox daemon.');
});
it('marks the sandbox as ready', async () => {
@@ -32,8 +32,9 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs, signal }) => {
const sandboxToken = ctx.env.__EAS_SANDBOX_MCP_TOKEN;
fn: async (stepCtx, { inputs, signal, env }) => {
// The daemon needs this credential, but shell commands do not.
const { __EAS_SANDBOX_MCP_TOKEN: sandboxToken, ...commandEnv } = env;
if (!sandboxToken) {
throw new SystemError('__EAS_SANDBOX_MCP_TOKEN is required to start the sandbox daemon.');
}
@@ -48,6 +49,8 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
reconnectDelayMs: RECONNECT_DELAY_MS,
logger: stepCtx.logger,
signal,
workingDirectory: stepCtx.workingDirectory,
env: commandEnv,
});
try {
await daemon.ready;
@@ -1,4 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { SandboxDaemonResponseZ, SystemError } from '@expo/eas-build-job';
import Log from '@expo/logger';
import http from 'node:http';
import net, { AddressInfo } from 'node:net';
@@ -7,6 +7,10 @@ import WebSocket, { WebSocketServer } from 'ws';
import { startSandboxDaemonAsync } from '../sandboxDaemon';
jest.unmock('@expo/logger');
jest.unmock('fs');
jest.unmock('fs/promises');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
describe(startSandboxDaemonAsync.name, () => {
const logger = Log.child({ buildStepId: 'sandbox-test' });
@@ -17,6 +21,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
try {
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
@@ -40,6 +46,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${(server.address() as AddressInfo).port}`,
reconnectDelayMs: 60_000,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
try {
const socket = await connection;
@@ -72,6 +80,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
try {
await expect(daemon.ready).rejects.toBeInstanceOf(SystemError);
@@ -100,6 +110,8 @@ describe(startSandboxDaemonAsync.name, () => {
reconnectDelayMs: 60_000,
logger,
signal: controller.signal,
workingDirectory: process.cwd(),
env: process.env,
});
const connectionError = daemon.ready.catch(error => error);
@@ -126,6 +138,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 60_000,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
await expect(daemon.ready).rejects.toThrow('Sandbox MCP server connection failed');
@@ -157,6 +171,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
const daemon = await daemonPromise;
@@ -170,6 +186,117 @@ describe(startSandboxDaemonAsync.name, () => {
await new Promise<void>(resolve => httpServer.close(() => resolve()));
});
it('executes command messages', async () => {
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
httpServer.on('upgrade', (request, socket, head) => {
mcpServer.handleUpgrade(request, socket, head, client =>
mcpServer.emit('connection', client)
);
});
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
const address = httpServer.address() as AddressInfo;
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
const daemon = await startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
await daemon.ready;
const commandResponse = new Promise<string>(resolve =>
socket.once('message', data => resolve(`${data}`))
);
socket.send(
JSON.stringify({
jsonrpc: '2.0',
id: 'command-1',
method: 'execCommand',
params: { cmd: 'printf hello' },
})
);
expect(JSON.parse(await commandResponse)).toMatchObject({
jsonrpc: '2.0',
id: 'command-1',
result: { output: 'hello', exitCode: 0 },
});
await daemon.stopAsync();
mcpServer.close();
await new Promise<void>(resolve => httpServer.close(() => resolve()));
});
it('returns JSON-RPC errors for invalid messages', async () => {
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
httpServer.on('upgrade', (request, socket, head) => {
mcpServer.handleUpgrade(request, socket, head, client =>
mcpServer.emit('connection', client)
);
});
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
const address = httpServer.address() as AddressInfo;
const connection = new Promise<WebSocket>(resolve => mcpServer.once('connection', resolve));
const daemon = await startSandboxDaemonAsync({
credential: 'secret-token',
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 10,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
await daemon.ready;
const sendAsync = async (message: string): Promise<unknown> => {
const response = new Promise<string>(resolve =>
socket.once('message', data => resolve(`${data}`))
);
socket.send(message);
return SandboxDaemonResponseZ.parse(JSON.parse(await response));
};
await expect(sendAsync('{')).resolves.toMatchObject({
id: null,
error: { code: -32700, message: 'Parse error' },
});
await expect(sendAsync('{}')).resolves.toMatchObject({
id: null,
error: { code: -32600, message: 'Invalid request' },
});
await expect(
sendAsync(JSON.stringify({ jsonrpc: '2.0', id: '1', method: 'unknown' }))
).resolves.toMatchObject({
id: '1',
error: { code: -32601, message: 'Method not found' },
});
await expect(
sendAsync(JSON.stringify({ jsonrpc: '2.0', id: '2', method: 'execCommand', params: {} }))
).resolves.toMatchObject({
id: '2',
error: { code: -32602, message: 'Invalid params' },
});
await expect(
sendAsync(
JSON.stringify({
jsonrpc: '2.0',
id: '3',
method: 'writeStdin',
params: { sessionId: 999 },
})
)
).resolves.toMatchObject({
id: '3',
error: { code: -32603, message: 'Command session 999 does not exist.' },
});
await daemon.stopAsync();
mcpServer.close();
await new Promise<void>(resolve => httpServer.close(() => resolve()));
});
it('cancels the reconnect delay when stopped', async () => {
const httpServer = http.createServer();
const mcpServer = new WebSocketServer({ noServer: true });
@@ -186,6 +313,8 @@ describe(startSandboxDaemonAsync.name, () => {
serverUrl: `ws://127.0.0.1:${address.port}`,
reconnectDelayMs: 60_000,
logger,
workingDirectory: process.cwd(),
env: process.env,
});
const socket = await connection;
const daemon = await daemonPromise;
@@ -0,0 +1,286 @@
jest.unmock('fs');
jest.unmock('fs/promises');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
import { type SandboxDaemonCommandResult, type SandboxDaemonMethod } from '@expo/eas-build-job';
import fs from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { performance } from 'node:perf_hooks';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import {
type SandboxDaemonCommandImplementations,
createSandboxCommandImplementations,
} from '../sandboxCommandImplementations';
describe('sandbox daemon commands', () => {
let commandImplementations: SandboxDaemonCommandImplementations;
let stopAsync: () => Promise<void>;
let workingDirectory: string;
beforeEach(async () => {
workingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'sandbox-command-executor-'));
const controller = new AbortController();
const commands = createSandboxCommandImplementations({
workingDirectory,
env: process.env,
signal: controller.signal,
});
commandImplementations = commands.commandImplementations;
stopAsync = async () => {
controller.abort();
await commands.stoppedPromise;
};
});
afterEach(async () => {
await stopAsync();
await fs.chmod(workingDirectory, 0o700);
await fs.rm(workingDirectory, { recursive: true, force: true });
});
it('returns output and the exit code for a completed command', async () => {
const result = await commandImplementations.execCommand({ cmd: 'printf hello' });
expect(result).toMatchObject({ output: 'hello', exitCode: 0 });
expect('sessionId' in result).toBe(false);
});
it('rejects a command canceled during directory validation', async () => {
const controller = new AbortController();
const commands = createSandboxCommandImplementations({
workingDirectory,
env: process.env,
signal: controller.signal,
});
const command = commands.commandImplementations.execCommand({ cmd: 'printf should-not-run' });
controller.abort();
await commands.stoppedPromise;
await expect(command).rejects.toMatchObject({ name: 'AbortError' });
await expect(
commands.commandImplementations.writeStdin({ sessionId: 1 })
).rejects.toMatchObject({ name: 'AbortError' });
});
it('returns a session id and accepts stdin for a running command', async () => {
const started = await commandImplementations.execCommand({
cmd: `node -e "process.stdin.once('data', data => process.stdout.write(data.toString().toUpperCase(), () => process.exit(0)))"`,
yieldTimeMs: 10,
});
const sessionId = getSessionId(started);
const completed = await commandImplementations.writeStdin({
sessionId,
chars: 'hello',
yieldTimeMs: 1_000,
});
expect(completed).toMatchObject({ output: 'HELLO', exitCode: 0 });
expect('sessionId' in completed).toBe(false);
});
it('returns only output produced since the previous call', async () => {
const started = await commandImplementations.execCommand({
cmd: `node -e "process.stdin.once('data', () => process.stdout.write('second\\n', () => process.exit(0))); console.log('first')"`,
yieldTimeMs: 50,
});
const sessionId = getSessionId(started);
expect(await readUntilAsync(started, output => output.includes('first\n'))).toMatchObject({
output: 'first\n',
});
const completed = await readUntilAsync(
await commandImplementations.writeStdin({ sessionId, chars: 'continue', yieldTimeMs: 100 }),
(_, result) => !('sessionId' in result)
);
expect(completed).toMatchObject({ output: 'second\n', exitCode: 0 });
});
it('provides a TTY when requested', async () => {
const result = await commandImplementations.execCommand({
cmd: 'test -t 0 && test -t 1 && test -t 2',
tty: true,
});
expect(result).toMatchObject({ exitCode: 0 });
});
it('uses pipes by default', async () => {
const result = await commandImplementations.execCommand({ cmd: 'test -t 0' });
expect(result).toMatchObject({ exitCode: 1 });
});
it('sends Ctrl+C to the foreground PTY process', async () => {
const started = await commandImplementations.execCommand({
cmd: `exec node -e "process.on('SIGINT', () => process.stdout.write('interrupted\\n', () => process.exit(0))); console.log('ready'); setInterval(() => {}, 1000)"`,
tty: true,
yieldTimeMs: 50,
});
await readUntilAsync(started, output => output.includes('ready'));
const completed = await readUntilAsync(
await commandImplementations.writeStdin({
sessionId: getSessionId(started),
chars: '\u0003',
yieldTimeMs: 1_000,
}),
(_, result) => !('sessionId' in result)
);
expect(completed.output).toContain('interrupted');
expect(completed).toMatchObject({ exitCode: 0 });
});
it('writes U+0003 as literal input in pipe mode', async () => {
const started = await commandImplementations.execCommand({
cmd: `node -e "process.stdin.once('data', data => process.stdout.write(data[0] + '\\n', () => process.exit(0)))"`,
yieldTimeMs: 10,
});
const completed = await readUntilAsync(
await commandImplementations.writeStdin({
sessionId: getSessionId(started),
chars: '\u0003',
yieldTimeMs: 1_000,
}),
(_, result) => !('sessionId' in result)
);
expect(completed).toMatchObject({ output: '3\n', exitCode: 0 });
});
it('reports a termination signal without an exit code', async () => {
const result = await commandImplementations.execCommand({ cmd: 'kill -TERM $$' });
expect(result).toMatchObject({ terminationSignal: 'SIGTERM' });
expect('exitCode' in result).toBe(false);
});
it('measures each call separately', async () => {
const now = jest.spyOn(performance, 'now');
try {
now.mockReturnValueOnce(1_000).mockReturnValueOnce(1_250);
const started = await commandImplementations.execCommand({
cmd: 'read value',
yieldTimeMs: 0,
});
expect(started.wallTimeSeconds).toBe(0.25);
now.mockReturnValueOnce(5_000).mockReturnValueOnce(5_100);
const polled = await commandImplementations.writeStdin({
sessionId: getSessionId(started),
yieldTimeMs: 0,
});
expect(polled.wallTimeSeconds).toBe(0.1);
} finally {
now.mockRestore();
}
});
it('uses the requested working directory', async () => {
const childDirectory = path.join(workingDirectory, 'child');
await fs.mkdir(childDirectory);
const result = await commandImplementations.execCommand({ cmd: 'pwd', workdir: 'child' });
expect(result).toMatchObject({ output: `${await fs.realpath(childDirectory)}\n`, exitCode: 0 });
});
it('reports a missing working directory', async () => {
await expect(
commandImplementations.execCommand({ cmd: 'true', workdir: 'missing' })
).rejects.toThrow(
`Working directory does not exist: ${path.join(workingDirectory, 'missing')}`
);
});
it('reports a working directory that is not a directory', async () => {
const file = path.join(workingDirectory, 'file');
await fs.writeFile(file, 'content');
await expect(
commandImplementations.execCommand({ cmd: 'true', workdir: 'file' })
).rejects.toThrow(`Working directory is not a directory: ${file}`);
});
it('reports an inaccessible working directory', async () => {
if (process.platform === 'win32') {
return;
}
const directory = path.join(workingDirectory, 'inaccessible');
await fs.mkdir(directory, 0o000);
await expect(
commandImplementations.execCommand({ cmd: 'true', workdir: 'inaccessible' })
).rejects.toThrow(`Working directory is not accessible: ${directory}`);
await fs.chmod(directory, 0o700);
});
it.each([false, true])('stops command descendants when tty is %s', async tty => {
const started = await commandImplementations.execCommand({
cmd: `node -e "const { spawn } = require('node:child_process'); const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' }); process.stdout.write(String(child.pid) + '\\n'); setInterval(() => {}, 1000)"`,
tty,
yieldTimeMs: 100,
});
const ready = await readUntilAsync(started, output => /\d+\r?\n/.test(output));
const childPid = Number(/\d+/.exec(ready.output)?.[0]);
expect(childPid).toBeGreaterThan(0);
await stopAsync();
await setTimeoutAsync(50);
expect(isProcessRunning(childPid)).toBe(false);
});
async function readUntilAsync(
initial: SandboxDaemonCommandResult<SandboxDaemonMethod>,
isReady: (output: string, result: SandboxDaemonCommandResult<SandboxDaemonMethod>) => boolean
): Promise<SandboxDaemonCommandResult<SandboxDaemonMethod>> {
let result = initial;
let output = result.output;
const deadline = Date.now() + 10_000;
while (!isReady(output, result)) {
if (!('sessionId' in result) || Date.now() >= deadline) {
throw new Error(
`Command did not reach the expected state: ${JSON.stringify({ ...result, output })}`
);
}
result = await commandImplementations.writeStdin({
sessionId: result.sessionId,
yieldTimeMs: 100,
});
output += result.output;
}
return { ...result, output };
}
});
function getSessionId(result: unknown): number {
if (
typeof result !== 'object' ||
result === null ||
!('sessionId' in result) ||
typeof result.sessionId !== 'number'
) {
throw new Error('Expected the command to still be running.');
}
return result.sessionId;
}
function isProcessRunning(pid: number): boolean {
const result = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8' });
if (result.error) {
throw result.error;
}
if (result.status === 1 && !result.stdout.trim()) {
return false;
}
expect(result.status).toBe(0);
// A zombie has exited, but its parent has not yet collected its exit status.
return !result.stdout.trim().startsWith('Z');
}
@@ -0,0 +1,105 @@
jest.unmock('node:fs/promises');
import { ShellSessionManager } from '../shellSessionManager';
import * as processes from '../../../utils/processes';
describe('ShellSessionManager', () => {
it.each([false, true])('uses only the supplied environment (tty: %s)', async tty => {
const controller = new AbortController();
const previous = process.env.SANDBOX_WORKER_ONLY;
process.env.SANDBOX_WORKER_ONLY = 'worker-secret';
const manager = new ShellSessionManager({
workingDirectory: process.cwd(),
env: { SHELL: '/bin/sh', SANDBOX_PREPARED: 'prepared-value' },
signal: controller.signal,
});
try {
const sessionId = await manager.startAsync({
cmd: 'printf "%s:%s" "$SANDBOX_PREPARED" "${SANDBOX_WORKER_ONLY-unset}"',
tty,
});
expect(await manager.readAsync(sessionId, 10_000)).toEqual({
output: 'prepared-value:unset',
exitCode: 0,
});
} finally {
controller.abort();
await manager.stoppedPromise;
if (previous === undefined) {
delete process.env.SANDBOX_WORKER_ONLY;
} else {
process.env.SANDBOX_WORKER_ONLY = previous;
}
}
});
it.each([false, true])('does not signal an exited leader (tty: %s)', async tty => {
const controller = new AbortController();
const manager = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: controller.signal,
});
const kill = jest.spyOn(processes, 'killProcessGroup');
try {
const sessionId = await manager.startAsync({ cmd: 'exit 0', tty });
expect(await manager.readAsync(sessionId, 10_000)).toMatchObject({ exitCode: 0 });
controller.abort();
await manager.stoppedPromise;
expect(kill).not.toHaveBeenCalled();
} finally {
controller.abort();
await manager.stoppedPromise;
kill.mockRestore();
}
});
it('keeps completed sessions without repeating their output', async () => {
const controller = new AbortController();
const manager = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: controller.signal,
});
try {
const sessionId = await manager.startAsync({ cmd: 'printf hello' });
expect(await manager.readAsync(sessionId, 1_000)).toEqual({ output: 'hello', exitCode: 0 });
expect(await manager.readAsync(sessionId, 1_000)).toEqual({ output: '', exitCode: 0 });
} finally {
controller.abort();
await manager.stoppedPromise;
}
});
it('keeps sessions separate and stops only its own manager', async () => {
const firstController = new AbortController();
const secondController = new AbortController();
const first = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: firstController.signal,
});
const second = new ShellSessionManager({
workingDirectory: process.cwd(),
env: process.env,
signal: secondController.signal,
});
try {
const firstId = await first.startAsync({ cmd: 'printf first' });
const secondId = await second.startAsync({ cmd: 'read value; printf "%s" "$value"' });
expect(await first.readAsync(firstId, 1_000)).toEqual({ output: 'first', exitCode: 0 });
firstController.abort();
await first.stoppedPromise;
await expect(first.startAsync({ cmd: 'printf stopped' })).rejects.toMatchObject({
name: 'AbortError',
});
expect(await second.readAsync(secondId, 0)).toEqual({ output: '', sessionId: secondId });
second.write(secondId, 'second\n');
expect(await second.readAsync(secondId, 1_000)).toEqual({ output: 'second', exitCode: 0 });
} finally {
firstController.abort();
secondController.abort();
await Promise.all([first.stoppedPromise, second.stoppedPromise]);
}
});
});
@@ -0,0 +1,61 @@
import {
type SandboxDaemonCommandParams,
type SandboxDaemonCommandResult,
type SandboxDaemonMethod,
} from '@expo/eas-build-job';
import { performance } from 'node:perf_hooks';
import { ShellSessionManager } from './shellSessionManager';
const DEFAULT_EXEC_YIELD_TIME_MS = 10_000;
const DEFAULT_WRITE_YIELD_TIME_MS = 250;
export type SandboxDaemonCommandImplementations = {
[Method in SandboxDaemonMethod]: (
params: SandboxDaemonCommandParams<Method>
) => Promise<SandboxDaemonCommandResult<Method>>;
};
export function createSandboxCommandImplementations({
workingDirectory,
env,
signal,
}: {
workingDirectory: string;
env: NodeJS.ProcessEnv;
signal: AbortSignal;
}): {
commandImplementations: SandboxDaemonCommandImplementations;
stoppedPromise: Promise<void>;
} {
const sessions = new ShellSessionManager({ workingDirectory, env, signal });
return {
commandImplementations: {
async execCommand(params) {
const callStartedAt = performance.now();
const sessionId = await sessions.startAsync({
cmd: params.cmd,
workdir: params.workdir,
tty: params.tty,
});
const result = await sessions.readAsync(
sessionId,
params.yieldTimeMs ?? DEFAULT_EXEC_YIELD_TIME_MS
);
return { ...result, wallTimeSeconds: (performance.now() - callStartedAt) / 1_000 };
},
async writeStdin(params) {
const callStartedAt = performance.now();
if (params.chars !== undefined) {
sessions.write(params.sessionId, params.chars);
}
const result = await sessions.readAsync(
params.sessionId,
params.yieldTimeMs ?? DEFAULT_WRITE_YIELD_TIME_MS
);
return { ...result, wallTimeSeconds: (performance.now() - callStartedAt) / 1_000 };
},
},
stoppedPromise: sessions.stoppedPromise,
};
}
@@ -1,14 +1,28 @@
import { SystemError } from '@expo/eas-build-job';
import {
type SandboxDaemonCommandResult,
SandboxDaemonCommands,
type SandboxDaemonMethod,
SandboxDaemonRequestZ,
type SandboxDaemonResponse,
SystemError,
} from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
import WebSocket from 'ws';
import {
type SandboxDaemonCommandImplementations,
createSandboxCommandImplementations,
} from './sandboxCommandImplementations';
export interface SandboxDaemonOptions {
credential: string;
serverUrl: string;
reconnectDelayMs: number;
logger: bunyan;
signal?: AbortSignal;
workingDirectory: string;
env: NodeJS.ProcessEnv;
}
export interface SandboxDaemon {
@@ -25,6 +39,12 @@ export async function startSandboxDaemonAsync(
let hasConnected = false;
let resolveConnected!: () => void;
let rejectConnected!: (error: Error) => void;
const { commandImplementations, stoppedPromise: commandsStoppedPromise } =
createSandboxCommandImplementations({
workingDirectory: options.workingDirectory,
env: options.env,
signal: abortController.signal,
});
const connected = new Promise<void>((resolve, reject) => {
resolveConnected = resolve;
rejectConnected = reject;
@@ -38,15 +58,39 @@ export async function startSandboxDaemonAsync(
const connectionLoop = (async () => {
while (!abortController.signal.aborted) {
try {
socket = new WebSocket(new URL('/sandbox/connect', options.serverUrl), {
const connectedSocket = new WebSocket(new URL('/sandbox/connect', options.serverUrl), {
handshakeTimeout: 10_000,
headers: { Authorization: `Bearer ${options.credential}` },
});
await waitForOpen(socket);
socket = connectedSocket;
await waitForOpen(connectedSocket);
options.logger.info('Sandbox MCP server connected.');
connectedSocket.on('message', async message => {
try {
const response = await handleMessageAsync(commandImplementations, message.toString());
// MCP fails pending calls on disconnect. Responses belong to this socket only.
if (connectedSocket.readyState !== WebSocket.OPEN) {
options.logger.warn(
'Sandbox command response was lost after disconnect. The command may have run and output may have been consumed.'
);
return;
}
await new Promise<void>((resolve, reject) => {
connectedSocket.send(JSON.stringify(response), error => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
} catch (error) {
options.logger.warn({ err: error }, 'Could not send sandbox command response.');
}
});
hasConnected = true;
resolveConnected();
await waitForClose(socket);
await waitForClose(connectedSocket);
} catch (error: any) {
if (!hasConnected) {
const message = `Sandbox MCP server connection failed: ${error?.message ?? 'unknown error'}`;
@@ -82,6 +126,7 @@ export async function startSandboxDaemonAsync(
async stopAsync(): Promise<void> {
options.signal?.removeEventListener('abort', stop);
stop();
await commandsStoppedPromise;
await connectionLoop;
},
};
@@ -104,3 +149,48 @@ function waitForClose(socket: WebSocket): Promise<void> {
socket.once('error', reject);
});
}
async function handleMessageAsync(
commandImplementations: SandboxDaemonCommandImplementations,
message: string
): Promise<SandboxDaemonResponse> {
let rawRequest: unknown;
try {
rawRequest = JSON.parse(message);
} catch {
return { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } };
}
const request = SandboxDaemonRequestZ.safeParse(rawRequest);
if (!request.success) {
return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid request' } };
}
const { id, method, params } = request.data;
if (!Object.hasOwn(SandboxDaemonCommands, method)) {
return { jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } };
}
const commandMethod = method as SandboxDaemonMethod;
const parsedParams = SandboxDaemonCommands[commandMethod].params.safeParse(params);
if (!parsedParams.success) {
return { jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params' } };
}
try {
return {
jsonrpc: '2.0',
id,
result: await (
commandImplementations[commandMethod] as (
params: typeof parsedParams.data
) => Promise<SandboxDaemonCommandResult<typeof commandMethod>>
)(parsedParams.data),
};
} catch (error) {
return {
jsonrpc: '2.0',
id,
error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error' },
};
}
}
@@ -0,0 +1,293 @@
import * as pty from 'node-pty';
import { type ChildProcess, spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import { constants as osConstants } from 'node:os';
import path from 'node:path';
import { killProcessGroup } from '../../utils/processes';
const PROCESS_STOP_GRACE_PERIOD_MS = 1_000;
const SIGNAL_NAMES = new Map(
Object.entries(osConstants.signals)
.reverse()
.map(([name, number]) => [number, name])
);
export class ShellSessionManager {
// Keep completed sessions so callers can still read their output and exit status.
private readonly sessions = new Map<number, CommandSession>();
private nextSessionId = 1;
public readonly stoppedPromise: Promise<void>;
public constructor(
private readonly options: {
workingDirectory: string;
env: NodeJS.ProcessEnv;
signal: AbortSignal;
}
) {
this.stoppedPromise = (async () => {
if (!options.signal.aborted) {
await new Promise<void>(resolve => {
options.signal.addEventListener('abort', () => resolve(), { once: true });
});
}
await this.stopAsync();
})();
// Cleanup can fail before the owner awaits it.
this.stoppedPromise.catch(() => {});
}
public async startAsync({
cmd,
workdir,
tty,
}: {
cmd: string;
workdir?: string;
tty?: boolean;
}): Promise<number> {
this.throwIfStopped();
const workingDirectory = workdir
? path.resolve(this.options.workingDirectory, workdir)
: this.options.workingDirectory;
await validateWorkingDirectoryAsync(workingDirectory);
this.throwIfStopped();
const sessionId = this.nextSessionId++;
const session = tty
? startPtyCommand({ command: cmd, workingDirectory, env: this.options.env })
: startPipeCommand({ command: cmd, workingDirectory, env: this.options.env });
this.sessions.set(sessionId, session);
return sessionId;
}
public write(sessionId: number, chars: string): void {
this.throwIfStopped();
const session = this.getSession(sessionId);
if (!session.isCompleted && !session.error) {
session.write(chars);
}
}
public async readAsync(
sessionId: number,
yieldTimeMs: number
): Promise<
{ output: string } & (
| { exitCode: number }
| { terminationSignal: string }
| { sessionId: number }
)
> {
this.throwIfStopped();
const session = this.getSession(sessionId);
await waitForSessionsAsync([session], yieldTimeMs);
if (session.error) {
throw session.error;
}
const output = session.output;
session.output = '';
const result = {
output,
};
if (session.terminationSignal !== undefined) {
return { ...result, terminationSignal: session.terminationSignal };
}
if (session.exitCode !== undefined) {
return { ...result, exitCode: session.exitCode };
}
return { ...result, sessionId };
}
private async stopAsync(): Promise<void> {
const sessions = [...this.sessions.values()];
// Only signal groups while their leaders are alive. After a leader exits, its PID may
// be reused. Any surviving descendants are left to VM teardown.
for (const session of sessions) {
if (!session.hasLeaderExited) {
killProcessGroup(session.process, 'SIGTERM');
}
}
await waitForSessionsAsync(sessions, PROCESS_STOP_GRACE_PERIOD_MS);
for (const session of sessions) {
if (!session.hasLeaderExited) {
killProcessGroup(session.process, 'SIGKILL');
}
}
await waitForSessionsAsync(sessions, PROCESS_STOP_GRACE_PERIOD_MS);
this.sessions.clear();
}
private throwIfStopped(): void {
this.options.signal.throwIfAborted();
}
private getSession(sessionId: number): CommandSession {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Command session ${sessionId} does not exist.`);
}
return session;
}
}
type CommandSession = {
process: ChildProcess | pty.IPty;
completed: Promise<void>;
resolveCompleted: () => void;
isCompleted: boolean;
hasLeaderExited: boolean;
// Unread output is intentionally unlimited for now and can exhaust memory. Reading clears it.
output: string;
exitCode?: number;
terminationSignal?: string;
error?: Error;
write(chars: string): void;
};
function startPipeCommand({
command,
workingDirectory,
env,
}: {
command: string;
workingDirectory: string;
env: NodeJS.ProcessEnv;
}): CommandSession {
const child = spawn(command, {
cwd: workingDirectory,
env,
shell: env.SHELL ?? true,
detached: true,
});
const session = createSession(child);
session.write = (chars: string): void => {
child.stdin.write(chars);
};
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', data => {
session.output += data;
});
child.stderr.on('data', data => {
session.output += data;
});
child.stdin.on('error', () => {});
child.once('exit', () => {
session.hasLeaderExited = true;
});
child.once('error', error => {
if (!session.isCompleted) {
session.hasLeaderExited = true;
session.isCompleted = true;
session.error = error;
session.resolveCompleted();
}
});
child.once('close', (exitCode, signal) => {
if (!session.isCompleted) {
session.isCompleted = true;
if (signal) {
session.terminationSignal = signal;
} else {
session.exitCode = exitCode ?? 1;
}
session.resolveCompleted();
}
});
return session;
}
function startPtyCommand({
command,
workingDirectory,
env,
}: {
command: string;
workingDirectory: string;
env: NodeJS.ProcessEnv;
}): CommandSession {
const shell = env.SHELL ?? '/bin/sh';
const terminal = pty.spawn(shell, ['-c', command], {
cwd: workingDirectory,
env,
name: 'xterm-256color',
cols: 80,
rows: 24,
});
const session = createSession(terminal);
session.write = (chars: string): void => {
terminal.write(chars);
};
terminal.onData(data => {
session.output += data;
});
terminal.onExit(({ exitCode, signal }) => {
session.hasLeaderExited = true;
session.isCompleted = true;
if (signal) {
session.terminationSignal = SIGNAL_NAMES.get(signal) ?? `SIGNAL_${signal}`;
} else {
session.exitCode = exitCode;
}
session.resolveCompleted();
});
return session;
}
function createSession(process: CommandSession['process']): CommandSession {
let resolveCompleted!: () => void;
const completed = new Promise<void>(resolve => {
resolveCompleted = resolve;
});
return {
process,
completed,
resolveCompleted,
isCompleted: false,
hasLeaderExited: false,
output: '',
write: () => {},
};
}
async function validateWorkingDirectoryAsync(workingDirectory: string): Promise<void> {
let stats;
try {
stats = await fs.stat(workingDirectory);
} catch (error: any) {
if (error?.code === 'ENOENT') {
throw new Error(`Working directory does not exist: ${workingDirectory}`);
}
if (error?.code === 'EACCES') {
throw new Error(`Working directory is not accessible: ${workingDirectory}`);
}
throw error;
}
if (!stats.isDirectory()) {
throw new Error(`Working directory is not a directory: ${workingDirectory}`);
}
try {
await fs.access(workingDirectory, fs.constants.R_OK | fs.constants.X_OK);
} catch (error: any) {
if (error?.code === 'EACCES') {
throw new Error(`Working directory is not accessible: ${workingDirectory}`);
}
throw error;
}
}
async function waitForSessionsAsync(sessions: CommandSession[], timeoutMs: number): Promise<void> {
let timer: NodeJS.Timeout | undefined;
try {
await Promise.race([
Promise.all(sessions.map(session => session.completed)),
new Promise<void>(resolve => {
timer = setTimeout(resolve, timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
+10 -4
View File
@@ -10,14 +10,20 @@ export function isChildProcessAlive(child: ChildProcess): boolean {
* Kill a detached spawn's process group. Negated pid targets the group so bash/sleep
* children cannot survive after the parent is gone (e.g. across upterm redial).
*/
export function killProcessGroup(child: ChildProcess): void {
export function killProcessGroup(
child: { pid?: number; kill(signal?: NodeJS.Signals): void },
signal: NodeJS.Signals = 'SIGTERM'
): void {
if (child.pid == null) {
return;
}
try {
process.kill(-child.pid, 'SIGTERM');
} catch {
child.kill();
process.kill(-child.pid, signal);
} catch (error: any) {
// ESRCH means the process group no longer exists, so there is nothing left to stop.
if (error?.code !== 'ESRCH') {
child.kill(signal);
}
}
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "24.3.0",
"version": "24.5.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "24.3.0"
"@expo/steps": "24.5.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "24.3.0",
"version": "24.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
+5 -2
View File
@@ -75,8 +75,11 @@ export const SandboxDaemonResponseZ = z.union([
z.strictObject({ jsonrpc: z.literal('2.0'), id: z.string(), result: z.unknown().nonoptional() }),
z.strictObject({
jsonrpc: z.literal('2.0'),
id: z.string(),
error: z.strictObject({ code: z.number().int(), message: z.string() }),
id: z.string().nullable(),
error: z.strictObject({
code: z.number().int(),
message: z.string(),
}),
}),
]);
+133 -133
View File
File diff suppressed because it is too large. Load diff
+519 -25
View File
@@ -25267,16 +25267,12 @@
"fields": [
{
"name": "previousPeriodTotal",
"description": null,
"description": "Total for the equal-length window before startTime. Null when the window is longer than 45 days, since the previous period would fall outside the 90-day retention.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@@ -25398,23 +25394,19 @@
},
{
"name": "previousPeriodCrashCount",
"description": "Fatal exception events in the equal-length window before startTime.",
"description": "Fatal exception events in the equal-length window before startTime. Null when the window is longer than 45 days.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previousPeriodCrashFreeSessions",
"description": "Same fractions for the equal-length window before startTime; null when it had no activity.",
"description": "Same fractions for the equal-length window before startTime; null when it had no activity, or when the window is longer than 45 days and the previous period falls outside the 90-day retention.",
"args": [],
"type": {
"kind": "SCALAR",
@@ -25438,16 +25430,12 @@
},
{
"name": "previousPeriodCrashedUsers",
"description": null,
"description": "Null when the window is longer than 45 days.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
@@ -29533,6 +29521,38 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "firstSeenAt",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lastSeenAt",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": null,
@@ -29810,6 +29830,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "FIRST_SEEN",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "LAST_SEEN",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "NAME",
"description": null,
@@ -76017,6 +76049,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sandboxes",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SandboxQuery",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "snack",
"description": null,
@@ -77878,6 +77926,232 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "Sandbox",
"description": null,
"fields": [
{
"name": "app",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "App",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createdAt",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "finishedAt",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lastUsedAt",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "startedAt",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "status",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "SandboxStatus",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "turtleJobRun",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "JobRun",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updatedAt",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SandboxQuery",
"description": null,
"fields": [
{
"name": "byId",
"description": null,
"args": [
{
"name": "sandboxId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Sandbox",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "SandboxStatus",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "ERRORED",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "RUNNING",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "STARTING",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "STOPPED",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SecondFactorBooleanResult",
@@ -81266,6 +81540,189 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SupabaseAdvisorLint",
"description": "An unresolved finding from Supabase's Security or Performance Advisor (database linter).",
"fields": [
{
"name": "cacheKey",
"description": "Stable identifier for this finding on this project.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "description",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "detail",
"description": "Project-specific explanation naming the affected schema object.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "entity",
"description": "Affected schema object, e.g. public.todos, when the lint names one.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "level",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "SupabaseAdvisorLintLevel",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": "Lint rule identifier, e.g. rls_disabled_in_public.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "remediation",
"description": "Link to the remediation guide, when Supabase provides one.",
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "title",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "SupabaseAdvisorLintLevel",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "ERROR",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "INFO",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "WARN",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "SupabaseAdvisorType",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "PERFORMANCE",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "SECURITY",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SupabaseConnection",
@@ -81688,6 +82145,43 @@
"name": "SupabaseProject",
"description": null,
"fields": [
{
"name": "advisorLints",
"description": "Live unresolved lints from the project's Security or Performance Advisor, ordered by severity.\nReadable with view permission; a token refresh triggered by a read is persisted with elevated\nprivileges. Null when Supabase cannot be reached.",
"args": [
{
"name": "type",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "SupabaseAdvisorType",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SupabaseAdvisorLint",
"ofType": null
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "app",
"description": null,
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "24.4.2",
"version": "24.6.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,8 +48,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-json": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"@expo/eas-json": "24.5.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "24.0.0",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.3.0",
"@expo/steps": "24.5.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -11,8 +11,8 @@ import { detectProjectSdkVersionAsync } from '../../project/detectProjectSdkVers
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { mockTestCommand } from './utils';
import { UserQuery } from '../../graphql/queries/UserQuery';
jest.mock('@expo/config', () => ({
...jest.requireActual('@expo/config'),
@@ -45,8 +45,8 @@ jest.mock('fs-extra', () => ({
writeFile: jest.fn().mockResolvedValue(undefined),
remove: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../user/actions');
jest.mock('../../graphql/queries/WorkflowRunQuery');
jest.mock('../../graphql/queries/UserQuery');
jest.mock('../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../project/uploadAccountScopedFileAsync');
jest.mock('../../project/uploadAccountScopedProjectSourceAsync');
@@ -110,7 +110,9 @@ const mockActor = {
describe('Go command', () => {
beforeEach(() => {
jest.mocked(ensureActorHasPrimaryAccount).mockReturnValue(mockAccount as any);
jest
.mocked(UserQuery.requireCurrentUserPrimaryAccountAsync)
.mockReturnValue(mockAccount as any);
jest.mocked(WorkflowRunQuery.expoGoRepackConfigurationAsync).mockResolvedValue({
files: [],
sdkVersion: '55.0.0',
@@ -8,7 +8,7 @@ import {
mockProjectId,
mockTestCommand,
} from './utils';
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/logs/fetchLogs';
import { fetchRawLogsForJobAsync } from '../../commandUtils/workflow/fetchLogs';
import WorkflowLogView from '../../commands/workflow/logs';
import { AppPlatform, BuildPriority, BuildStatus } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
@@ -35,7 +35,7 @@ jest.mock('fs');
jest.mock('../../log');
jest.mock('../../prompts');
jest.mock('../../utils/json');
jest.mock('../../commandUtils/workflow/logs/fetchLogs');
jest.mock('../../commandUtils/workflow/fetchLogs');
describe(WorkflowLogView, () => {
beforeEach(() => {
@@ -0,0 +1,233 @@
import { BuildPhase } from '@expo/eas-build-job';
import { v4 as uuid } from 'uuid';
import { groupLogLinesIntoSteps } from '../../commandUtils/logs/parseLogs';
import { JobLogs, RawLogLine } from '../../commandUtils/logs/types';
import fetch from '../../fetch';
import {
AppPlatform,
BuildFragment,
BuildPriority,
BuildStatus,
RealtimeLogsTargetType,
} from '../../graphql/generated';
import {
formatActiveBuildText,
formatActiveBuildsText,
isBuildCompleted,
logSourceForBuild,
} from '../logs';
jest.mock('../../fetch');
function createBuildFragment(overrides: Partial<BuildFragment> = {}): BuildFragment {
return {
id: 'build-id',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
platform: AppPlatform.Android,
logFiles: [],
priority: BuildPriority.Normal,
app: {
__typename: 'App',
slug: 'test-project',
id: uuid(),
name: 'test-project',
ownerAccount: {
__typename: 'Account',
id: uuid(),
name: 'test-account',
},
},
status: BuildStatus.InProgress,
isForIosSimulator: false,
...overrides,
};
}
function phaseLines(phase: string, count: number): RawLogLine[] {
return Array.from({ length: count }, (_, index) => ({ phase, msg: `line${index}` }));
}
function logsForPhase(phase: string, count: number): JobLogs {
return groupLogLinesIntoSteps(phaseLines(phase, count));
}
describe(logSourceForBuild, () => {
it('keys the source by the build id', () => {
expect(logSourceForBuild(createBuildFragment({ id: 'some-build-id' })).key).toBe(
'some-build-id'
);
});
it('targets the build itself for realtime logs', () => {
expect(logSourceForBuild(createBuildFragment({ id: 'some-build-id' })).realtimeTarget).toEqual({
type: RealtimeLogsTargetType.Build,
id: 'some-build-id',
});
});
it('is in progress only while the build is in progress', () => {
expect(
logSourceForBuild(createBuildFragment({ status: BuildStatus.InProgress })).isInProgress
).toBe(true);
for (const status of [
BuildStatus.New,
BuildStatus.InQueue,
BuildStatus.PendingCancel,
BuildStatus.Canceled,
BuildStatus.Errored,
BuildStatus.Finished,
]) {
expect(logSourceForBuild(createBuildFragment({ status })).isInProgress).toBe(false);
}
});
describe('fetchRawLogLinesAsync', () => {
afterEach(() => {
jest.mocked(fetch).mockReset();
});
it('parses the first log file of the build', async () => {
jest.mocked(fetch).mockResolvedValueOnce({
text: async () =>
[
'{"logId":"1","phase":"INSTALL_DEPENDENCIES","msg":"npm ci"}',
'{"logId":"2","phase":"INSTALL_DEPENDENCIES","msg":"done"}',
].join('\n'),
} as any);
const source = logSourceForBuild(
createBuildFragment({ logFiles: ['https://logs.test/first', 'https://logs.test/second'] })
);
await expect(source.fetchRawLogLinesAsync()).resolves.toEqual([
{ logId: '1', phase: 'INSTALL_DEPENDENCIES', msg: 'npm ci' },
{ logId: '2', phase: 'INSTALL_DEPENDENCIES', msg: 'done' },
]);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith('https://logs.test/first', {
timeout: expect.any(Number),
});
});
it('returns null when the build has no log file', async () => {
await expect(
logSourceForBuild(createBuildFragment({ logFiles: [] })).fetchRawLogLinesAsync()
).resolves.toBeNull();
expect(fetch).not.toHaveBeenCalled();
});
});
});
describe(isBuildCompleted, () => {
it('reports FINISHED as completed', () => {
expect(isBuildCompleted(BuildStatus.Finished)).toBe(true);
});
it('reports ERRORED as completed', () => {
expect(isBuildCompleted(BuildStatus.Errored)).toBe(true);
});
it('reports CANCELED as completed', () => {
expect(isBuildCompleted(BuildStatus.Canceled)).toBe(true);
});
it('reports NEW as not completed', () => {
expect(isBuildCompleted(BuildStatus.New)).toBe(false);
});
it('reports IN_QUEUE as not completed', () => {
expect(isBuildCompleted(BuildStatus.InQueue)).toBe(false);
});
it('reports IN_PROGRESS as not completed', () => {
expect(isBuildCompleted(BuildStatus.InProgress)).toBe(false);
});
it('reports PENDING_CANCEL as not completed', () => {
expect(isBuildCompleted(BuildStatus.PendingCancel)).toBe(false);
});
});
describe(formatActiveBuildText, () => {
it('returns the base text alone when there are no logs', () => {
expect(formatActiveBuildText('Build in progress...', new Map())).toBe('Build in progress...');
});
it('shows the display name of a raw build phase', () => {
const output = formatActiveBuildText(
'Build in progress...',
logsForPhase(BuildPhase.INSTALL_DEPENDENCIES, 1)
);
expect(output).toContain('Current phase');
expect(output).toContain('Install dependencies');
expect(output).not.toContain('INSTALL_DEPENDENCIES');
});
it('passes a build step display name through untouched', () => {
const output = formatActiveBuildText(
'Build in progress...',
groupLogLinesIntoSteps([
{ buildStepId: 'step-id-1', buildStepDisplayName: 'Run fastlane build', msg: 'line0' },
])
);
expect(output).toContain('Run fastlane build');
expect(output).not.toContain('step-id-1');
});
it('keeps the base text and five trailing log lines', () => {
const output = formatActiveBuildText(
'Build in progress...',
logsForPhase(BuildPhase.RUN_GRADLEW, 10)
);
expect(output.startsWith('Build in progress...\n')).toBe(true);
expect(output).toContain('line5');
expect(output).toContain('line9');
expect(output).not.toContain('line4');
});
});
describe(formatActiveBuildsText, () => {
it('returns the base text alone when no build has logs', () => {
expect(
formatActiveBuildsText('Waiting for builds to complete.', [
{ build: createBuildFragment(), logs: new Map() },
])
).toBe('Waiting for builds to complete.');
});
it('labels each build with its platform emoji and display name', () => {
const output = formatActiveBuildsText('Waiting for builds to complete.', [
{
build: createBuildFragment({ id: 'android-build', platform: AppPlatform.Android }),
logs: logsForPhase(BuildPhase.RUN_GRADLEW, 1),
},
{
build: createBuildFragment({ id: 'ios-build', platform: AppPlatform.Ios }),
logs: logsForPhase(BuildPhase.RUN_FASTLANE, 1),
},
]);
expect(output).toContain('🤖 Android');
expect(output).toContain('Run gradlew');
expect(output).toContain('🍏 iOS');
expect(output).toContain('Run fastlane');
});
it('keeps three trailing log lines per build', () => {
const output = formatActiveBuildsText('Waiting for builds to complete.', [
{
build: createBuildFragment({ id: 'android-build', platform: AppPlatform.Android }),
logs: logsForPhase(BuildPhase.RUN_GRADLEW, 10),
},
]);
expect(output).toContain('line7');
expect(output).toContain('line9');
expect(output).not.toContain('line6');
});
});
@@ -0,0 +1,163 @@
import { BuildPhase } from '@expo/eas-build-job';
import { v4 as uuid } from 'uuid';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { LogsState } from '../../commandUtils/logs/state';
import { groupLogLinesIntoSteps } from '../../commandUtils/logs/parseLogs';
import { JobLogs } from '../../commandUtils/logs/types';
import { LogsWatcher } from '../../commandUtils/logs/watcher';
import {
AppPlatform,
BuildFragment,
BuildPriority,
BuildStatus,
} from '../../graphql/generated';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { Ora, isSpinnerEnabled, ora } from '../../ora';
import { createRealtimeLogsClient } from '../../utils/centrifuge';
import { sleepAsync } from '../../utils/promise';
import { waitForBuildEndAsync } from '../build';
jest.mock('../../ora', () => ({
...jest.requireActual('../../ora'),
ora: jest.fn(),
isSpinnerEnabled: jest.fn(),
}));
jest.mock('../../commandUtils/logs/watcher', () => ({
LogsWatcher: jest.fn(),
}));
jest.mock('../../utils/centrifuge', () => ({
createRealtimeLogsClient: jest.fn(),
}));
jest.mock('../../graphql/queries/BuildQuery', () => ({
BuildQuery: {
byIdAsync: jest.fn(),
},
}));
jest.mock('../../utils/promise', () => ({
...jest.requireActual('../../utils/promise'),
sleepAsync: jest.fn(),
}));
const graphqlClient = {} as unknown as ExpoGraphqlClient;
function createBuildFragment(status: BuildStatus): BuildFragment {
return {
id: 'build-id',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
platform: AppPlatform.Android,
logFiles: [],
priority: BuildPriority.Normal,
app: {
__typename: 'App',
slug: 'test-project',
id: uuid(),
name: 'test-project',
ownerAccount: {
__typename: 'Account',
id: uuid(),
name: 'test-account',
},
},
status,
isForIosSimulator: false,
};
}
function createSpinner(): Ora {
const spinner: Record<string, unknown> = {
text: '',
prefixText: '',
isSpinning: true,
start: jest.fn(() => spinner),
stop: jest.fn(() => spinner),
stopAndPersist: jest.fn(() => spinner),
succeed: jest.fn(() => spinner),
fail: jest.fn(() => spinner),
warn: jest.fn(() => spinner),
};
return spinner as unknown as Ora;
}
function setBuildStatuses(statuses: BuildStatus[]): void {
const mocked = jest.mocked(BuildQuery.byIdAsync);
for (const status of statuses) {
mocked.mockResolvedValueOnce(createBuildFragment(status));
}
}
describe(waitForBuildEndAsync, () => {
let spinner: Ora;
let publish: () => void;
let watcherClose: jest.Mock;
let logs: JobLogs;
beforeEach(() => {
jest.clearAllMocks();
spinner = createSpinner();
logs = new Map();
publish = () => {};
watcherClose = jest.fn();
jest.mocked(ora).mockReturnValue(spinner);
jest.mocked(LogsWatcher).mockImplementation(((
_createRealtimeLogsClient: unknown,
onRealtimeLogs: () => void
) => {
publish = onRealtimeLogs;
return {
syncAsync: jest.fn(async () =>
new Map<string, LogsState>([
['build-id', { getLogs: () => logs, markCompleted: jest.fn() } as unknown as LogsState],
])
),
close: watcherClose,
};
}) as unknown as () => LogsWatcher);
jest.mocked(sleepAsync).mockImplementation(async () => {
publish();
});
});
it('does not create a watcher when the spinner is disabled', async () => {
jest.mocked(isSpinnerEnabled).mockReturnValue(false);
setBuildStatuses([BuildStatus.Finished]);
await waitForBuildEndAsync(graphqlClient, { buildIds: ['build-id'], accountName: 'acct' });
expect(LogsWatcher).not.toHaveBeenCalled();
expect(createRealtimeLogsClient).not.toHaveBeenCalled();
});
it('renders the current phase and its log tail on publication while in progress', async () => {
jest.mocked(isSpinnerEnabled).mockReturnValue(true);
setBuildStatuses([BuildStatus.InProgress, BuildStatus.Finished]);
jest.mocked(sleepAsync).mockImplementationOnce(async () => {
logs = groupLogLinesIntoSteps([
{ phase: BuildPhase.INSTALL_DEPENDENCIES, msg: 'npm ci' },
{ phase: BuildPhase.INSTALL_DEPENDENCIES, msg: 'added 1 package' },
]);
publish();
});
await waitForBuildEndAsync(graphqlClient, { buildIds: ['build-id'], accountName: 'acct' });
expect(LogsWatcher).toHaveBeenCalledTimes(1);
expect(spinner.text).toContain('Build in progress...');
expect(spinner.text).toContain('Install dependencies');
expect(spinner.text).toContain('added 1 package');
expect(watcherClose).toHaveBeenCalledTimes(1);
});
it('ignores a publication that arrives while the build is queued', async () => {
jest.mocked(isSpinnerEnabled).mockReturnValue(true);
setBuildStatuses([BuildStatus.InProgress, BuildStatus.InQueue, BuildStatus.Finished]);
logs = groupLogLinesIntoSteps([{ phase: BuildPhase.INSTALL_DEPENDENCIES, msg: 'npm ci' }]);
await waitForBuildEndAsync(graphqlClient, { buildIds: ['build-id'], accountName: 'acct' });
expect(jest.mocked(sleepAsync).mock.calls.length).toBe(2);
expect(spinner.text).toBe('Build queued...');
});
});
+114 -21
View File
@@ -30,6 +30,12 @@ import {
} from './errors';
import { transformMetadata } from './graphql';
import { LocalBuildMode, runLocalBuildAsync } from './local';
import {
formatActiveBuildText,
formatActiveBuildsText,
isBuildCompleted,
logSourceForBuild,
} from './logs';
import { collectMetadataAsync } from './metadata';
import { printDeprecationWarnings } from './utils/printBuildInfo';
import {
@@ -46,6 +52,8 @@ import { getExpoWebsiteBaseUrl } from '../api';
import { formatStarterSubscribeCommand } from '../billing/plans';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { EasCommandError } from '../commandUtils/errors';
import { LogsState } from '../commandUtils/logs/state';
import { LogsWatcher } from '../commandUtils/logs/watcher';
import { createFingerprintAsync } from '../fingerprint/cli';
import {
AppPlatform,
@@ -60,7 +68,7 @@ import {
import { BuildMutation, BuildResult } from '../graphql/mutations/BuildMutation';
import { BuildQuery } from '../graphql/queries/BuildQuery';
import Log, { learnMore, link } from '../log';
import { Ora, ora } from '../ora';
import { Ora, isSpinnerEnabled, ora, updateSpinnerText } from '../ora';
import {
RequestedPlatform,
appPlatformDisplayNames,
@@ -70,6 +78,7 @@ import {
import { maybeUploadFingerprintAsync } from '../project/maybeUploadFingerprintAsync';
import { resolveRuntimeVersionAsync } from '../project/resolveRuntimeVersionAsync';
import { uploadFileAtPathToGCSAsync } from '../uploads';
import { createRealtimeLogsClient } from '../utils/centrifuge';
import { formatBytes } from '../utils/files';
import { printJsonOnlyOutput } from '../utils/json';
import { createProgressTracker } from '../utils/progress';
@@ -538,19 +547,64 @@ export async function waitForBuildEndAsync(
originalSpinnerText = 'Waiting for builds to complete. You can press Ctrl+C to exit.';
spinner = ora('Waiting for builds to complete. You can press Ctrl+C to exit.').start();
}
while (true) {
const builds = await getBuildsSafelyAsync(graphqlClient, buildIds);
const { refetch } =
builds.length === 1
? await handleSingleBuildProgressAsync({ build: builds[0], accountName }, { spinner })
: await handleMultipleBuildsProgressAsync({ builds }, { spinner, originalSpinnerText });
if (!refetch) {
return builds;
let render = (): void => {};
const watcher = isSpinnerEnabled()
? new LogsWatcher(
() => createRealtimeLogsClient(graphqlClient),
() => {
render();
}
)
: null;
try {
while (true) {
render = (): void => {};
const builds = await getBuildsSafelyAsync(graphqlClient, buildIds);
const logsStates = await syncBuildLogsAsync(watcher, builds);
const installRender = (nextRender: () => void): void => {
render = nextRender;
};
const { refetch } =
builds.length === 1
? await handleSingleBuildProgressAsync(
{ build: builds[0], accountName },
{ spinner, logsStates, installRender }
)
: await handleMultipleBuildsProgressAsync(
{ builds },
{ spinner, originalSpinnerText, logsStates, installRender }
);
if (!refetch) {
return builds;
}
await sleepAsync(intervalSec * 1000);
}
await sleepAsync(intervalSec * 1000);
} finally {
watcher?.close();
}
}
async function syncBuildLogsAsync(
watcher: LogsWatcher | null,
builds: MaybeBuildFragment[]
): Promise<Map<string, LogsState>> {
if (!watcher) {
return new Map();
}
const existingBuilds = builds.filter(isBuildFragment);
const logsStates = await watcher.syncAsync(existingBuilds.map(build => logSourceForBuild(build)));
for (const build of existingBuilds) {
if (isBuildCompleted(build.status)) {
nullthrows(
logsStates.get(build.id),
'syncAsync must have been called before markCompleted'
).markCompleted();
}
}
return logsStates;
}
async function getBuildsSafelyAsync(
graphqlClient: ExpoGraphqlClient,
buildIds: string[]
@@ -570,6 +624,11 @@ interface BuildProgressResult {
refetch: boolean;
}
type BuildLogsProgressOptions = {
logsStates: Map<string, LogsState>;
installRender: (render: () => void) => void;
};
let queueProgressBarStarted = false;
const queueProgressBar = new cliProgress.SingleBar(
{ format: '|{bar}| {estimatedWaitTime}' },
@@ -587,10 +646,12 @@ async function handleSingleBuildProgressAsync(
build: MaybeBuildFragment;
accountName: string;
},
{ spinner }: { spinner: Ora }
{ spinner, logsStates, installRender }: { spinner: Ora } & BuildLogsProgressOptions
): Promise<BuildProgressResult> {
if (build === null) {
spinner.text = 'Could not fetch the build status. Check your network connection.';
updateSpinnerText(spinner, {
text: 'Could not fetch the build status. Check your network connection.',
});
return { refetch: true };
}
@@ -615,16 +676,18 @@ async function handleSingleBuildProgressAsync(
statusNewSetAt ??= now;
const newStatusDurationMs = now - statusNewSetAt;
if (newStatusDurationMs < NEW_STATUS_GRACE_PERIOD_MS) {
spinner.text = 'Waiting for build to get enqueued…';
updateSpinnerText(spinner, { text: 'Waiting for build to get enqueued…' });
} else {
spinner.text = `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
formatAccountBillingUrl(accountName)
)}.`;
updateSpinnerText(spinner, {
text: `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
formatAccountBillingUrl(accountName)
)}.`,
});
}
break;
}
case BuildStatus.InQueue: {
spinner.text = 'Build queued...';
updateSpinnerText(spinner, { text: 'Build queued...' });
const progressBarPayload =
typeof build.estimatedWaitTimeLeftSeconds === 'number'
? { estimatedWaitTime: formatEstimatedWaitTime(build.estimatedWaitTimeLeftSeconds) }
@@ -661,9 +724,17 @@ async function handleSingleBuildProgressAsync(
case BuildStatus.Canceled:
spinner.fail('Build canceled');
return { refetch: false };
case BuildStatus.InProgress:
spinner.text = 'Build in progress...';
case BuildStatus.InProgress: {
const logsState = logsStates.get(build.id);
const render = (): void => {
updateSpinnerText(spinner, {
text: formatActiveBuildText('Build in progress...', logsState?.getLogs() ?? new Map()),
});
};
installRender(render);
render();
break;
}
case BuildStatus.Errored:
spinner.fail('Build failed');
if (build.error) {
@@ -698,7 +769,12 @@ const platforms = [AppPlatform.Android, AppPlatform.Ios];
async function handleMultipleBuildsProgressAsync(
{ builds: maybeBuilds }: { builds: MaybeBuildFragment[] },
{ spinner, originalSpinnerText }: { spinner: Ora; originalSpinnerText: string }
{
spinner,
originalSpinnerText,
logsStates,
installRender,
}: { spinner: Ora; originalSpinnerText: string } & BuildLogsProgressOptions
): Promise<BuildProgressResult> {
const buildCount = maybeBuilds.length;
const builds = maybeBuilds.filter<BuildFragment>(isBuildFragment);
@@ -732,7 +808,24 @@ async function handleMultipleBuildsProgressAsync(
someNew &&
statusNewSetAt !== null &&
Date.now() - statusNewSetAt >= NEW_STATUS_GRACE_PERIOD_MS;
spinner.text = formatPendingBuildsText(originalSpinnerText, builds, showConcurrencyWarning);
const pendingBuildsText = formatPendingBuildsText(
originalSpinnerText,
builds,
showConcurrencyWarning
);
const inProgressBuilds = builds.filter(build => build.status === BuildStatus.InProgress);
const render = (): void => {
const text = formatActiveBuildsText(
pendingBuildsText,
inProgressBuilds.map(build => ({
build,
logs: logsStates.get(build.id)?.getLogs() ?? new Map(),
}))
);
updateSpinnerText(spinner, { text });
};
installRender(render);
render();
return { refetch: true };
}
}
+113
View File
@@ -0,0 +1,113 @@
import { BuildPhase, buildPhaseDisplayName } from '@expo/eas-build-job';
import { choicesFromJobLogs, stepLogTail } from '../commandUtils/logs/format';
import { parseLogLines } from '../commandUtils/logs/parseLogs';
import { JobLogs, RawLogLine } from '../commandUtils/logs/types';
import { LogSource } from '../commandUtils/logs/watcher';
import fetch from '../fetch';
import { BuildFragment, BuildStatus, RealtimeLogsTargetType } from '../graphql/generated';
import Log from '../log';
import { appPlatformDisplayNames, appPlatformEmojis } from '../platform';
import formatFields, { FormatFieldsItem } from '../utils/formatFields';
const LOGS_FETCH_TIMEOUT_MS = 5_000;
const SINGLE_BUILD_MAX_LOG_LINES = 5;
const MULTIPLE_BUILDS_MAX_LOG_LINES = 3;
async function fetchAndParseLogsForBuildAsync(build: BuildFragment): Promise<RawLogLine[] | null> {
const logFileUrl = build.logFiles[0];
if (!logFileUrl) {
return null;
}
const response = await fetch(logFileUrl, { timeout: LOGS_FETCH_TIMEOUT_MS });
const rawLogs = await response.text();
if (!rawLogs) {
return null;
}
const { logLines, errors } = parseLogLines(rawLogs);
for (const error of errors) {
Log.debug(`Failed to parse a log line: ${error.message}`);
}
return logLines;
}
export function logSourceForBuild(build: BuildFragment): LogSource {
return {
key: build.id,
realtimeTarget: { type: RealtimeLogsTargetType.Build, id: build.id },
isInProgress: build.status === BuildStatus.InProgress,
fetchRawLogLinesAsync: async () => await fetchAndParseLogsForBuildAsync(build),
};
}
export function isBuildCompleted(status: BuildStatus): boolean {
switch (status) {
case BuildStatus.Finished:
case BuildStatus.Errored:
case BuildStatus.Canceled:
return true;
case BuildStatus.New:
case BuildStatus.InQueue:
case BuildStatus.InProgress:
case BuildStatus.PendingCancel:
return false;
}
}
function stepDisplayName(label: string): string {
return Object.hasOwn(buildPhaseDisplayName, label)
? buildPhaseDisplayName[label as BuildPhase]
: label;
}
function activeBuildFields(
logs: JobLogs,
maxLogLines: number,
buildLabel: string | null
): FormatFieldsItem[] {
const steps = choicesFromJobLogs(logs);
if (steps.length === 0) {
return [];
}
const currentStep = steps[steps.length - 1];
const fields: FormatFieldsItem[] = [{ label: '', value: '' }];
if (buildLabel !== null) {
fields.push({ label: ' Build', value: buildLabel });
}
fields.push({ label: ' Current phase', value: stepDisplayName(currentStep.name) });
if (currentStep.logLines?.length) {
fields.push({ label: ' Current logs', value: '' });
for (const logLine of stepLogTail(currentStep, maxLogLines)) {
fields.push({ label: '', value: logLine });
}
}
return fields;
}
function appendFields(baseText: string, fields: FormatFieldsItem[]): string {
if (fields.length === 0) {
return baseText;
}
return `${baseText}\n${formatFields(fields)}`;
}
export function formatActiveBuildText(baseText: string, logs: JobLogs): string {
return appendFields(baseText, activeBuildFields(logs, SINGLE_BUILD_MAX_LOG_LINES, null));
}
export function formatActiveBuildsText(
baseText: string,
buildsWithLogs: { build: BuildFragment; logs: JobLogs }[]
): string {
return appendFields(
baseText,
buildsWithLogs.flatMap(({ build, logs }) =>
activeBuildFields(
logs,
MULTIPLE_BUILDS_MAX_LOG_LINES,
`${appPlatformEmojis[build.platform]} ${appPlatformDisplayNames[build.platform]}`
)
)
);
}
@@ -45,11 +45,6 @@ describe(getProjectIdAsync, () => {
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_1',
@@ -420,11 +415,6 @@ describe(getProjectIdAsync, () => {
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_1',
@@ -524,11 +514,6 @@ describe(getProjectIdAsync, () => {
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
accounts: [
{
id: 'account_id_1',
@@ -1,7 +1,7 @@
import { groupLogLinesIntoSteps, mergeLogLines, parseLogLines } from '../parseLogs';
import { WorkflowRawLogLine } from '../../types';
import { RawLogLine } from '../types';
function logLine(overrides: Partial<WorkflowRawLogLine> = {}): WorkflowRawLogLine {
function logLine(overrides: Partial<RawLogLine> = {}): RawLogLine {
return { msg: 'a message', ...overrides };
}
@@ -0,0 +1,110 @@
import { LogsState } from '../state';
import { RawLogLine } from '../types';
function fileLine(logId: string, msg: string, buildStepId = 'install'): RawLogLine {
return { logId, buildStepId, msg };
}
function stepMessages(logsState: LogsState): string[] {
return Array.from(logsState.getLogs().values()).flatMap(group =>
group.logLines.map(logLine => logLine.msg)
);
}
describe(LogsState, () => {
it('hides realtime lines until the file logs reach them', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('5', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first']);
});
it('shows realtime lines once a published logId also appears in the file', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
});
it('shows realtime logs when a publication repeats a logId already in the file', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('shows buffered realtime lines once the job is completed', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('9', 'pushed')]);
logsState.markCompleted();
logsState.markCompleted();
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('folds later publications once completion showed them', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('9', 'buffered')]);
logsState.markCompleted();
logsState.ingestRealtimeLogLines([fileLine('10', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'buffered', 'pushed']);
});
it('replaces the file snapshot instead of accumulating it', () => {
const logsState = new LogsState();
const keyless: RawLogLine = { buildStepId: 'install', msg: 'submission log' };
logsState.ingestFileLogLines([keyless]);
logsState.ingestFileLogLines([keyless]);
expect(stepMessages(logsState)).toEqual(['submission log']);
});
it('drops a buffered realtime line once it lands in the file', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('ignores publications that are not arrays of log lines', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
expect(logsState.ingestRealtimeLogLines({ logId: '2', msg: 'not an array' })).toBe(false);
expect(logsState.ingestRealtimeLogLines(['a string', 42, null])).toBe(false);
expect(logsState.ingestRealtimeLogLines([{ msg: 'no logId' }])).toBe(false);
expect(stepMessages(logsState)).toEqual(['first']);
});
it('deduplicates a line republished on the same channel', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('deduplicates a line republished later', () => {
const logsState = new LogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
});
});
@@ -0,0 +1,153 @@
import { RealtimeLogsTargetType } from '../../../graphql/generated';
import { RealtimeLogsClient } from '../../../utils/centrifuge';
import { LogSource, LogsWatcher } from '../watcher';
function createFakeClient(): {
client: RealtimeLogsClient;
publish: (data: unknown) => void;
subscribeCalls: unknown[];
closeCount: () => number;
} {
const listeners: ((data: unknown) => void)[] = [];
const subscribeCalls: unknown[] = [];
let closed = 0;
return {
subscribeCalls,
closeCount: () => closed,
publish: data => {
listeners.forEach(listener => {
listener(data);
});
},
client: {
subscribeAsync: async (args, onPublication) => {
subscribeCalls.push(args);
listeners.push(onPublication);
return { close: () => closed++ };
},
close: () => closed++,
},
};
}
describe(LogsWatcher, () => {
let fetchRawLogLinesAsync: jest.Mock;
beforeEach(() => {
fetchRawLogLinesAsync = jest
.fn()
.mockResolvedValue([{ logId: '1', buildStepId: 'install', msg: 'from the file' }]);
});
function inProgressSource(overrides: Partial<LogSource> = {}): LogSource {
return {
key: 'job1',
realtimeTarget: { type: RealtimeLogsTargetType.JobRun, id: 'job-run-id' },
isInProgress: true,
fetchRawLogLinesAsync,
...overrides,
};
}
it('subscribes once per in-progress source', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
await watcher.syncAsync([inProgressSource()]);
expect(fake.subscribeCalls).toEqual([
{ target: { type: RealtimeLogsTargetType.JobRun, id: 'job-run-id' } },
]);
});
it('reports realtime logs as they arrive', async () => {
const fake = createFakeClient();
const onRealtimeLogs = jest.fn();
const watcher = new LogsWatcher(() => fake.client, onRealtimeLogs);
await watcher.syncAsync([inProgressSource()]);
fake.publish([{ logId: '2', buildStepId: 'install', msg: 'pushed' }]);
expect(onRealtimeLogs).toHaveBeenCalledTimes(1);
});
it('does not report a publication that carries no usable log lines', async () => {
const fake = createFakeClient();
const onRealtimeLogs = jest.fn();
const watcher = new LogsWatcher(() => fake.client, onRealtimeLogs);
await watcher.syncAsync([inProgressSource()]);
fake.publish([{ msg: 'no logId' }]);
expect(onRealtimeLogs).not.toHaveBeenCalled();
});
it('still fetches logs when the realtime logs client is unavailable', async () => {
const watcher = new LogsWatcher(
() => null,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
expect(fetchRawLogLinesAsync).toHaveBeenCalledTimes(1);
});
it('fetches logs on every sync while in progress, and stops once the source is not', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
await watcher.syncAsync([inProgressSource()]);
expect(fetchRawLogLinesAsync).toHaveBeenCalledTimes(2);
await watcher.syncAsync([inProgressSource({ isInProgress: false })]);
expect(fetchRawLogLinesAsync).toHaveBeenCalledTimes(2);
});
it('does not fetch logs for a source that is not in progress', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource({ isInProgress: false })]);
expect(fetchRawLogLinesAsync).not.toHaveBeenCalled();
});
it('closes the subscription when a source leaves in-progress', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
expect(fake.closeCount()).toBe(0);
await watcher.syncAsync([inProgressSource({ isInProgress: false })]);
expect(fake.closeCount()).toBe(1);
});
it('closes the client and its subscriptions', async () => {
const fake = createFakeClient();
const watcher = new LogsWatcher(
() => fake.client,
() => {}
);
await watcher.syncAsync([inProgressSource()]);
watcher.close();
expect(fake.closeCount()).toBe(2);
});
});
@@ -0,0 +1,25 @@
import { JobLogs, LogLine } from './types';
import { Choice } from '../../prompts';
export function choicesFromJobLogs(
logs: JobLogs
): (Choice & { name: string; status: string; logLines: LogLine[] | undefined })[] {
return Array.from(logs.values())
.map(({ key, label, result, logLines }) => ({
title: `${label} - ${result ?? ''}`,
name: label,
status: result ?? '',
value: key,
logLines,
}))
.filter(step => step.status !== 'skipped');
}
export function stepLogTail(
step: { logLines?: LogLine[] },
maxLogLines: number // -1 means no limit
): string[] {
const logLines = step.logLines ?? [];
const tail = maxLogLines === -1 ? logLines : logLines.slice(-maxLogLines);
return tail.map(line => line.msg);
}
@@ -0,0 +1,56 @@
import { JobLogs, RawLogLine } from './types';
import uniqBy from '../../utils/expodash/uniqBy';
export function parseLogLines(rawLogs: string): {
logLines: RawLogLine[];
errors: Error[];
} {
const logLines: RawLogLine[] = [];
const errors: Error[] = [];
for (const rawLogLine of rawLogs.split('\n')) {
if (!rawLogLine) {
continue;
}
try {
logLines.push(JSON.parse(rawLogLine));
} catch (err) {
errors.push(err as Error);
}
}
return { logLines, errors };
}
export function mergeLogLines<T extends RawLogLine>(...logLineGroups: T[][]): T[] {
return uniqBy(logLineGroups.flat().reverse(), logLine => logLine.logId ?? Symbol()).reverse();
}
export function groupLogLinesIntoSteps(
logLines: RawLogLine[],
accumulator: JobLogs = new Map()
): JobLogs {
for (const logLine of logLines) {
const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } = logLine;
const stepKey = buildStepId ?? buildStepDisplayName ?? phase;
const stepLabel = buildStepDisplayName ?? buildStepId ?? phase;
if (!stepKey || !stepLabel || !msg) {
continue;
}
let logGroup = accumulator.get(stepKey);
if (!logGroup) {
logGroup = { key: stepKey, label: stepLabel, logLines: [] };
accumulator.set(stepKey, logGroup);
}
if (buildStepDisplayName) {
logGroup.label = buildStepDisplayName;
}
if (logGroup.result === undefined && (marker === 'end-step' || marker === 'END_PHASE')) {
logGroup.result = result ?? '';
}
logGroup.logLines.push({ time, msg, result, marker, err });
}
return accumulator;
}
@@ -0,0 +1,94 @@
import { groupLogLinesIntoSteps, mergeLogLines } from './parseLogs';
import { JobLogs, RawLogLine } from './types';
type RealtimeLogLine = RawLogLine & { logId: string };
function isRealtimeLogLine(entry: unknown): entry is RealtimeLogLine {
return (
typeof entry === 'object' &&
entry !== null &&
typeof (entry as Partial<RawLogLine>).logId === 'string'
);
}
export class LogsState {
private fileLogIds = new Set<string>();
private realtimeLogLines: RealtimeLogLine[] = [];
private realtimeLogIds = new Set<string>();
private haveFileLogsCaughtUp = false;
private realtimeLogsRevealed = false;
private jobLogs: JobLogs = new Map();
public ingestFileLogLines(logLines: RawLogLine[]): void {
this.fileLogIds = new Set(logLines.flatMap(logLine => (logLine.logId ? [logLine.logId] : [])));
if (
!this.haveFileLogsCaughtUp &&
this.realtimeLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
) {
this.haveFileLogsCaughtUp = true;
}
this.realtimeLogLines = this.realtimeLogLines.filter(
logLine => !this.fileLogIds.has(logLine.logId)
);
this.realtimeLogIds = new Set(this.realtimeLogLines.map(logLine => logLine.logId));
if (this.haveFileLogsCaughtUp) {
this.realtimeLogsRevealed = true;
}
this.jobLogs = groupLogLinesIntoSteps(
mergeLogLines(logLines, this.realtimeLogsRevealed ? this.realtimeLogLines : [])
);
}
public ingestRealtimeLogLines(data: unknown): boolean {
if (!Array.isArray(data)) {
return false;
}
const publishedLogLines = data.filter(isRealtimeLogLine);
if (publishedLogLines.length === 0) {
return false;
}
if (
!this.haveFileLogsCaughtUp &&
publishedLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
) {
this.haveFileLogsCaughtUp = true;
}
const newLogLines = publishedLogLines.filter(
logLine => !this.fileLogIds.has(logLine.logId) && !this.realtimeLogIds.has(logLine.logId)
);
for (const logLine of newLogLines) {
this.realtimeLogLines.push(logLine);
this.realtimeLogIds.add(logLine.logId);
}
if (!this.realtimeLogsRevealed) {
if (this.haveFileLogsCaughtUp) {
this.revealRealtimeLogs();
}
} else if (newLogLines.length > 0) {
groupLogLinesIntoSteps(newLogLines, this.jobLogs);
}
return true;
}
public markCompleted(): void {
this.revealRealtimeLogs();
}
public getLogs(): JobLogs {
return this.jobLogs;
}
private revealRealtimeLogs(): void {
if (this.realtimeLogsRevealed) {
return;
}
this.realtimeLogsRevealed = true;
groupLogLinesIntoSteps(this.realtimeLogLines, this.jobLogs);
}
}
@@ -0,0 +1,23 @@
export type RawLogLine = {
logId?: string;
buildStepId?: string;
buildStepDisplayName?: string;
phase?: string;
time?: string;
msg?: string;
result?: string;
marker?: string;
err?: any;
};
export type LogLine = Pick<RawLogLine, 'time' | 'result' | 'marker' | 'err'> &
Required<Pick<RawLogLine, 'msg'>>;
export type StepLogs = {
key: string;
label: string;
result?: string;
logLines: LogLine[];
};
export type JobLogs = Map<string, StepLogs>;
@@ -0,0 +1,102 @@
import { LogsState } from './state';
import { RawLogLine } from './types';
import { RealtimeLogsTargetInput } from '../../graphql/generated';
import Log from '../../log';
import { RealtimeLogsClient, RealtimeLogsSubscription } from '../../utils/centrifuge';
import nullthrows from 'nullthrows';
export type LogSource = {
key: string;
realtimeTarget: RealtimeLogsTargetInput | null;
isInProgress: boolean;
fetchRawLogLinesAsync: () => Promise<RawLogLine[] | null>;
};
type TrackedSource = {
logsState: LogsState;
subscription: RealtimeLogsSubscription | null;
};
export class LogsWatcher {
private readonly trackedSources = new Map<string, TrackedSource>();
private realtimeLogsClient?: RealtimeLogsClient | null;
constructor(
private readonly createRealtimeLogsClient: () => RealtimeLogsClient | null,
private readonly onRealtimeLogs: () => void
) {}
public async syncAsync(sources: LogSource[]): Promise<Map<string, LogsState>> {
await Promise.all(sources.map(source => this.syncSourceAsync(source)));
return new Map(
sources.map(source => [
source.key,
nullthrows(
this.trackedSources.get(source.key),
'source has to be in tracked sources after it was synced'
).logsState,
])
);
}
public close(): void {
for (const trackedSource of this.trackedSources.values()) {
trackedSource.subscription?.close();
trackedSource.subscription = null;
}
this.realtimeLogsClient?.close();
}
private async syncSourceAsync(source: LogSource): Promise<void> {
let trackedSource = this.trackedSources.get(source.key);
if (!trackedSource) {
trackedSource = { logsState: new LogsState(), subscription: null };
this.trackedSources.set(source.key, trackedSource);
}
if (!source.isInProgress) {
trackedSource.subscription?.close();
trackedSource.subscription = null;
return;
}
await Promise.all([
trackedSource.subscription ? Promise.resolve() : this.subscribeAsync(source, trackedSource),
this.fetchLogsAsync(source, trackedSource),
]);
}
private async fetchLogsAsync(source: LogSource, trackedSource: TrackedSource): Promise<void> {
try {
const logLines = await source.fetchRawLogLinesAsync();
if (logLines) {
trackedSource.logsState.ingestFileLogLines(logLines);
}
} catch (err: any) {
Log.debug(`Failed to fetch logs for job ${source.key}: ${err.message}`);
}
}
private getRealtimeLogsClient(): RealtimeLogsClient | null {
if (this.realtimeLogsClient === undefined) {
this.realtimeLogsClient = this.createRealtimeLogsClient();
}
return this.realtimeLogsClient;
}
private async subscribeAsync(source: LogSource, trackedSource: TrackedSource): Promise<void> {
const target = source.realtimeTarget;
if (!target) {
return;
}
const realtimeLogsClient = this.getRealtimeLogsClient();
if (!realtimeLogsClient) {
return;
}
trackedSource.subscription = await realtimeLogsClient.subscribeAsync({ target }, data => {
if (trackedSource.logsState.ingestRealtimeLogLines(data)) {
this.onRealtimeLogs();
}
});
}
}
@@ -1,9 +1,9 @@
import { getMockWorkflowCustomJobFragment } from '../../../../__tests__/commands/utils';
import { BuildQuery } from '../../../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../../../context/contextUtils/createGraphqlClient';
import { getMockWorkflowCustomJobFragment } from '../../../__tests__/commands/utils';
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
import { fetchRawLogsForJobAsync } from '../fetchLogs';
jest.mock('../../../../graphql/queries/BuildQuery');
jest.mock('../../../graphql/queries/BuildQuery');
describe(fetchRawLogsForJobAsync, () => {
afterEach(() => {
@@ -0,0 +1,108 @@
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
import {
RealtimeLogsTargetType,
WorkflowJobStatus,
WorkflowJobType,
} from '../../../graphql/generated';
import { fetchRawLogsForJobAsync } from '../fetchLogs';
import { logSourceForWorkflowJob } from '../logs';
import { WorkflowJobResult } from '../types';
jest.mock('../fetchLogs');
function inProgressJob(): WorkflowJobResult {
const job = getMockWorkflowRunWithJobsFragment().jobs[0];
return { ...job, status: WorkflowJobStatus.InProgress };
}
describe(logSourceForWorkflowJob, () => {
afterEach(() => {
jest.clearAllMocks();
});
it('keys the source by the job id', () => {
expect(logSourceForWorkflowJob({} as any, inProgressJob()).key).toBe('job1');
});
it('is in progress only while the job is in progress', () => {
const job = inProgressJob();
expect(logSourceForWorkflowJob({} as any, job).isInProgress).toBe(true);
for (const status of [
WorkflowJobStatus.New,
WorkflowJobStatus.ActionRequired,
WorkflowJobStatus.PendingCancel,
WorkflowJobStatus.Success,
WorkflowJobStatus.Failure,
WorkflowJobStatus.Canceled,
WorkflowJobStatus.Skipped,
]) {
expect(logSourceForWorkflowJob({} as any, { ...job, status }).isInProgress).toBe(false);
}
});
it('targets the turtle job run when there is one', () => {
const job = { ...inProgressJob(), turtleJobRun: { id: 'job-run-id' } } as WorkflowJobResult;
expect(logSourceForWorkflowJob({} as any, job).realtimeTarget).toEqual({
type: RealtimeLogsTargetType.JobRun,
id: 'job-run-id',
});
});
it('falls back to the build when there is no turtle job run', () => {
const withTurtleBuild = {
...inProgressJob(),
turtleJobRun: null,
turtleBuild: { id: 'build-id' },
} as WorkflowJobResult;
const withBuildOutput = {
...inProgressJob(),
turtleJobRun: null,
turtleBuild: null,
outputs: { build_id: 'output-build-id' },
} as WorkflowJobResult;
expect(logSourceForWorkflowJob({} as any, withTurtleBuild).realtimeTarget).toEqual({
type: RealtimeLogsTargetType.Build,
id: 'build-id',
});
expect(logSourceForWorkflowJob({} as any, withBuildOutput).realtimeTarget).toEqual({
type: RealtimeLogsTargetType.Build,
id: 'output-build-id',
});
});
it('has no realtime target when neither is present', () => {
const job = {
...inProgressJob(),
turtleJobRun: null,
turtleBuild: null,
outputs: null,
} as WorkflowJobResult;
expect(logSourceForWorkflowJob({} as any, job).realtimeTarget).toBeNull();
});
it('fetches and parses the job logs with the graphql client it was given', async () => {
const graphqlClient = {} as any;
const job = { ...inProgressJob(), type: WorkflowJobType.Build } as WorkflowJobResult;
jest
.mocked(fetchRawLogsForJobAsync)
.mockResolvedValue(
[
'{"logId":"1","buildStepId":"install","msg":"npm ci"}',
'{"logId":"2","buildStepId":"install","msg":"done"}',
].join('\n')
);
await expect(
logSourceForWorkflowJob(graphqlClient, job).fetchRawLogLinesAsync()
).resolves.toEqual([
{ logId: '1', buildStepId: 'install', msg: 'npm ci' },
{ logId: '2', buildStepId: 'install', msg: 'done' },
]);
expect(fetchRawLogsForJobAsync).toHaveBeenCalledWith({ graphqlClient }, job);
});
});
@@ -1,15 +1,15 @@
import { getMockWorkflowRunWithJobsFragment } from '../../../__tests__/commands/utils';
import { WorkflowJobStatus } from '../../../graphql/generated';
import { groupLogLinesIntoSteps, parseLogLines } from '../logs/parseLogs';
import { WorkflowLogs, WorkflowRawLogLine } from '../types';
import { groupLogLinesIntoSteps, parseLogLines } from '../../logs/parseLogs';
import { JobLogs, RawLogLine } from '../../logs/types';
import { formatActiveWorkflowRun, formatFailedWorkflowRun } from '../utils';
function jobWithLogs(
logLines: WorkflowRawLogLine[],
logLines: RawLogLine[],
status: WorkflowJobStatus = WorkflowJobStatus.InProgress
): {
job: ReturnType<typeof getMockWorkflowRunWithJobsFragment>['jobs'][number];
logs: WorkflowLogs;
logs: JobLogs;
} {
return {
job: { ...getMockWorkflowRunWithJobsFragment().jobs[0], status },
@@ -17,7 +17,7 @@ function jobWithLogs(
};
}
function stepLines(buildStepId: string, count: number): WorkflowRawLogLine[] {
function stepLines(buildStepId: string, count: number): RawLogLine[] {
return Array.from({ length: count }, (_, index) => ({ buildStepId, msg: `line${index}` }));
}
@@ -1,6 +1,6 @@
import { WorkflowJobResult } from '../types';
import { BuildQuery } from '../../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
import { WorkflowJobResult } from './types';
import { BuildQuery } from '../../graphql/queries/BuildQuery';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
export async function fetchRawLogsForJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
@@ -0,0 +1,59 @@
import { fetchRawLogsForJobAsync } from './fetchLogs';
import { WorkflowJobResult } from './types';
import {
RealtimeLogsTargetInput,
RealtimeLogsTargetType,
WorkflowJobStatus,
} from '../../graphql/generated';
import Log from '../../log';
import { groupLogLinesIntoSteps, parseLogLines } from '../logs/parseLogs';
import { JobLogs, RawLogLine } from '../logs/types';
import { LogSource } from '../logs/watcher';
import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
async function fetchAndParseLogsFromJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<RawLogLine[] | null> {
const rawLogs = await fetchRawLogsForJobAsync(state, job);
if (!rawLogs) {
return null;
}
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
const { logLines, errors } = parseLogLines(rawLogs);
for (const error of errors) {
Log.debug(`Failed to parse a log line: ${error.message}`);
}
return logLines;
}
export async function fetchAndProcessLogsFromJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<JobLogs | null> {
const logLines = await fetchAndParseLogsFromJobAsync(state, job);
return logLines && groupLogLinesIntoSteps(logLines);
}
export function logSourceForWorkflowJob(
graphqlClient: ExpoGraphqlClient,
job: WorkflowJobResult
): LogSource {
return {
key: job.id,
realtimeTarget: realtimeLogsTargetForJob(job),
isInProgress: job.status === WorkflowJobStatus.InProgress,
fetchRawLogLinesAsync: async () => await fetchAndParseLogsFromJobAsync({ graphqlClient }, job),
};
}
function realtimeLogsTargetForJob(job: WorkflowJobResult): RealtimeLogsTargetInput | null {
if (job.turtleJobRun?.id) {
return { type: RealtimeLogsTargetType.JobRun, id: job.turtleJobRun.id };
}
const buildId = job.turtleBuild?.id ?? job.outputs?.build_id;
if (buildId) {
return { type: RealtimeLogsTargetType.Build, id: buildId };
}
return null;
}
@@ -1,308 +0,0 @@
import { getMockWorkflowRunWithJobsFragment } from '../../../../__tests__/commands/utils';
import { RealtimeLogsTargetType, WorkflowJobStatus } from '../../../../graphql/generated';
import { RealtimeLogsClient } from '../../../../utils/centrifuge';
import { fetchAndParseLogsFromJobAsync } from '../parseLogs';
import { WorkflowJobLogsState, WorkflowRunLogsWatcher, realtimeLogsTargetForJob } from '../watcher';
import { WorkflowJobResult, WorkflowRawLogLine } from '../../types';
jest.mock('../parseLogs', () => ({
...jest.requireActual('../parseLogs'),
fetchAndParseLogsFromJobAsync: jest.fn(),
}));
function createFakeClient(): {
client: RealtimeLogsClient;
publish: (data: unknown) => void;
subscribeCalls: unknown[];
closeCount: () => number;
} {
const listeners: ((data: unknown) => void)[] = [];
const subscribeCalls: unknown[] = [];
let closed = 0;
return {
subscribeCalls,
closeCount: () => closed,
publish: data => {
listeners.forEach(listener => {
listener(data);
});
},
client: {
subscribeAsync: async (args, onPublication) => {
subscribeCalls.push(args);
listeners.push(onPublication);
return { close: () => closed++ };
},
close: () => closed++,
},
};
}
function inProgressJob(): WorkflowJobResult {
const job = getMockWorkflowRunWithJobsFragment().jobs[0];
return { ...job, status: WorkflowJobStatus.InProgress };
}
describe(realtimeLogsTargetForJob, () => {
it('targets the turtle job run when there is one', () => {
const job = { ...inProgressJob(), turtleJobRun: { id: 'job-run-id' } } as WorkflowJobResult;
expect(realtimeLogsTargetForJob(job)).toEqual({
type: RealtimeLogsTargetType.JobRun,
id: 'job-run-id',
});
});
it('falls back to the build when there is no turtle job run', () => {
const job = {
...inProgressJob(),
turtleJobRun: null,
turtleBuild: { id: 'build-id' },
} as WorkflowJobResult;
expect(realtimeLogsTargetForJob(job)).toEqual({
type: RealtimeLogsTargetType.Build,
id: 'build-id',
});
});
it('has no target when neither is present', () => {
const job = {
...inProgressJob(),
turtleJobRun: null,
turtleBuild: null,
outputs: null,
} as WorkflowJobResult;
expect(realtimeLogsTargetForJob(job)).toBeNull();
});
});
describe(WorkflowRunLogsWatcher, () => {
beforeEach(() => {
jest
.mocked(fetchAndParseLogsFromJobAsync)
.mockResolvedValue([{ logId: '1', buildStepId: 'install', msg: 'from the file' }]);
});
afterEach(() => {
jest.clearAllMocks();
});
it('subscribes once per in-progress job', async () => {
const fake = createFakeClient();
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => fake.client,
() => {}
);
const job = inProgressJob();
await watcher.syncJobsAsync([job]);
await watcher.syncJobsAsync([job]);
expect(fake.subscribeCalls).toHaveLength(1);
});
it('reports realtime logs as they arrive', async () => {
const fake = createFakeClient();
const onRealtimeLogs = jest.fn();
const watcher = new WorkflowRunLogsWatcher({} as any, () => fake.client, onRealtimeLogs);
await watcher.syncJobsAsync([inProgressJob()]);
fake.publish([{ logId: '2', buildStepId: 'install', msg: 'pushed' }]);
expect(onRealtimeLogs).toHaveBeenCalledTimes(1);
});
it('does not report a publication that carries no usable log lines', async () => {
const fake = createFakeClient();
const onRealtimeLogs = jest.fn();
const watcher = new WorkflowRunLogsWatcher({} as any, () => fake.client, onRealtimeLogs);
await watcher.syncJobsAsync([inProgressJob()]);
fake.publish([{ msg: 'no logId' }]);
expect(onRealtimeLogs).not.toHaveBeenCalled();
});
it('still fetches logs when the realtime logs client is unavailable', async () => {
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => null,
() => {}
);
await watcher.syncJobsAsync([inProgressJob()]);
expect(fetchAndParseLogsFromJobAsync).toHaveBeenCalledTimes(1);
});
it('fetches logs on every sync while in progress, and stops once the job completes', async () => {
const fake = createFakeClient();
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => fake.client,
() => {}
);
const job = inProgressJob();
await watcher.syncJobsAsync([job]);
await watcher.syncJobsAsync([job]);
expect(fetchAndParseLogsFromJobAsync).toHaveBeenCalledTimes(2);
await watcher.syncJobsAsync([{ ...job, status: WorkflowJobStatus.Success }]);
expect(fetchAndParseLogsFromJobAsync).toHaveBeenCalledTimes(2);
});
it('does not fetch logs for a job that has not started', async () => {
const fake = createFakeClient();
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => fake.client,
() => {}
);
await watcher.syncJobsAsync([{ ...inProgressJob(), status: WorkflowJobStatus.New }]);
expect(fetchAndParseLogsFromJobAsync).not.toHaveBeenCalled();
});
it('closes the subscription when a job leaves in-progress', async () => {
const fake = createFakeClient();
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => fake.client,
() => {}
);
const job = inProgressJob();
await watcher.syncJobsAsync([job]);
expect(fake.closeCount()).toBe(0);
await watcher.syncJobsAsync([{ ...job, status: WorkflowJobStatus.Failure }]);
expect(fake.closeCount()).toBe(1);
});
it('closes the client and its subscriptions', async () => {
const fake = createFakeClient();
const watcher = new WorkflowRunLogsWatcher(
{} as any,
() => fake.client,
() => {}
);
await watcher.syncJobsAsync([inProgressJob()]);
watcher.close();
expect(fake.closeCount()).toBe(2);
});
});
function fileLine(logId: string, msg: string, buildStepId = 'install'): WorkflowRawLogLine {
return { logId, buildStepId, msg };
}
function stepMessages(logsState: WorkflowJobLogsState): string[] {
return Array.from(logsState.getLogs().values()).flatMap(group =>
group.logLines.map(logLine => logLine.msg)
);
}
describe(WorkflowJobLogsState, () => {
it('hides realtime lines until the file logs reach them', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('5', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first']);
});
it('shows realtime lines once a published logId also appears in the file', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
});
it('shows realtime logs when a publication repeats a logId already in the file', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('shows buffered realtime lines once the job is completed', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('9', 'pushed')]);
logsState.markCompleted();
logsState.markCompleted();
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('folds later publications once completion showed them', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('9', 'buffered')]);
logsState.markCompleted();
logsState.ingestRealtimeLogLines([fileLine('10', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'buffered', 'pushed']);
});
it('replaces the file snapshot instead of accumulating it', () => {
const logsState = new WorkflowJobLogsState();
const keyless: WorkflowRawLogLine = { buildStepId: 'install', msg: 'submission log' };
logsState.ingestFileLogLines([keyless]);
logsState.ingestFileLogLines([keyless]);
expect(stepMessages(logsState)).toEqual(['submission log']);
});
it('drops a buffered realtime line once it lands in the file', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
logsState.ingestFileLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('ignores publications that are not arrays of log lines', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
expect(logsState.ingestRealtimeLogLines({ logId: '2', msg: 'not an array' })).toBe(false);
expect(logsState.ingestRealtimeLogLines(['a string', 42, null])).toBe(false);
expect(logsState.ingestRealtimeLogLines([{ msg: 'no logId' }])).toBe(false);
expect(stepMessages(logsState)).toEqual(['first']);
});
it('deduplicates a line republished on the same channel', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed']);
});
it('deduplicates a line republished later', () => {
const logsState = new WorkflowJobLogsState();
logsState.ingestFileLogLines([fileLine('1', 'first')]);
logsState.ingestRealtimeLogLines([fileLine('1', 'first'), fileLine('2', 'pushed')]);
logsState.ingestRealtimeLogLines([fileLine('3', 'newer')]);
logsState.ingestRealtimeLogLines([fileLine('2', 'pushed')]);
expect(stepMessages(logsState)).toEqual(['first', 'pushed', 'newer']);
});
});
@@ -1,83 +0,0 @@
import { fetchRawLogsForJobAsync } from './fetchLogs';
import { WorkflowJobResult, WorkflowLogs, WorkflowRawLogLine } from '../types';
import Log from '../../../log';
import uniqBy from '../../../utils/expodash/uniqBy';
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
export function parseLogLines(rawLogs: string): {
logLines: WorkflowRawLogLine[];
errors: Error[];
} {
const logLines: WorkflowRawLogLine[] = [];
const errors: Error[] = [];
for (const rawLogLine of rawLogs.split('\n')) {
if (!rawLogLine) {
continue;
}
try {
logLines.push(JSON.parse(rawLogLine));
} catch (err) {
errors.push(err as Error);
}
}
return { logLines, errors };
}
export function mergeLogLines<T extends WorkflowRawLogLine>(...logLineGroups: T[][]): T[] {
return uniqBy(logLineGroups.flat().reverse(), logLine => logLine.logId ?? Symbol()).reverse();
}
export function groupLogLinesIntoSteps(
logLines: WorkflowRawLogLine[],
accumulator: WorkflowLogs = new Map()
): WorkflowLogs {
for (const logLine of logLines) {
const { buildStepDisplayName, buildStepId, phase, time, msg, result, marker, err } = logLine;
const stepKey = buildStepId ?? buildStepDisplayName ?? phase;
const stepLabel = buildStepDisplayName ?? buildStepId ?? phase;
if (!stepKey || !stepLabel || !msg) {
continue;
}
let logGroup = accumulator.get(stepKey);
if (!logGroup) {
logGroup = { key: stepKey, label: stepLabel, logLines: [] };
accumulator.set(stepKey, logGroup);
}
if (buildStepDisplayName) {
logGroup.label = buildStepDisplayName;
}
if (logGroup.result === undefined && (marker === 'end-step' || marker === 'END_PHASE')) {
logGroup.result = result ?? '';
}
logGroup.logLines.push({ time, msg, result, marker, err });
}
return accumulator;
}
export async function fetchAndParseLogsFromJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<WorkflowRawLogLine[] | null> {
const rawLogs = await fetchRawLogsForJobAsync(state, job);
if (!rawLogs) {
return null;
}
Log.debug(`rawLogs = ${JSON.stringify(rawLogs, null, 2)}`);
const { logLines, errors } = parseLogLines(rawLogs);
for (const error of errors) {
Log.debug(`Failed to parse a log line: ${error.message}`);
}
return logLines;
}
export async function fetchAndProcessLogsFromJobAsync(
state: { graphqlClient: ExpoGraphqlClient },
job: WorkflowJobResult
): Promise<WorkflowLogs | null> {
const logLines = await fetchAndParseLogsFromJobAsync(state, job);
return logLines && groupLogLinesIntoSteps(logLines);
}
@@ -1,210 +0,0 @@
import { fetchAndParseLogsFromJobAsync, groupLogLinesIntoSteps, mergeLogLines } from './parseLogs';
import { WorkflowJobResult, WorkflowLogs, WorkflowRawLogLine } from '../types';
import {
RealtimeLogsTargetInput,
RealtimeLogsTargetType,
WorkflowJobStatus,
} from '../../../graphql/generated';
import Log from '../../../log';
import { RealtimeLogsClient, RealtimeLogsSubscription } from '../../../utils/centrifuge';
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
import nullthrows from 'nullthrows';
type RealtimeLogLine = WorkflowRawLogLine & { logId: string };
function isRealtimeLogLine(entry: unknown): entry is RealtimeLogLine {
return (
typeof entry === 'object' &&
entry !== null &&
typeof (entry as Partial<WorkflowRawLogLine>).logId === 'string'
);
}
export class WorkflowJobLogsState {
private fileLogIds = new Set<string>();
private realtimeLogLines: RealtimeLogLine[] = [];
private realtimeLogIds = new Set<string>();
private haveFileLogsCaughtUp = false;
private realtimeLogsRevealed = false;
private groupedLogs: WorkflowLogs = new Map();
public ingestFileLogLines(logLines: WorkflowRawLogLine[]): void {
this.fileLogIds = new Set(logLines.flatMap(logLine => (logLine.logId ? [logLine.logId] : [])));
if (
!this.haveFileLogsCaughtUp &&
this.realtimeLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
) {
this.haveFileLogsCaughtUp = true;
}
this.realtimeLogLines = this.realtimeLogLines.filter(
logLine => !this.fileLogIds.has(logLine.logId)
);
this.realtimeLogIds = new Set(this.realtimeLogLines.map(logLine => logLine.logId));
if (this.haveFileLogsCaughtUp) {
this.realtimeLogsRevealed = true;
}
this.groupedLogs = groupLogLinesIntoSteps(
mergeLogLines(logLines, this.realtimeLogsRevealed ? this.realtimeLogLines : [])
);
}
public ingestRealtimeLogLines(data: unknown): boolean {
if (!Array.isArray(data)) {
return false;
}
const publishedLogLines = data.filter(isRealtimeLogLine);
if (publishedLogLines.length === 0) {
return false;
}
if (
!this.haveFileLogsCaughtUp &&
publishedLogLines.some(logLine => this.fileLogIds.has(logLine.logId))
) {
this.haveFileLogsCaughtUp = true;
}
const newLogLines = publishedLogLines.filter(
logLine => !this.fileLogIds.has(logLine.logId) && !this.realtimeLogIds.has(logLine.logId)
);
for (const logLine of newLogLines) {
this.realtimeLogLines.push(logLine);
this.realtimeLogIds.add(logLine.logId);
}
if (!this.realtimeLogsRevealed) {
if (this.haveFileLogsCaughtUp) {
this.revealRealtimeLogs();
}
} else if (newLogLines.length > 0) {
groupLogLinesIntoSteps(newLogLines, this.groupedLogs);
}
return true;
}
public markCompleted(): void {
this.revealRealtimeLogs();
}
public getLogs(): WorkflowLogs {
return this.groupedLogs;
}
private revealRealtimeLogs(): void {
if (this.realtimeLogsRevealed) {
return;
}
this.realtimeLogsRevealed = true;
groupLogLinesIntoSteps(this.realtimeLogLines, this.groupedLogs);
}
}
type TrackedJob = {
logsState: WorkflowJobLogsState;
subscription: RealtimeLogsSubscription | null;
};
export function realtimeLogsTargetForJob(job: WorkflowJobResult): RealtimeLogsTargetInput | null {
if (job.turtleJobRun?.id) {
return { type: RealtimeLogsTargetType.JobRun, id: job.turtleJobRun.id };
}
const buildId = job.turtleBuild?.id ?? job.outputs?.build_id;
if (buildId) {
return { type: RealtimeLogsTargetType.Build, id: buildId };
}
return null;
}
export class WorkflowRunLogsWatcher {
private readonly trackedJobs = new Map<string, TrackedJob>();
private realtimeLogsClient?: RealtimeLogsClient | null;
constructor(
private readonly graphqlClient: ExpoGraphqlClient,
private readonly createRealtimeLogsClient: () => RealtimeLogsClient | null,
private readonly onRealtimeLogs: () => void
) {}
public async syncJobsAsync(
jobs: WorkflowJobResult[]
): Promise<Map<string, WorkflowJobLogsState>> {
await Promise.all(jobs.map(job => this.syncJobAsync(job)));
return new Map(
jobs.map(job => [
job.id,
nullthrows(
this.trackedJobs.get(job.id),
'job has to be in tracked jobs after it was synced'
).logsState,
])
);
}
public close(): void {
for (const trackedJob of this.trackedJobs.values()) {
trackedJob.subscription?.close();
trackedJob.subscription = null;
}
this.realtimeLogsClient?.close();
}
private async syncJobAsync(job: WorkflowJobResult): Promise<void> {
let trackedJob = this.trackedJobs.get(job.id);
if (!trackedJob) {
trackedJob = { logsState: new WorkflowJobLogsState(), subscription: null };
this.trackedJobs.set(job.id, trackedJob);
}
const workflowInProgress = job.status === WorkflowJobStatus.InProgress;
if (!workflowInProgress) {
trackedJob.subscription?.close();
trackedJob.subscription = null;
return;
}
await Promise.all([
trackedJob.subscription ? Promise.resolve() : this.subscribeAsync(job, trackedJob),
this.fetchLogsAsync(job, trackedJob),
]);
}
private async fetchLogsAsync(job: WorkflowJobResult, trackedJob: TrackedJob): Promise<void> {
try {
const logLines = await fetchAndParseLogsFromJobAsync(
{ graphqlClient: this.graphqlClient },
job
);
if (logLines) {
trackedJob.logsState.ingestFileLogLines(logLines);
}
} catch (err: any) {
Log.debug(`Failed to fetch logs for job ${job.id}: ${err.message}`);
}
}
private getRealtimeLogsClient(): RealtimeLogsClient | null {
if (this.realtimeLogsClient === undefined) {
this.realtimeLogsClient = this.createRealtimeLogsClient();
}
return this.realtimeLogsClient;
}
private async subscribeAsync(job: WorkflowJobResult, trackedJob: TrackedJob): Promise<void> {
const target = realtimeLogsTargetForJob(job);
if (!target) {
return;
}
const realtimeLogsClient = this.getRealtimeLogsClient();
if (!realtimeLogsClient) {
return;
}
trackedJob.subscription = await realtimeLogsClient.subscribeAsync({ target }, data => {
if (trackedJob.logsState.ingestRealtimeLogLines(data)) {
this.onRealtimeLogs();
}
});
}
}
@@ -1,13 +1,10 @@
import { Choice } from 'prompts';
import { WorkflowJobResult, WorkflowLogs } from './types';
import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs';
import {
choiceFromWorkflowJob,
choiceFromWorkflowRun,
choicesFromWorkflowLogs,
processWorkflowRuns,
} from './utils';
import { WorkflowJobResult } from './types';
import { fetchAndProcessLogsFromJobAsync } from './logs';
import { choicesFromJobLogs } from '../logs/format';
import { JobLogs } from '../logs/types';
import { choiceFromWorkflowJob, choiceFromWorkflowRun, processWorkflowRuns } from './utils';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { WorkflowJobQuery } from '../../graphql/queries/WorkflowJobQuery';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
@@ -58,7 +55,7 @@ export type WorkflowCommandSelectionState = {
jobId?: string;
step?: string;
job?: WorkflowJobResult;
logs?: WorkflowLogs | null;
logs?: JobLogs | null;
message?: string;
};
@@ -140,7 +137,7 @@ export function moveToWorkflowSelectionFinishedState(
previousState: WorkflowCommandSelectionState,
params: {
step: string;
logs: WorkflowLogs;
logs: JobLogs;
}
): WorkflowCommandSelectionState {
return moveToNewWorkflowCommandSelectionState(
@@ -276,7 +273,7 @@ export const workflowStepSelectionAction: WorkflowCommandSelectionAction = async
return moveToWorkflowSelectionFinishedState(prevState, { step: '', logs });
}
const choices: Choice[] = [
...choicesFromWorkflowLogs(logs),
...choicesFromJobLogs(logs),
{
title: 'Go back and select a different workflow job',
value: 'go-back',
@@ -41,27 +41,3 @@ export type WorkflowRunWithJobsResult = WorkflowRunResult & {
jobs: WorkflowJobResult[];
logs?: string;
};
export type WorkflowRawLogLine = {
logId?: string;
buildStepId?: string;
buildStepDisplayName?: string;
phase?: string;
time?: string;
msg?: string;
result?: string;
marker?: string;
err?: any;
};
export type WorkflowLogLine = Pick<WorkflowRawLogLine, 'time' | 'result' | 'marker' | 'err'> &
Required<Pick<WorkflowRawLogLine, 'msg'>>;
export type StepLogs = {
key: string;
label: string;
result?: string;
logLines: WorkflowLogLine[];
};
export type WorkflowLogs = Map<string, StepLogs>;
@@ -1,14 +1,8 @@
import chalk from 'chalk';
import * as fs from 'node:fs';
import { fetchAndProcessLogsFromJobAsync } from './logs/parseLogs';
import {
WorkflowJobResult,
WorkflowLogLine,
WorkflowLogs,
WorkflowRunResult,
WorkflowTriggerType,
} from './types';
import { fetchAndProcessLogsFromJobAsync, logSourceForWorkflowJob } from './logs';
import { WorkflowJobResult, WorkflowRunResult, WorkflowTriggerType } from './types';
import {
WorkflowJobStatus,
WorkflowRunByIdQuery,
@@ -17,7 +11,9 @@ import {
WorkflowRunStatus,
WorkflowRunTriggerEventType,
} from '../../graphql/generated';
import { WorkflowRunLogsWatcher } from './logs/watcher';
import { choicesFromJobLogs, stepLogTail } from '../logs/format';
import { JobLogs } from '../logs/types';
import { LogsWatcher } from '../logs/watcher';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { ora, updateSpinnerText } from '../../ora';
@@ -83,20 +79,6 @@ export function choiceFromWorkflowJob(job: WorkflowJobResult, index: number): Ch
};
}
export function choicesFromWorkflowLogs(
logs: WorkflowLogs
): (Choice & { name: string; status: string; logLines: WorkflowLogLine[] | undefined })[] {
return Array.from(logs.values())
.map(({ key, label, result, logLines }) => ({
title: `${label} - ${result ?? ''}`,
name: label,
status: result ?? '',
value: key,
logLines,
}))
.filter(step => step.status !== 'skipped');
}
export function processWorkflowRuns(runs: WorkflowRunFragment[]): WorkflowRunResult[] {
return runs.map(run => {
const finishedAt = run.status === WorkflowRunStatus.InProgress ? null : run.updatedAt;
@@ -157,18 +139,9 @@ type WorkflowRunWithJobs = WorkflowRunByIdWithJobsQuery['workflowRuns']['byId'];
type JobWithLogs = {
job: WorkflowRunWithJobs['jobs'][number];
logs: WorkflowLogs;
logs: JobLogs;
};
function stepLogTail(
step: { logLines?: WorkflowLogLine[] },
maxLogLines: number // -1 means no limit
): string[] {
const logLines = step.logLines ?? [];
const tail = maxLogLines === -1 ? logLines : logLines.slice(-maxLogLines);
return tail.map(line => line.msg);
}
export async function logsForFailedWorkflowRunAsync(
graphqlClient: ExpoGraphqlClient,
workflowRun: WorkflowRunWithJobs
@@ -195,7 +168,7 @@ export function formatActiveWorkflowRun(
if (job.status !== WorkflowJobStatus.InProgress) {
continue;
}
const steps = choicesFromWorkflowLogs(logs);
const steps = choicesFromJobLogs(logs);
if (steps.length === 0) {
continue;
}
@@ -222,7 +195,7 @@ export function formatFailedWorkflowRun(
for (const { job, logs } of jobLogs) {
statusValues.push({ label: '', value: '' });
statusValues.push({ label: ' Failed job', value: job.name });
const steps = choicesFromWorkflowLogs(logs);
const steps = choicesFromJobLogs(logs);
const failedStep = steps.find(step => step.status === 'fail' || step.status === 'failed');
if (!failedStep) {
continue;
@@ -290,8 +263,7 @@ export async function showWorkflowStatusAsync(
prefixText: chalk`{bold.yellow Workflow run is waiting to start:}`,
});
const watcher = new WorkflowRunLogsWatcher(
graphqlClient,
const watcher = new LogsWatcher(
() => createRealtimeLogsClient(graphqlClient),
() => {
renderActiveWorkflowRun();
@@ -316,12 +288,14 @@ export async function showWorkflowStatusAsync(
updateSpinnerText(spinner, {
prefixText: chalk`{bold.green Workflow run is in progress:}`,
});
const logsStates = await watcher.syncJobsAsync(workflowRun.jobs);
const logsStates = await watcher.syncAsync(
workflowRun.jobs.map(job => logSourceForWorkflowJob(graphqlClient, job))
);
for (const job of workflowRun.jobs) {
if (isJobCompleted(job.status)) {
nullthrows(
logsStates.get(job.id),
'syncJobsAsync must have been called before markCompleted'
'syncAsync must have been called before markCompleted'
).markCompleted();
}
}
@@ -331,7 +305,7 @@ export async function showWorkflowStatusAsync(
job,
logs: nullthrows(
logsStates.get(job.id),
'syncJobsAsync must have been called before getLogs'
'syncAsync must have been called before getLogs'
).getLogs(),
}))
);
+22 -18
View File
@@ -19,6 +19,7 @@ import { ensureAppExistsAsync } from '../credentials/ios/appstore/ensureAppExist
import { ensureTestFlightGroupExistsAsync } from '../credentials/ios/appstore/ensureTestFlightGroup';
import { Target } from '../credentials/ios/types';
import {
AccountFragment,
WorkflowJobStatus,
WorkflowProjectSourceType,
WorkflowRunStatus,
@@ -34,7 +35,6 @@ import { detectProjectSdkVersionAsync } from '../project/detectProjectSdkVersion
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../user/actions';
import { Actor, getActorDisplayName } from '../user/User';
import { sleepAsync } from '../utils/promise';
import { Client } from '../vcs/vcs';
@@ -43,6 +43,7 @@ import {
INVALID_BUNDLE_IDENTIFIER_MESSAGE,
isBundleIdentifierValid,
} from '../project/ios/bundleIdentifier';
import { UserQuery } from '../graphql/queries/UserQuery';
function deriveBundleIdSlug(bundleId: string): string {
return bundleId.split('.').filter(Boolean).pop()!;
@@ -142,8 +143,11 @@ export default class Go extends EasCommand {
} = await this.getContextAsync(Go, {
nonInteractive: false,
});
Log.withTick(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
const userPrimaryAccount = await UserQuery.requireCurrentUserPrimaryAccountAsync(graphqlClient);
const detectedSdkVersion = await detectProjectSdkVersionAsync(process.cwd());
if (detectedSdkVersion && !flags['sdk-version']) {
Log.log(
@@ -154,7 +158,7 @@ export default class Go extends EasCommand {
if (!sdkVersion) {
({ sdkVersion } = await this.selectSdkVersionAsync(graphqlClient));
}
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
const bundleId = flags['bundle-id'] ?? this.generateBundleId(userPrimaryAccount);
if (!isBundleIdentifierValid(bundleId)) {
throw new Error(
`"${bundleId}" is not a valid iOS bundle identifier. ${INVALID_BUNDLE_IDENTIFIER_MESSAGE} Pass a valid identifier with --bundle-id.`
@@ -167,7 +171,7 @@ export default class Go extends EasCommand {
let projectId: string;
try {
projectId = await withSuppressedOutputAsync(() =>
this.ensureEasProjectAsync(graphqlClient, actor, slug)
this.ensureEasProjectAsync(graphqlClient, userPrimaryAccount, slug)
);
} catch (error) {
setupSpinner.fail();
@@ -202,7 +206,7 @@ export default class Go extends EasCommand {
} = await this.dispatchWorkflowAsync(
graphqlClient,
projectId,
actor,
userPrimaryAccount,
bundleId,
appName,
ascApp.id,
@@ -275,8 +279,8 @@ export default class Go extends EasCommand {
};
}
private generateBundleId(actor: Actor): string {
const username = ensureActorHasPrimaryAccount(actor).name;
private generateBundleId(userPrimaryAccount: AccountFragment): string {
const username = userPrimaryAccount.name;
const sanitizedUsername = username
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
@@ -287,14 +291,12 @@ export default class Go extends EasCommand {
private async ensureEasProjectAsync(
graphqlClient: ExpoGraphqlClient,
actor: Actor,
userPrimaryAccount: AccountFragment,
slug: string
): Promise<string> {
const account = ensureActorHasPrimaryAccount(actor);
const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync(
graphqlClient,
account.name,
userPrimaryAccount.name,
slug
);
@@ -303,7 +305,7 @@ export default class Go extends EasCommand {
}
return await AppMutation.createAppAsync(graphqlClient, {
accountId: account.id,
accountId: userPrimaryAccount.id,
projectName: slug,
});
}
@@ -394,7 +396,7 @@ export default class Go extends EasCommand {
private async dispatchWorkflowAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
actor: Actor,
userPrimaryAccount: AccountFragment,
bundleId: string,
appName: string,
ascAppId: string,
@@ -402,8 +404,6 @@ export default class Go extends EasCommand {
tmpDir: string,
vcsClient: Client
): Promise<{ workflowUrl: string; workflowRunId: string; sdkVersion: string }> {
const account = ensureActorHasPrimaryAccount(actor);
const repackConfig = await WorkflowRunQuery.expoGoRepackConfigurationAsync(graphqlClient, {
appId: projectId,
ascAppId,
@@ -421,17 +421,17 @@ export default class Go extends EasCommand {
const { projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({
graphqlClient,
vcsClient,
accountId: account.id,
accountId: userPrimaryAccount.id,
});
const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
accountId: userPrimaryAccount.id,
filePath: path.join(tmpDir, 'eas.json'),
maxSizeBytes: 1024 * 1024,
});
const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({
graphqlClient,
accountId: account.id,
accountId: userPrimaryAccount.id,
filePath: path.join(tmpDir, 'package.json'),
maxSizeBytes: 1024 * 1024,
});
@@ -449,7 +449,11 @@ export default class Go extends EasCommand {
},
});
const workflowUrl = getWorkflowRunUrl(account.name, deriveBundleIdSlug(bundleId), result.id);
const workflowUrl = getWorkflowRunUrl(
userPrimaryAccount.name,
deriveBundleIdSlug(bundleId),
result.id
);
return { workflowUrl, workflowRunId: result.id, sdkVersion: repackConfig.sdkVersion };
}
@@ -9,7 +9,6 @@ import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
Role,
} from '../../../../graphql/generated';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation';
@@ -56,12 +55,6 @@ describe(IntegrationsConvexConnect, () => {
email: '[email protected]',
featureGates: {},
isExpoAdmin: false,
primaryAccount: {
id: testAccountId,
name: testAccountName,
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
accounts: [],
};
@@ -62,12 +62,6 @@ describe(IntegrationsPostHogConnect, () => {
email: '[email protected]',
featureGates: {},
isExpoAdmin: false,
primaryAccount: {
id: testAccountId,
name: testAccountName,
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
accounts: [],
};
@@ -28,7 +28,6 @@ jest.mock('../../../project/expoConfig', () => ({
createOrModifyExpoConfigAsync: jest.fn(),
}));
jest.mock('../../../prompts');
jest.mock('../../../user/actions');
jest.mock('../../../graphql/mutations/AppMutation');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../ora', () => ({
@@ -7,11 +7,11 @@ import {
WorkflowCommandSelectionStateValue,
executeWorkflowSelectionActionsAsync,
} from '../../commandUtils/workflow/stateMachine';
import { WorkflowLogLine, WorkflowLogs } from '../../commandUtils/workflow/types';
import { JobLogs, LogLine } from '../../commandUtils/logs/types';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
function printLogsForAllSteps(logs: WorkflowLogs): void {
function printLogsForAllSteps(logs: JobLogs): void {
[...logs.values()].forEach(({ label, logLines }) => {
if (logLines.length === 0) {
return;
@@ -85,7 +85,7 @@ export default class WorkflowLogView extends EasCommand {
return;
}
const logs = finalSelectionState?.logs as unknown as WorkflowLogs | null;
const logs = finalSelectionState?.logs;
if (allSteps) {
if (logs) {
if (flags.json) {
@@ -103,7 +103,7 @@ export default class WorkflowLogView extends EasCommand {
const logGroup = logs?.get(selectedStep);
if (logGroup) {
if (flags.json) {
const output: { [key: string]: WorkflowLogLine[] | null } = {};
const output: { [key: string]: LogLine[] | null } = {};
output[selectedStep] = logGroup.logLines;
printJsonOnlyOutput(output);
} else {
@@ -8,11 +8,6 @@ export const jester: Actor = {
id: 'jester-id',
email: '[email protected]',
username: 'jester',
primaryAccount: {
id: 'jester-account-id',
name: 'jester',
viewerUserPermission: { role: Role.Admin },
},
accounts: [
{
id: 'jester-account-id',
@@ -1,7 +1,4 @@
import {
jester as mockJester,
testAppQueryByIdResponse,
} from '../../../../credentials/__tests__/fixtures-constants';
import { testAppQueryByIdResponse } from '../../../../credentials/__tests__/fixtures-constants';
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { testKeystore } from '../../../__tests__/fixtures-android';
import { createCtxMock } from '../../../__tests__/fixtures-context';
@@ -11,7 +8,6 @@ import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { CreateKeystore } from '../CreateKeystore';
jest.mock('../../../../prompts', () => ({ confirmAsync: jest.fn(() => true) }));
jest.mock('../../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
jest.mock('../../../utils/promptForCredentials');
jest.mock('../../utils/keystore', () => ({ generateRandomKeystoreAsync: jest.fn() }));
jest.mock('../../../../graphql/queries/AppQuery');
@@ -4,17 +4,13 @@ import {
testAndroidBuildCredentialsFragment,
testJksAndroidKeystoreFragment,
} from '../../../__tests__/fixtures-android';
import {
jester as mockJester,
testAppQueryByIdResponse,
} from '../../../__tests__/fixtures-constants';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { generateRandomKeystoreAsync } from '../../utils/keystore';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { SetUpBuildCredentials } from '../SetUpBuildCredentials';
jest.mock('../../../../ora');
jest.mock('../../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
jest.mock('../../../../prompts', () => ({ confirmAsync: jest.fn(() => true) }));
jest.mock('../../utils/keystore', () => ({ generateRandomKeystoreAsync: jest.fn() }));
jest.mock('../../../../graphql/queries/AppQuery');
@@ -25,7 +25,6 @@ import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme';
import { resolveTargetsAsync } from '../../project/ios/target';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { confirmAsync, promptAsync, selectAsync } from '../../prompts';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { CredentialsContext, CredentialsContextProjectInfo } from '../context';
import {
AppStoreApiKeyPurpose,
@@ -53,6 +52,7 @@ import { UpdateCredentialsJson } from '../ios/actions/UpdateCredentialsJson';
import { AppLookupParams } from '../ios/api/graphql/types/AppLookupParams';
import { App, IosAppCredentialsMap, Target } from '../ios/types';
import { displayIosCredentials } from '../ios/utils/printCredentials';
import { UserQuery } from '../../graphql/queries/UserQuery';
export class ManageIos {
constructor(
@@ -95,7 +95,7 @@ export class ManageIos {
const account = ctx.hasProjectContext
? await getAccountForProjectAsync(await ctx.getProjectIdAsync())
: ensureActorHasPrimaryAccount(ctx.user);
: await UserQuery.requireCurrentUserPrimaryAccountAsync(ctx.graphqlClient);
let app = null;
let targets = null;
@@ -8,8 +8,8 @@ import { Action } from './HelperActions';
import { ManageIos } from './ManageIos';
import { AccountFragment } from '../../graphql/generated';
import { getOwnerAccountForProjectIdAsync } from '../../project/projectUtils';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { CredentialsContext, CredentialsContextProjectInfo } from '../context';
import { UserQuery } from '../../graphql/queries/UserQuery';
export class SetUpIosBuildCredentials extends ManageIos {
constructor(
@@ -56,7 +56,7 @@ export class SetUpIosBuildCredentials extends ManageIos {
const account = ctx.hasProjectContext
? await getAccountForProjectAsync(await ctx.getProjectIdAsync())
: ensureActorHasPrimaryAccount(ctx.user);
: await UserQuery.requireCurrentUserPrimaryAccountAsync(ctx.graphqlClient);
let app = null;
let targets = null;
@@ -26,11 +26,6 @@ describe(AccountResolver, () => {
id: 'user_id_666',
email: '[email protected]',
username: 'dominik',
primaryAccount: {
id: 'account_id_777',
name: 'dominik',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_777',
+129 -15
View File
@@ -3639,7 +3639,8 @@ export type AppObserveOverviewEngagementInput = {
export type AppObserveOverviewEngagementStat = {
__typename?: 'AppObserveOverviewEngagementStat';
previousPeriodTotal: Scalars['Int']['output'];
/** Total for the equal-length window before startTime. Null when the window is longer than 45 days, since the previous period would fall outside the 90-day retention. */
previousPeriodTotal?: Maybe<Scalars['Int']['output']>;
/** Per-bucket approximate uniques; buckets do not sum to `total`. */
series: Array<AppObserveOverviewEngagementBucket>;
total: Scalars['Int']['output'];
@@ -3655,12 +3656,13 @@ export type AppObserveOverviewStability = {
crashFreeUsers: Scalars['Float']['output'];
/** Distinct users that hit a fatal error in range. */
crashedUsers: Scalars['Int']['output'];
/** Fatal exception events in the equal-length window before startTime. */
previousPeriodCrashCount: Scalars['Int']['output'];
/** Same fractions for the equal-length window before startTime; null when it had no activity. */
/** Fatal exception events in the equal-length window before startTime. Null when the window is longer than 45 days. */
previousPeriodCrashCount?: Maybe<Scalars['Int']['output']>;
/** Same fractions for the equal-length window before startTime; null when it had no activity, or when the window is longer than 45 days and the previous period falls outside the 90-day retention. */
previousPeriodCrashFreeSessions?: Maybe<Scalars['Float']['output']>;
previousPeriodCrashFreeUsers?: Maybe<Scalars['Float']['output']>;
previousPeriodCrashedUsers: Scalars['Int']['output'];
/** Null when the window is longer than 45 days. */
previousPeriodCrashedUsers?: Maybe<Scalars['Int']['output']>;
series: Array<AppObserveOverviewStabilityBucket>;
/** All exception events in range, for distinguishing quiet apps from crash-free ones. */
totalErrors: Scalars['Int']['output'];
@@ -4126,6 +4128,8 @@ export enum AppObserveUserEventListOrderByField {
export type AppObserveUserEventName = {
__typename?: 'AppObserveUserEventName';
count: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
lastSeenAt: Scalars['DateTime']['output'];
name: Scalars['String']['output'];
};
@@ -4156,6 +4160,8 @@ export type AppObserveUserEventNamesOrderBy = {
export enum AppObserveUserEventNamesOrderByField {
Count = 'COUNT',
FirstSeen = 'FIRST_SEEN',
LastSeen = 'LAST_SEEN',
Name = 'NAME'
}
@@ -10793,6 +10799,7 @@ export type RootQuery = {
posthogIntegration: PostHogIntegrationQuery;
/** Top-level query object for querying Runtimes. */
runtimes: RuntimeQuery;
sandboxes: SandboxQuery;
snack: SnackQuery;
/** Top-level query object for querying Expo status page services. */
statuspageService: StatuspageServiceQuery;
@@ -11037,6 +11044,36 @@ export type SsoUserDataInput = {
lastName?: InputMaybe<Scalars['String']['input']>;
};
export type Sandbox = {
__typename?: 'Sandbox';
app: App;
createdAt: Scalars['DateTime']['output'];
finishedAt?: Maybe<Scalars['DateTime']['output']>;
id: Scalars['ID']['output'];
lastUsedAt: Scalars['DateTime']['output'];
startedAt?: Maybe<Scalars['DateTime']['output']>;
status: SandboxStatus;
turtleJobRun: JobRun;
updatedAt: Scalars['DateTime']['output'];
};
export type SandboxQuery = {
__typename?: 'SandboxQuery';
byId: Sandbox;
};
export type SandboxQuery_ByIdArgs = {
sandboxId: Scalars['ID']['input'];
};
export enum SandboxStatus {
Errored = 'ERRORED',
Running = 'RUNNING',
Starting = 'STARTING',
Stopped = 'STOPPED'
}
export type SecondFactorBooleanResult = {
__typename?: 'SecondFactorBooleanResult';
success: Scalars['Boolean']['output'];
@@ -11546,6 +11583,35 @@ export type SubscriptionDetails_PlanEnablementArgs = {
serviceMetric: EasServiceMetric;
};
/** An unresolved finding from Supabase's Security or Performance Advisor (database linter). */
export type SupabaseAdvisorLint = {
__typename?: 'SupabaseAdvisorLint';
/** Stable identifier for this finding on this project. */
cacheKey: Scalars['String']['output'];
description: Scalars['String']['output'];
/** Project-specific explanation naming the affected schema object. */
detail: Scalars['String']['output'];
/** Affected schema object, e.g. public.todos, when the lint names one. */
entity?: Maybe<Scalars['String']['output']>;
level: SupabaseAdvisorLintLevel;
/** Lint rule identifier, e.g. rls_disabled_in_public. */
name: Scalars['String']['output'];
/** Link to the remediation guide, when Supabase provides one. */
remediation?: Maybe<Scalars['String']['output']>;
title: Scalars['String']['output'];
};
export enum SupabaseAdvisorLintLevel {
Error = 'ERROR',
Info = 'INFO',
Warn = 'WARN'
}
export enum SupabaseAdvisorType {
Performance = 'PERFORMANCE',
Security = 'SECURITY'
}
export type SupabaseConnection = {
__typename?: 'SupabaseConnection';
account: Account;
@@ -11622,6 +11688,12 @@ export type SupabaseOrganization = {
export type SupabaseProject = {
__typename?: 'SupabaseProject';
/**
* Live unresolved lints from the project's Security or Performance Advisor, ordered by severity.
* Readable with view permission; a token refresh triggered by a read is persisted with elevated
* privileges. Null when Supabase cannot be reached.
*/
advisorLints?: Maybe<Array<SupabaseAdvisorLint>>;
app: App;
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
@@ -11633,6 +11705,11 @@ export type SupabaseProject = {
updatedAt: Scalars['DateTime']['output'];
};
export type SupabaseProject_AdvisorLintsArgs = {
type: SupabaseAdvisorType;
};
export type SupabaseProjectMutation = {
__typename?: 'SupabaseProjectMutation';
/** Removes the Expo-side project link only; the Supabase project is preserved. */
@@ -16714,30 +16791,40 @@ export type CurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
export type CurrentUserQuery = { __typename?: 'RootQuery', meActor?:
| { __typename: 'PartnerActor', username: string, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename: 'PartnerActor', id: string, username: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> }
| { __typename: 'Robot', firstName?: string | null, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename: 'Robot', id: string, firstName?: string | null, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> }
| { __typename: 'SSOUser', username: string, id: string, featureGates: any, isExpoAdmin: boolean, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename: 'SSOUser', id: string, username: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> }
| { __typename: 'User', email: string, username: string, id: string, featureGates: any, isExpoAdmin: boolean, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename: 'User', id: string, email: string, username: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> }
| null };
export type CurrentUserWithPrimaryAccountQueryVariables = Exact<{ [key: string]: never; }>;
export type CurrentUserWithPrimaryAccountQuery = { __typename?: 'RootQuery', meActor?:
| { __typename: 'PartnerActor', id: string }
| { __typename: 'Robot', id: string }
| { __typename: 'SSOUser', id: string, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } } }
| { __typename: 'User', id: string, primaryAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } } }
| null };
export type WebhooksByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
webhookFilter?: InputMaybe<WebhookFilter>;
@@ -16895,6 +16982,33 @@ export type UsageMetricTotalFragment = { __typename?: 'UsageMetricTotal', id: st
export type AccountUsageMetricFragment = { __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number };
type MeActorFragment_PartnerActor = { __typename: 'PartnerActor', username: string, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> };
type MeActorFragment_Robot = { __typename: 'Robot', firstName?: string | null, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> };
type MeActorFragment_SsoUser = { __typename: 'SSOUser', username: string, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> };
type MeActorFragment_User = { __typename: 'User', email: string, username: string, id: string, featureGates: any, isExpoAdmin: boolean, accounts: Array<{ __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
| null, viewerUserPermission: { __typename?: 'UserPermission', role: Role } }> };
export type MeActorFragment =
| MeActorFragment_PartnerActor
| MeActorFragment_Robot
| MeActorFragment_SsoUser
| MeActorFragment_User
;
export type AppFragment = { __typename?: 'App', id: string, name: string, fullName: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?:
| { __typename?: 'SSOUser', id: string, username: string }
| { __typename?: 'User', id: string, username: string }
@@ -3,8 +3,13 @@ import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import { CurrentUserQuery } from '../generated';
import {
AccountFragment,
CurrentUserQuery,
CurrentUserWithPrimaryAccountQuery,
} from '../generated';
import { AccountFragmentNode } from '../types/Account';
import { MeActorFragmentNode } from '../types/Actor';
export const UserQuery = {
async currentUserAsync(graphqlClient: ExpoGraphqlClient): Promise<CurrentUserQuery['meActor']> {
@@ -16,31 +21,10 @@ export const UserQuery = {
meActor {
__typename
id
... on User {
email
}
... on UserActor {
username
primaryAccount {
id
...AccountFragment
}
}
... on Robot {
firstName
}
accounts {
id
...AccountFragment
}
... on PartnerActor {
username
}
featureGates
isExpoAdmin
...MeActorFragment
}
}
${print(AccountFragmentNode)}
${print(MeActorFragmentNode)}
`,
{},
{
@@ -52,4 +36,52 @@ export const UserQuery = {
return data.meActor;
},
async requireCurrentUserPrimaryAccountAsync(
graphqlClient: ExpoGraphqlClient
): Promise<AccountFragment> {
let data: CurrentUserWithPrimaryAccountQuery;
try {
data = await withErrorHandlingAsync(
graphqlClient
.query<CurrentUserWithPrimaryAccountQuery>(
gql`
query CurrentUserWithPrimaryAccount {
meActor {
__typename
id
... on UserActor {
primaryAccount {
id
...AccountFragment
}
}
}
}
${print(AccountFragmentNode)}
`,
{},
{
additionalTypenames: ['User', 'SSOUser'],
}
)
.toPromise()
);
} catch (error) {
throw new Error(
'An error occurred while fetching the primary account of the current user. Check to ensure your session has sufficient scope on your primary account.'
);
}
const actor = data.meActor;
if (!actor) {
throw new Error('Must be logged in to perform this action.');
}
if (actor.__typename === 'User' || actor.__typename === 'SSOUser') {
return actor.primaryAccount;
}
throw new Error(`This action is not supported for the ${actor.__typename} user type.`);
},
};
@@ -0,0 +1,31 @@
/* eslint-disable graphql/required-fields */
import { print } from 'graphql';
import gql from 'graphql-tag';
import { AccountFragmentNode } from './Account';
export const MeActorFragmentNode = gql`
${print(AccountFragmentNode)}
fragment MeActorFragment on Actor {
__typename
id
... on User {
email
}
... on UserActor {
username
}
... on Robot {
firstName
}
accounts {
id
...AccountFragment
}
... on PartnerActor {
username
}
featureGates
isExpoAdmin
}
`;
@@ -1,4 +1,4 @@
import { AppObserveUserEvent, PageInfo } from '../../graphql/generated';
import { AppObserveUserEvent, AppObserveUserEventName, PageInfo } from '../../graphql/generated';
import {
buildObserveCustomEventNamesTable,
buildObserveCustomEventsEmptyWithSuggestionsJson,
@@ -7,6 +7,16 @@ import {
buildObserveCustomEventsTable,
} from '../formatCustomEvents';
function makeEventName(name: string, count: number): AppObserveUserEventName {
return {
__typename: 'AppObserveUserEventName' as const,
name,
count,
firstSeenAt: '2025-01-01T00:00:00.000Z',
lastSeenAt: '2025-01-15T00:00:00.000Z',
};
}
function makeCustomEvent(overrides: Partial<AppObserveUserEvent> = {}): AppObserveUserEvent {
return {
__typename: 'AppObserveUserEvent' as const,
@@ -299,8 +309,8 @@ describe(buildObserveCustomEventNamesTable, () => {
it('shows event names with counts', () => {
const output = buildObserveCustomEventNamesTable([
{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 },
{ __typename: 'AppObserveUserEventName', name: 'bar', count: 5 },
makeEventName('foo', 10),
makeEventName('bar', 5),
]);
expect(output).toContain('foo');
expect(output).toContain('10');
@@ -309,10 +319,9 @@ describe(buildObserveCustomEventNamesTable, () => {
});
it('appends a truncation notice when isTruncated is true', () => {
const output = buildObserveCustomEventNamesTable(
[{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 }],
{ isTruncated: true }
);
const output = buildObserveCustomEventNamesTable([makeEventName('foo', 10)], {
isTruncated: true,
});
expect(output).toContain('Result is truncated');
});
});
@@ -320,8 +329,8 @@ describe(buildObserveCustomEventNamesTable, () => {
describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
it('shows the filtered event name and the available event names', () => {
const output = buildObserveCustomEventsEmptyWithSuggestionsTable('login', [
{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 },
{ __typename: 'AppObserveUserEventName', name: 'bar', count: 5 },
makeEventName('foo', 10),
makeEventName('bar', 5),
]);
expect(output).toContain('No events found matching "login"');
@@ -342,7 +351,7 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
it('appends a truncation notice when isTruncated is set', () => {
const output = buildObserveCustomEventsEmptyWithSuggestionsTable(
'login',
[{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 }],
[makeEventName('foo', 10)],
{ isTruncated: true }
);
@@ -352,7 +361,7 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsTable, () => {
it('includes the time range description in the message when provided', () => {
const output = buildObserveCustomEventsEmptyWithSuggestionsTable(
'login',
[{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 }],
[makeEventName('foo', 10)],
{ daysBack: 7 }
);
@@ -364,10 +373,7 @@ describe(buildObserveCustomEventsEmptyWithSuggestionsJson, () => {
it('returns the filtered event name, empty events array, and available names', () => {
const result = buildObserveCustomEventsEmptyWithSuggestionsJson(
'login',
[
{ __typename: 'AppObserveUserEventName', name: 'foo', count: 10 },
{ __typename: 'AppObserveUserEventName', name: 'bar', count: 5 },
],
[makeEventName('foo', 10), makeEventName('bar', 5)],
false
);
+5 -1
View File
@@ -17,6 +17,10 @@ const errorReal = console.error;
const isCi = boolish('CI', false);
export function isSpinnerEnabled(): boolean {
return !(Log.isDebug || !process.stdin.isTTY || isCi);
}
/**
* A custom ora spinner that sends the stream to stdout in CI, or non-TTY, instead of stderr (the default).
*
@@ -25,7 +29,7 @@ const isCi = boolish('CI', false);
*/
export function ora(options?: Options | string): Ora {
const inputOptions = typeof options === 'string' ? { text: options } : (options ?? {});
const disabled = Log.isDebug || !process.stdin.isTTY || isCi;
const disabled = !isSpinnerEnabled();
const spinner = oraReal({
// Ensure our non-interactive mode emulates CI mode.
isEnabled: !disabled,
@@ -10,11 +10,6 @@ const userActor: Actor = {
id: 'user-id',
email: '[email protected]',
username: 'jane',
primaryAccount: {
id: 'account-id-personal',
name: 'jane',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account-id-org',
@@ -4,7 +4,7 @@ import os from 'os';
import { instance, mock } from 'ts-mockito';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { jester, jester as mockJester } from '../../../credentials/__tests__/fixtures-constants';
import { jester } from '../../../credentials/__tests__/fixtures-constants';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { promptAsync } from '../../../prompts';
import { resolveVcsClient } from '../../../vcs';
@@ -16,7 +16,6 @@ import {
jest.mock('fs');
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
const vcsClient = resolveVcsClient();
@@ -2,14 +2,12 @@ import fs from 'fs-extra';
import { vol } from 'memfs';
import os from 'os';
import { jester as mockJester } from '../../../credentials/__tests__/fixtures-constants';
import { promptAsync } from '../../../prompts';
import { resolveVcsClient } from '../../../vcs';
import { resolveGradleBuildContextAsync } from '../gradle';
jest.mock('fs');
jest.mock('../../../prompts');
jest.mock('../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
const vcsClient = resolveVcsClient();
@@ -5,7 +5,7 @@ import path from 'path';
import { instance, mock } from 'ts-mockito';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { jester, jester as mockJester } from '../../../credentials/__tests__/fixtures-constants';
import { jester } from '../../../credentials/__tests__/fixtures-constants';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { promptAsync } from '../../../prompts';
import { resolveVcsClient } from '../../../vcs';
@@ -18,7 +18,6 @@ import {
jest.mock('fs');
jest.mock('../../../prompts');
jest.mock('../../../graphql/queries/AppQuery');
jest.mock('../../../user/actions', () => ({ ensureLoggedInAsync: jest.fn(() => mockJester) }));
const vcsClient = resolveVcsClient();
@@ -35,9 +35,6 @@ jest.mock('../../../graphql/mutations/SubmissionMutation', () => ({
jest.mock('../../../user/User', () => ({
getUserAsync: jest.fn(() => mockJester),
}));
jest.mock('../../../user/actions', () => ({
ensureLoggedInAsync: jest.fn(() => mockJester),
}));
jest.mock('../../../project/projectUtils');
jest.mock('../../ArchiveSource', () => {
return {
@@ -6,11 +6,6 @@ const userStub: Actor = {
id: 'userId',
email: '[email protected]',
username: 'username',
primaryAccount: {
id: 'account_id_777',
name: 'username',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_777',
@@ -26,11 +21,6 @@ const ssoUserStub: Actor = {
__typename: 'SSOUser',
id: 'ssoUserId',
username: 'ssoUsername',
primaryAccount: {
id: 'account_id_888',
name: 'ssoUsername',
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_888',
-9
View File
@@ -1,9 +0,0 @@
import { Actor } from './User';
import { AccountFragment } from '../graphql/generated';
export function ensureActorHasPrimaryAccount(user: Actor): AccountFragment {
if (user.__typename === 'User' || user.__typename === 'SSOUser') {
return user.primaryAccount;
}
throw new Error('This action is not supported for robot users.');
}
+3
View File
@@ -35,6 +35,8 @@ function createWebSocketConstructor(): WebSocketConstructor {
return httpsProxyAgent ? createProxiedWebSocketConstructor(httpsProxyAgent) : WebSocket;
}
const LOGS_WEBSOCKET_TIMEOUT_MS = 5_000;
export function createRealtimeLogsClient(
graphqlClient: ExpoGraphqlClient
): RealtimeLogsClient | null {
@@ -49,6 +51,7 @@ export function createRealtimeLogsClient(
client = new Centrifuge(getEASLogsWebsocketUrl(), {
websocket: createWebSocketConstructor(),
getToken: getTokenAsync,
timeout: LOGS_WEBSOCKET_TIMEOUT_MS,
});
client.on('error', ({ error }) => {
Log.debug(`Realtime logs connection error: ${error.message}`);
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "24.3.0",
"version": "24.5.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": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "24.4.0",
"version": "24.5.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,9 +26,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "24.4.0",
"@expo/build-tools": "24.5.0",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"@expo/logger": "24.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "24.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "24.3.0",
"version": "24.5.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,7 +27,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"@expo/logger": "24.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "24.4.0",
"version": "24.5.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,15 +19,15 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "24.4.0",
"@expo/build-tools": "24.5.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/eas-build-job": "24.3.0",
"@expo/eas-build-job": "24.5.0",
"@expo/logger": "24.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "24.3.0",
"@expo/steps": "24.5.0",
"@expo/template-file": "24.0.0",
"@expo/turtle-spawn": "24.0.0",
"@hapi/boom": "10.0.1",
Generated
+37 -17
View File
@@ -2291,14 +2291,14 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:24.4.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:24.5.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:24.0.0"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:24.0.0"
"@expo/package-manager": "npm:1.9.10"
@@ -2306,7 +2306,7 @@ __metadata:
"@expo/repack-app": "npm:~0.9.0"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:24.3.0"
"@expo/steps": "npm:24.5.0"
"@expo/template-file": "npm:24.0.0"
"@expo/turtle-spawn": "npm:24.0.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -2340,6 +2340,7 @@ __metadata:
nock: "npm:13.4.0"
node-fetch: "npm:^2.7.0"
node-forge: "npm:^1.3.1"
node-pty: "npm:1.2.0-beta.15"
node-stream-zip: "npm:1.15.0"
nullthrows: "npm:^1.1.1"
plist: "npm:^3.1.0"
@@ -2559,7 +2560,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:24.3.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:24.5.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:
@@ -2578,12 +2579,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:24.3.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:24.5.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:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -2897,11 +2898,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:24.3.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:24.5.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/logger": "npm:24.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
@@ -2971,15 +2972,15 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:24.4.0"
"@expo/build-tools": "npm:24.5.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/logger": "npm:24.0.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:24.3.0"
"@expo/steps": "npm:24.5.0"
"@expo/template-file": "npm:24.0.0"
"@expo/turtle-spawn": "npm:24.0.0"
"@hapi/boom": "npm:10.0.1"
@@ -9830,7 +9831,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:24.3.0"
"@expo/steps": "npm:24.5.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10414,9 +10415,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:24.4.0"
"@expo/build-tools": "npm:24.5.0"
"@expo/bunyan": "npm:^4.0.1"
"@expo/eas-build-job": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/logger": "npm:24.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:24.0.0"
@@ -10471,8 +10472,8 @@ __metadata:
"@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:24.3.0"
"@expo/eas-json": "npm:24.3.0"
"@expo/eas-build-job": "npm:24.5.0"
"@expo/eas-json": "npm:24.5.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:24.0.0"
@@ -10487,7 +10488,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:24.3.0"
"@expo/steps": "npm:24.5.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"
@@ -15369,6 +15370,15 @@ __metadata:
languageName: node
linkType: hard
"node-addon-api@npm:^7.1.0":
version: 7.1.1
resolution: "node-addon-api@npm:7.1.1"
dependencies:
node-gyp: "npm:latest"
checksum: 10c0/fb32a206276d608037fa1bcd7e9921e177fe992fc610d098aa3128baca3c0050fc1e014fa007e9b3874cf865ddb4f5bd9f43ccb7cbbbe4efaff6a83e920b17e9
languageName: node
linkType: hard
"node-domexception@npm:^1.0.0":
version: 1.0.0
resolution: "node-domexception@npm:1.0.0"
@@ -15528,6 +15538,16 @@ __metadata:
languageName: node
linkType: hard
"node-pty@npm:1.2.0-beta.15":
version: 1.2.0-beta.15
resolution: "node-pty@npm:1.2.0-beta.15"
dependencies:
node-addon-api: "npm:^7.1.0"
node-gyp: "npm:latest"
checksum: 10c0/2f2bcca18efe94f56fd5c9fc9611164f63af912b96286ea4d858f4886e3b25aa9c529e3d7680d47e5ddefc6c3e799786ed11dc4b80947e40438e00a67dbc2fbf
languageName: node
linkType: hard
"node-releases@npm:^2.0.1":
version: 2.0.1
resolution: "node-releases@npm:2.0.1"