mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a109648a6 | ||
|
|
ea139e207c | ||
|
|
7b3c8cf245 | ||
|
|
efbe0bbdff | ||
|
|
8f645d8870 | ||
|
|
97007d17cc | ||
|
|
0dbaa34a8b | ||
|
|
2fc537f9a7 | ||
|
|
71cf0aea74 | ||
|
|
f6a3028356 | ||
|
|
3182070f4c | ||
|
|
097b58c7a9 | ||
|
|
9584d05708 | ||
|
|
b9ac59b336 |
No files matched your search
+26
-1
@@ -8,12 +8,37 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 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))
|
||||
- [build-tools] Stream iOS Simulator previews with H.264. ([#4416](https://github.com/expo/eas-cli/pull/4416) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Report the expo.dev preview page as a simulator session's preview URL, and the preview server behind it as `previewApiUrl`. ([#4353](https://github.com/expo/eas-cli/pull/4353) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] `eas simulator:start` and `eas simulator:get` now report the expo.dev preview page instead of the raw tunnel URL, in both the printed instructions and `--json`. ([#4352](https://github.com/expo/eas-cli/pull/4352) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Honor `EAS_OVERRIDE_PACKAGE_MANAGER` and `EAS_FALLBACK_PACKAGE_MANAGER` when installing and launching simulator packages (serve-sim, device-hub, Argent, agent-device, Appium) so they can use bun instead of stalling on npm audit. ([#4343](https://github.com/expo/eas-cli/pull/4343) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.6.0](https://github.com/expo/eas-cli/releases/tag/v24.6.0) - 2026-09-15
|
||||
|
||||
### 🎉 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
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.4.2",
|
||||
"PRODUCTION": "24.4.2"
|
||||
"STAGING": "24.6.0",
|
||||
"PRODUCTION": "24.6.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.5.0",
|
||||
"version": "24.7.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "24.5.0",
|
||||
"version": "24.7.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -256,7 +256,9 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects sharding with maestro-runner', async () => {
|
||||
it('passes shards to maestro-runner as parallel devices', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
@@ -264,10 +266,9 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
shards: 2,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'maestro-runner does not support EAS Maestro test sharding'
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
|
||||
});
|
||||
|
||||
it('rejects a non-junit output_format with maestro-runner', async () => {
|
||||
|
||||
@@ -52,7 +52,11 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
spawnDetached: () => ({ pid: 123, getOutput: () => '', stopAsync: jest.fn() }),
|
||||
waitForFileAsync: async () => ({ port: 1234, token: 'controller-token' }),
|
||||
startNgrokTunnelAsync: async () => ({ url: 'https://controller.test', stopAsync: jest.fn() }),
|
||||
startNgrokTunnelAsync: async () => ({
|
||||
url: 'https://controller.test',
|
||||
subdomainId: 'controller-id',
|
||||
stopAsync: jest.fn(),
|
||||
}),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
@@ -115,7 +119,8 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
|
||||
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://preview.test',
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://preview.test',
|
||||
previewToken: 'preview-secret',
|
||||
stopAsync: stopPreview,
|
||||
});
|
||||
|
||||
+188
-2
@@ -1,9 +1,38 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { stopAgentDeviceEventCollectionSafelyAsync } from '../startAgentDeviceRemoteSession';
|
||||
import { spawnDetached } from '../../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
startAgentDeviceDaemonAsync,
|
||||
stopAgentDeviceEventCollectionSafelyAsync,
|
||||
} from '../startAgentDeviceRemoteSession';
|
||||
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
spawnDetached: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
|
||||
async function writeDaemonEntry(cwd: string): Promise<string> {
|
||||
const daemonPath = path.join(
|
||||
cwd,
|
||||
'node_modules',
|
||||
'agent-device',
|
||||
'dist',
|
||||
'src',
|
||||
'internal',
|
||||
'daemon.js'
|
||||
);
|
||||
await fs.promises.mkdir(path.dirname(daemonPath), { recursive: true });
|
||||
await fs.promises.writeFile(daemonPath, '');
|
||||
return daemonPath;
|
||||
}
|
||||
|
||||
describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
|
||||
beforeEach(() => {
|
||||
@@ -12,7 +41,6 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
|
||||
|
||||
it('reports an unexpected stop failure without rejecting', async () => {
|
||||
const error = new Error('stop failed');
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
|
||||
await expect(
|
||||
stopAgentDeviceEventCollectionSafelyAsync({
|
||||
@@ -37,3 +65,161 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(startAgentDeviceDaemonAsync, () => {
|
||||
const stopAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
stopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 1234,
|
||||
getOutput: () => '',
|
||||
stopAsync,
|
||||
});
|
||||
jest.mocked(spawn).mockImplementation((async (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: { cwd?: string }
|
||||
) => {
|
||||
if (command === 'bun' && args[0] === '--version') {
|
||||
return { stdout: '1.3.11\n' };
|
||||
}
|
||||
if (typeof options?.cwd === 'string' && command !== 'git') {
|
||||
await writeDaemonEntry(options.cwd);
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
});
|
||||
|
||||
it('installs with bun add by default and launches the published daemon', async () => {
|
||||
const handle = await startAgentDeviceDaemonAsync({
|
||||
packageVersion: '1.2.3',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['add', '[email protected]'],
|
||||
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
|
||||
);
|
||||
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
expect(spawnDetached).toHaveBeenCalledWith({
|
||||
command: 'node',
|
||||
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
|
||||
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
|
||||
});
|
||||
|
||||
await handle.stopAsync();
|
||||
await expect(fs.promises.access(addCwd)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('installs with npm when EAS_OVERRIDE_PACKAGE_MANAGER is npm', async () => {
|
||||
await startAgentDeviceDaemonAsync({
|
||||
packageVersion: undefined,
|
||||
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npm',
|
||||
['install', '--no-audit', 'agent-device@latest'],
|
||||
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to git clone when the published daemon is missing', async () => {
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'bun' && args[0] === '--version') {
|
||||
return { stdout: '1.3.11\n' };
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
|
||||
await startAgentDeviceDaemonAsync({
|
||||
packageVersion: '1.2.3',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'git',
|
||||
[
|
||||
'clone',
|
||||
'--depth',
|
||||
'1',
|
||||
'--branch',
|
||||
'v1.2.3',
|
||||
'https://github.com/callstack/agent-device.git',
|
||||
'/tmp/agent-device-src',
|
||||
],
|
||||
expect.objectContaining({ env: {}, logger })
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--production'],
|
||||
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
|
||||
);
|
||||
expect(spawnDetached).toHaveBeenCalledWith({
|
||||
command: 'bun',
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: '/tmp/agent-device-src',
|
||||
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
|
||||
});
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: expect.objectContaining({
|
||||
packageSpec: '[email protected]',
|
||||
packageManager: 'bun',
|
||||
bunVersion: '1.3.11',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('clones latest and reports unknown bun when diagnostics fail', async () => {
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'bun' && args[0] === '--version') {
|
||||
throw new Error('bun missing');
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
|
||||
await startAgentDeviceDaemonAsync({
|
||||
packageVersion: undefined,
|
||||
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'git',
|
||||
[
|
||||
'clone',
|
||||
'--depth',
|
||||
'1',
|
||||
'https://github.com/callstack/agent-device.git',
|
||||
'/tmp/agent-device-src',
|
||||
],
|
||||
expect.objectContaining({ logger })
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npm',
|
||||
['install', '--no-audit', '--omit=dev'],
|
||||
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: expect.objectContaining({
|
||||
packageVersion: 'latest',
|
||||
packageManager: 'npm',
|
||||
bunVersion: 'unknown',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+68
-1
@@ -1,10 +1,16 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
|
||||
import { resolveAppium3VersionSpec, resolveAppiumDeviceAsync } from '../startAppiumRemoteSession';
|
||||
import {
|
||||
installAppiumAsync,
|
||||
resolveAppium3VersionSpec,
|
||||
resolveAppiumDeviceAsync,
|
||||
} from '../startAppiumRemoteSession';
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
|
||||
@@ -15,6 +21,7 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as never;
|
||||
|
||||
@@ -84,3 +91,63 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(installAppiumAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args.includes('--json')) {
|
||||
return { stdout: JSON.stringify({}) };
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
});
|
||||
|
||||
it('installs Appium with npm by default', async () => {
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: { EXISTING: 'value' },
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'npm',
|
||||
['install', '--no-audit', 'appium@^3'],
|
||||
expect.objectContaining({
|
||||
cwd: result.appiumHome,
|
||||
env: expect.objectContaining({ APPIUM_HOME: result.appiumHome, EXISTING: 'value' }),
|
||||
})
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
result.appiumBinPath,
|
||||
['driver', 'install', 'xcuitest'],
|
||||
expect.objectContaining({ env: result.appiumEnv, logger })
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('installs Appium with bun add when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '3.5.0',
|
||||
driverName: 'uiautomator2',
|
||||
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' },
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'bun',
|
||||
['add', '[email protected]'],
|
||||
expect.objectContaining({ cwd: result.appiumHome })
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
+5
-2
@@ -88,10 +88,12 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
url: 'https://argent-abc.tunnel.example.com',
|
||||
subdomainId: 'argent-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.tunnel.example.com',
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
stopAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
@@ -179,7 +181,8 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
webPreviewUrl: 'https://web-preview.tunnel.example.com',
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.tunnel.example.com',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -74,7 +74,7 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
|
||||
).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Continuing and letting bun x resolve it.')
|
||||
expect.stringContaining('Continuing and letting the package manager resolve it.')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,11 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
|
||||
const tunnel = { url: 'https://egress.example.com', stopAsync: jest.fn() };
|
||||
const tunnel = {
|
||||
url: 'https://egress.example.com',
|
||||
subdomainId: 'egress-id',
|
||||
stopAsync: jest.fn(),
|
||||
};
|
||||
|
||||
async function start(signal?: AbortSignal): Promise<void> {
|
||||
await createStartLocalEgressBuildFunction().fn!({ logger } as BuildStepContext, {
|
||||
|
||||
+10
-4
@@ -44,7 +44,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
stopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
@@ -54,7 +55,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
|
||||
it('reports the session token when serve-sim minted one', async () => {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
stopAsync,
|
||||
});
|
||||
@@ -64,7 +66,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: {
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
},
|
||||
})
|
||||
@@ -89,7 +92,10 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
remoteConfig: { previewUrl: 'https://web-preview.example.test' },
|
||||
remoteConfig: {
|
||||
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
},
|
||||
logger,
|
||||
});
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
|
||||
|
||||
@@ -108,6 +108,9 @@ function buildMaestroArgs({
|
||||
}
|
||||
case 'maestro-runner': {
|
||||
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
|
||||
if (shards !== undefined && shards > 1) {
|
||||
args.push(`--parallel=${shards}`);
|
||||
}
|
||||
if (includeTags) {
|
||||
args.push(`--include-tags=${includeTags}`);
|
||||
}
|
||||
@@ -259,12 +262,6 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
undefined,
|
||||
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
|
||||
);
|
||||
if (backend === 'maestro-runner' && shards !== undefined && shards > 1) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
'maestro-runner does not support EAS Maestro test sharding. Remove shards or set it to 1.'
|
||||
);
|
||||
}
|
||||
if (backend === 'maestro-runner' && outputFormat !== undefined && outputFormat !== 'junit') {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageAdd,
|
||||
resolvePackageInstall,
|
||||
} from '../../utils/packageManager';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
import {
|
||||
@@ -122,7 +128,9 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
logger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
);
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
@@ -132,7 +140,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
@@ -183,6 +192,74 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
});
|
||||
}
|
||||
|
||||
export async function startAgentDeviceDaemonAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
const packageSpec = createAgentDevicePackageSpec(packageVersion);
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-'));
|
||||
await fs.promises.writeFile(
|
||||
path.join(installDir, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-agent-device', private: true })}\n`
|
||||
);
|
||||
|
||||
try {
|
||||
const add = resolvePackageAdd(packageManager, packageSpec);
|
||||
logger.info(`Installing ${packageSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, { cwd: installDir, env, logger });
|
||||
|
||||
const daemonPath = getInstalledAgentDeviceDaemonPath(installDir);
|
||||
if (!fs.existsSync(daemonPath)) {
|
||||
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
|
||||
}
|
||||
|
||||
logger.info(`Launching daemon from ${daemonPath} after ${add.command} install.`);
|
||||
const daemonProcess = spawnDetached({
|
||||
command: 'node',
|
||||
args: [daemonPath],
|
||||
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
|
||||
});
|
||||
return {
|
||||
...daemonProcess,
|
||||
stopAsync: async () => {
|
||||
await daemonProcess.stopAsync();
|
||||
await fs.promises.rm(installDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
await fs.promises.rm(installDir, { recursive: true, force: true });
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
|
||||
Sentry.capture(
|
||||
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
|
||||
error,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: {
|
||||
phase: 'agent-device-daemon-start',
|
||||
fallback: 'git-clone',
|
||||
},
|
||||
extras: {
|
||||
packageSpec,
|
||||
packageVersion: packageVersion ?? 'latest',
|
||||
packageManager,
|
||||
bunVersion,
|
||||
},
|
||||
}
|
||||
);
|
||||
logger.warn(
|
||||
`Failed to start daemon from ${packageSpec} via ${packageManager}; falling back to git clone: ${error.message}`
|
||||
);
|
||||
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
@@ -205,61 +282,6 @@ export async function stopAgentDeviceEventCollectionSafelyAsync({
|
||||
}
|
||||
}
|
||||
|
||||
async function startAgentDeviceDaemonAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
const packageSpec = createAgentDevicePackageSpec(packageVersion);
|
||||
try {
|
||||
logger.info(`Installing ${packageSpec} globally with Bun.`);
|
||||
await spawn('bun', ['add', '--global', packageSpec], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
const daemonPath = getGlobalAgentDeviceDaemonPath(env);
|
||||
if (!fs.existsSync(daemonPath)) {
|
||||
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
|
||||
}
|
||||
|
||||
logger.info(`Launching daemon from ${daemonPath}.`);
|
||||
return spawnDetached({
|
||||
command: 'node',
|
||||
args: [daemonPath],
|
||||
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
|
||||
Sentry.capture(
|
||||
'Failed to start agent-device daemon from global Bun package; falling back to git clone',
|
||||
error,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: {
|
||||
phase: 'agent-device-daemon-start',
|
||||
fallback: 'git-clone',
|
||||
},
|
||||
extras: {
|
||||
packageSpec,
|
||||
packageVersion: packageVersion ?? 'latest',
|
||||
bunVersion,
|
||||
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
|
||||
},
|
||||
}
|
||||
);
|
||||
logger.warn(
|
||||
`Failed to start daemon from global ${packageSpec}; falling back to git clone: ${error.message}`
|
||||
);
|
||||
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
|
||||
}
|
||||
}
|
||||
|
||||
async function startAgentDeviceDaemonFromGitAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
@@ -276,14 +298,17 @@ async function startAgentDeviceDaemonFromGitAsync({
|
||||
);
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
||||
|
||||
logger.info('Installing agent-device dependencies.');
|
||||
await spawn('bun', ['install', '--production'], {
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const install = resolvePackageInstall(packageManager, { production: true });
|
||||
logger.info(`Installing agent-device dependencies with ${install.command}.`);
|
||||
await spawn(install.command, install.args, {
|
||||
cwd: SRC_DIR,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Launching daemon from cloned agent-device source.');
|
||||
// Git fallback is TypeScript source. The published path runs node on dist JS.
|
||||
return spawnDetached({
|
||||
command: 'bun',
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
@@ -346,11 +371,9 @@ function createAgentDevicePackageSpec(packageVersion: string | undefined): strin
|
||||
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
|
||||
}
|
||||
|
||||
function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
|
||||
function getInstalledAgentDeviceDaemonPath(installDir: string): string {
|
||||
return path.join(
|
||||
getBunInstallDirectory(env),
|
||||
'install',
|
||||
'global',
|
||||
installDir,
|
||||
'node_modules',
|
||||
AGENT_DEVICE_PACKAGE_NAME,
|
||||
'dist',
|
||||
@@ -360,11 +383,6 @@ function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
|
||||
);
|
||||
}
|
||||
|
||||
function getBunInstallDirectory(env: BuildStepEnv): string {
|
||||
const bunInstall = env.BUN_INSTALL?.trim();
|
||||
return bunInstall ? bunInstall : path.join(os.homedir(), '.bun');
|
||||
}
|
||||
|
||||
function parseDaemonInfo(raw: string): { port: number; token: string } {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
} from '../utils/localEgressSession';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageAdd,
|
||||
} from '../../utils/packageManager';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
@@ -153,7 +158,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
'appium:automationName': device.automationName,
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
@@ -198,7 +204,7 @@ export function resolveAppium3VersionSpec(packageVersion: string | undefined): s
|
||||
return versionSpec;
|
||||
}
|
||||
|
||||
type AppiumDevice = {
|
||||
export type AppiumDevice = {
|
||||
platformName: 'iOS' | 'Android';
|
||||
automationName: 'XCUITest' | 'UiAutomator2';
|
||||
driverName: 'xcuitest' | 'uiautomator2';
|
||||
@@ -247,7 +253,7 @@ export async function resolveAppiumDeviceAsync({
|
||||
}
|
||||
}
|
||||
|
||||
async function installAppiumAsync({
|
||||
export async function installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName,
|
||||
env,
|
||||
@@ -265,9 +271,14 @@ async function installAppiumAsync({
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
const add = resolvePackageAdd(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
`appium@${versionSpec}`
|
||||
);
|
||||
|
||||
logger.info(`Installing appium@${versionSpec}.`);
|
||||
await spawn('npm', ['install', '--prefix', appiumHome, `appium@${versionSpec}`], {
|
||||
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, {
|
||||
cwd: appiumHome,
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { isProcessDescendantOfAsync } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
@@ -113,41 +118,44 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
// Never rejects, so `void` is safe.
|
||||
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const argentExec = (args: string[]): { command: string; args: string[] } =>
|
||||
resolvePackageExec(packageManager, args);
|
||||
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
await spawn(
|
||||
'bun',
|
||||
[
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
],
|
||||
{ env, logger }
|
||||
);
|
||||
const enableArtifacts = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
]);
|
||||
await spawn(enableArtifacts.command, enableArtifacts.args, { env, logger });
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
await spawn(
|
||||
'bun',
|
||||
['x', `${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
|
||||
{ env, logger }
|
||||
);
|
||||
const enableEventLog = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_EVENT_LOG_FLAG,
|
||||
]);
|
||||
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
|
||||
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bun x.`);
|
||||
// Keep Argent itself in foreground mode under the detached bun process. This preserves
|
||||
// the bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
]);
|
||||
logger.info(
|
||||
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const argentServer = spawnDetached({
|
||||
command: 'bun',
|
||||
args: [
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
],
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH },
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
@@ -216,7 +224,9 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
logger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
);
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
@@ -226,7 +236,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
@@ -306,7 +317,7 @@ export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
logger.warn(
|
||||
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
|
||||
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
|
||||
`Continuing and letting bun x resolve it.`
|
||||
`Continuing and letting the package manager resolve it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
packageVersion,
|
||||
});
|
||||
logger.info(`Preview URL: ${webPreview.previewUrl}`);
|
||||
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
|
||||
|
||||
try {
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
@@ -70,7 +70,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
previewUrl: webPreview.previewUrl,
|
||||
previewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
turnIceServersToWebPreviewArgs,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForWebPreviewReadyAsync,
|
||||
websiteOrigin,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
@@ -130,7 +131,6 @@ describe(createServeSimArgs, () => {
|
||||
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
|
||||
})
|
||||
).toEqual([
|
||||
'--yes',
|
||||
'@expo/serve-sim@latest',
|
||||
'--port',
|
||||
'4321',
|
||||
@@ -140,7 +140,7 @@ describe(createServeSimArgs, () => {
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'vp8',
|
||||
'h264',
|
||||
'--max-dimension',
|
||||
'960',
|
||||
'--mjpeg-quality',
|
||||
@@ -169,17 +169,15 @@ describe(createServeSimArgs, () => {
|
||||
});
|
||||
|
||||
it('pins the requested package version', () => {
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' }).slice(0, 2)).toEqual([
|
||||
'--yes',
|
||||
'@expo/[email protected]',
|
||||
]);
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' })[0]).toBe(
|
||||
'@expo/[email protected]'
|
||||
);
|
||||
});
|
||||
|
||||
it('pins a dist-tag', () => {
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' }).slice(0, 2)).toEqual([
|
||||
'--yes',
|
||||
'@expo/serve-sim@next',
|
||||
]);
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' })[0]).toBe(
|
||||
'@expo/serve-sim@next'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,7 +189,6 @@ describe(createExpoDeviceHubArgs, () => {
|
||||
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
|
||||
})
|
||||
).toEqual([
|
||||
'--yes',
|
||||
'expo-device-hub@latest',
|
||||
'--port',
|
||||
'4321',
|
||||
@@ -219,10 +216,9 @@ describe(createExpoDeviceHubArgs, () => {
|
||||
});
|
||||
|
||||
it('pins the requested package version', () => {
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' }).slice(0, 2)).toEqual([
|
||||
'--yes',
|
||||
'[email protected]',
|
||||
]);
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' })[0]).toBe(
|
||||
'[email protected]'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -309,6 +305,11 @@ describe(startNgrokTunnelAsync, () => {
|
||||
})
|
||||
);
|
||||
expect(tunnel.url).toBe('https://web-preview.example.test');
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
domain: `web-preview-${tunnel.subdomainId}.eas-simulator.ngrok.dev`,
|
||||
})
|
||||
);
|
||||
await tunnel.stopAsync();
|
||||
await tunnel.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
@@ -419,9 +420,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(command).toBe('npx');
|
||||
expect(args).toEqual(createExpoDeviceHubArgs({ port, turnArgs, packageVersion }));
|
||||
expect(args).toEqual(['--yes', ...createExpoDeviceHubArgs({ port, turnArgs, packageVersion })]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.previewUrl).toBe('https://android-preview.example.test');
|
||||
expect(preview.apiUrl).toBe('https://android-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
@@ -442,7 +443,38 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
});
|
||||
|
||||
expect(preview.previewToken).toBe('tok-1');
|
||||
expect(preview.previewUrl).toBe('https://preview.example.test');
|
||||
expect(preview.apiUrl).toBe('https://preview.example.test');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}, 'https://expo.dev'],
|
||||
[{ EXPO_STAGING: '1' }, 'https://staging.expo.dev'],
|
||||
[{ EXPO_LOCAL: '1' }, 'https://expo.test'],
|
||||
])('points the preview page at the website for the stage the worker runs in', (stage, origin) => {
|
||||
expect(websiteOrigin({ ...env, ...stage })).toBe(origin);
|
||||
});
|
||||
|
||||
it('points the preview URL at the website page for the tunnel', async () => {
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env: { ...env, EXPO_STAGING: '1' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(preview.previewPageUrl).toMatch(
|
||||
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
|
||||
);
|
||||
const previewId = preview.previewPageUrl.split('/').at(-1);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ domain: `web-preview-${previewId}.${baseDomain}` })
|
||||
);
|
||||
});
|
||||
|
||||
// serve-sim is always launched with --require-token, so a missing token means it is running
|
||||
@@ -505,9 +537,18 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(command).toBe('npx');
|
||||
expect(args).toEqual(createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }));
|
||||
expect(args).toEqual([
|
||||
'--yes',
|
||||
...createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
metricsCorsArgs,
|
||||
frameAncestorArgs: ['--frame-ancestor', 'https://expo.dev'],
|
||||
packageVersion,
|
||||
}),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.previewUrl).toBe('https://ios-preview.example.test');
|
||||
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
@@ -538,6 +579,73 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('launches serve-sim with bun x when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://ios-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const bunEnv = { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env: bunEnv,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion: '4.5.6',
|
||||
});
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(command).toBe('bun');
|
||||
expect(args).toEqual([
|
||||
'x',
|
||||
...createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
metricsCorsArgs,
|
||||
frameAncestorArgs: ['--frame-ancestor', 'https://expo.dev'],
|
||||
packageVersion: '4.5.6',
|
||||
}),
|
||||
]);
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('launches serve-sim with bun x when EAS_FALLBACK_PACKAGE_MANAGER is bun', async () => {
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://ios-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env: { ...env, EAS_FALLBACK_PACKAGE_MANAGER: 'bun' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(command).toBe('bun');
|
||||
expect(args).toEqual([
|
||||
'x',
|
||||
...createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
metricsCorsArgs,
|
||||
frameAncestorArgs: ['--frame-ancestor', 'https://expo.dev'],
|
||||
}),
|
||||
]);
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe(turnIceServersToWebPreviewArgs, () => {
|
||||
|
||||
@@ -15,6 +15,11 @@ import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
@@ -22,6 +27,9 @@ import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetrics
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
// Pinned while H.264 is known to stall at larger encode sizes without recovering.
|
||||
// Passing this explicitly also overrides serve-sim's own default H.264 cap, so raising it
|
||||
// loses that guard too.
|
||||
const SERVE_SIM_MAX_DIMENSION = '960';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '6000000';
|
||||
@@ -549,7 +557,7 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise<void>
|
||||
}
|
||||
try {
|
||||
// spawnDetached creates a dedicated process group. Signaling the group also
|
||||
// terminates npx/bunx descendants instead of leaving the actual daemon alive.
|
||||
// terminates npx/bun descendants instead of leaving the actual daemon alive.
|
||||
process.kill(-pid, 'SIGTERM');
|
||||
} catch {
|
||||
try {
|
||||
@@ -612,6 +620,14 @@ export function spawnDetached({
|
||||
};
|
||||
}
|
||||
|
||||
export function websiteOrigin(env: BuildStepEnv): string {
|
||||
return env.EXPO_LOCAL
|
||||
? 'https://expo.test'
|
||||
: env.EXPO_STAGING
|
||||
? 'https://staging.expo.dev'
|
||||
: 'https://expo.dev';
|
||||
}
|
||||
|
||||
export function metricsCorsOriginToServeSimArgs(env: BuildStepEnv): string[] {
|
||||
const origin = env.EAS_SIMULATOR_METRICS_CORS_ORIGIN;
|
||||
if (!origin) {
|
||||
@@ -639,15 +655,16 @@ export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
metricsCorsArgs = [],
|
||||
frameAncestorArgs = [],
|
||||
packageVersion,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
metricsCorsArgs?: string[];
|
||||
frameAncestorArgs?: string[];
|
||||
packageVersion?: string;
|
||||
}): string[] {
|
||||
return [
|
||||
'--yes',
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
@@ -657,7 +674,7 @@ export function createServeSimArgs({
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'vp8',
|
||||
'h264',
|
||||
'--max-dimension',
|
||||
SERVE_SIM_MAX_DIMENSION,
|
||||
'--mjpeg-quality',
|
||||
@@ -668,6 +685,7 @@ export function createServeSimArgs({
|
||||
SERVE_SIM_VIDEO_FPS,
|
||||
...turnArgs,
|
||||
...metricsCorsArgs,
|
||||
...frameAncestorArgs,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -681,7 +699,6 @@ export function createExpoDeviceHubArgs({
|
||||
packageVersion?: string;
|
||||
}): string[] {
|
||||
return [
|
||||
'--yes',
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
@@ -771,7 +788,8 @@ export async function waitForWebPreviewReadyAsync({
|
||||
}
|
||||
|
||||
export type DeviceWebPreviewHandle = {
|
||||
previewUrl: string;
|
||||
previewPageUrl: string;
|
||||
apiUrl: string;
|
||||
/** Session token gating the preview. Only serve-sim mints one. */
|
||||
previewToken?: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
@@ -802,11 +820,17 @@ async function startWebPreviewWithTunnelAsync(
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
const port = await findAvailablePortAsync();
|
||||
logger.info(`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port}.`);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
createArgs(port, turnArgs)
|
||||
);
|
||||
logger.info(
|
||||
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
|
||||
);
|
||||
const previewServer = spawnDetached({
|
||||
command: 'npx',
|
||||
args: createArgs(port, turnArgs),
|
||||
command: previewExec.command,
|
||||
args: previewExec.args,
|
||||
env,
|
||||
});
|
||||
|
||||
@@ -827,7 +851,11 @@ async function startWebPreviewWithTunnelAsync(
|
||||
logger,
|
||||
});
|
||||
return {
|
||||
previewUrl: tunnel.url,
|
||||
previewPageUrl: new URL(
|
||||
`/simulator-preview/${tunnel.subdomainId}`,
|
||||
websiteOrigin(env)
|
||||
).toString(),
|
||||
apiUrl: tunnel.url,
|
||||
previewToken,
|
||||
stopAsync: async () => {
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
|
||||
@@ -869,6 +897,7 @@ export async function startServeSimWithTunnelAsync(
|
||||
}
|
||||
): Promise<ServeSimPreviewHandle> {
|
||||
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
|
||||
const frameAncestorArgs = ['--frame-ancestor', websiteOrigin(env)];
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
@@ -877,7 +906,7 @@ export async function startServeSimWithTunnelAsync(
|
||||
serverName: 'serve-sim',
|
||||
packageSpec: createServeSimPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) =>
|
||||
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
|
||||
createServeSimArgs({ port, turnArgs, metricsCorsArgs, frameAncestorArgs, packageVersion }),
|
||||
readPreviewTokenAsync: async device => {
|
||||
const previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
@@ -951,6 +980,7 @@ export async function startDeviceWebPreviewWithTunnelAsync(
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
subdomainId: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -969,7 +999,8 @@ export async function startNgrokTunnelAsync({
|
||||
rewriteHostHeader?: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<NgrokTunnelHandle> {
|
||||
const domain = `${subdomainPrefix}-${randomBytes(16).toString('hex')}.${baseDomain}`;
|
||||
const subdomainId = randomBytes(16).toString('hex');
|
||||
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
|
||||
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
|
||||
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
|
||||
// the process exits, and the step blocks forever to hold it open.
|
||||
@@ -987,6 +1018,7 @@ export async function startNgrokTunnelAsync({
|
||||
let stopped = false;
|
||||
return {
|
||||
url,
|
||||
subdomainId,
|
||||
stopAsync: async () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
PackageManager,
|
||||
findPackagerRootDir,
|
||||
getPackageVersionFromPackageJson,
|
||||
resolveConfiguredPackageManager,
|
||||
resolveFallbackPackageManager,
|
||||
resolveOverridePackageManager,
|
||||
resolvePackageAdd,
|
||||
resolvePackageExec,
|
||||
resolvePackageInstall,
|
||||
resolvePackageManager,
|
||||
resolvePackageVersionAsync,
|
||||
shouldUseFrozenLockfile,
|
||||
@@ -117,6 +123,176 @@ describe(resolvePackageManager, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveFallbackPackageManager, () => {
|
||||
it('returns undefined when unset or empty', () => {
|
||||
expect(resolveFallbackPackageManager({})).toBeUndefined();
|
||||
expect(resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: '' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns bun when set', () => {
|
||||
expect(resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bun' })).toBe(
|
||||
PackageManager.BUN
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a UserError on an unsupported value', () => {
|
||||
expect(() => resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' })).toThrow(
|
||||
errors.UserError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveOverridePackageManager, () => {
|
||||
it('returns undefined when unset or empty', () => {
|
||||
expect(resolveOverridePackageManager({})).toBeUndefined();
|
||||
expect(resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: '' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns bun when set', () => {
|
||||
expect(resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' })).toBe(
|
||||
PackageManager.BUN
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a UserError on an unsupported value', () => {
|
||||
expect(() => resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: 'bunn' })).toThrow(
|
||||
errors.UserError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveConfiguredPackageManager, () => {
|
||||
it('uses the unset default', () => {
|
||||
expect(resolveConfiguredPackageManager({}, PackageManager.NPM)).toBe(PackageManager.NPM);
|
||||
});
|
||||
|
||||
it('uses EAS_FALLBACK_PACKAGE_MANAGER when no override is set', () => {
|
||||
expect(
|
||||
resolveConfiguredPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bun' }, PackageManager.NPM)
|
||||
).toBe(PackageManager.BUN);
|
||||
});
|
||||
|
||||
it('prefers EAS_OVERRIDE_PACKAGE_MANAGER over the fallback', () => {
|
||||
expect(
|
||||
resolveConfiguredPackageManager(
|
||||
{
|
||||
EAS_OVERRIDE_PACKAGE_MANAGER: 'npm',
|
||||
EAS_FALLBACK_PACKAGE_MANAGER: 'bun',
|
||||
},
|
||||
PackageManager.BUN
|
||||
)
|
||||
).toBe(PackageManager.NPM);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageExec, () => {
|
||||
const packageArgs = ['@expo/serve-sim@latest', '--port', '1'];
|
||||
|
||||
it('maps npm to npx --yes', () => {
|
||||
expect(resolvePackageExec(PackageManager.NPM, packageArgs)).toEqual({
|
||||
command: 'npx',
|
||||
args: ['--yes', ...packageArgs],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps bun to bun x', () => {
|
||||
expect(resolvePackageExec(PackageManager.BUN, packageArgs)).toEqual({
|
||||
command: 'bun',
|
||||
args: ['x', ...packageArgs],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps yarn to npx --yes', () => {
|
||||
expect(resolvePackageExec(PackageManager.YARN, packageArgs)).toEqual({
|
||||
command: 'npx',
|
||||
args: ['--yes', ...packageArgs],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps pnpm to pnpm dlx', () => {
|
||||
expect(resolvePackageExec(PackageManager.PNPM, packageArgs)).toEqual({
|
||||
command: 'pnpm',
|
||||
args: ['dlx', ...packageArgs],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageAdd, () => {
|
||||
it('maps npm to npm install --no-audit', () => {
|
||||
expect(resolvePackageAdd(PackageManager.NPM, 'appium@^3')).toEqual({
|
||||
command: 'npm',
|
||||
args: ['install', '--no-audit', 'appium@^3'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps bun to bun add', () => {
|
||||
expect(resolvePackageAdd(PackageManager.BUN, 'agent-device@latest')).toEqual({
|
||||
command: 'bun',
|
||||
args: ['add', 'agent-device@latest'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps yarn to yarn add', () => {
|
||||
expect(resolvePackageAdd(PackageManager.YARN, 'appium@^3')).toEqual({
|
||||
command: 'yarn',
|
||||
args: ['add', 'appium@^3'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps pnpm to pnpm add', () => {
|
||||
expect(resolvePackageAdd(PackageManager.PNPM, 'appium@^3')).toEqual({
|
||||
command: 'pnpm',
|
||||
args: ['add', 'appium@^3'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageInstall, () => {
|
||||
it('maps bun production install', () => {
|
||||
expect(resolvePackageInstall(PackageManager.BUN, { production: true })).toEqual({
|
||||
command: 'bun',
|
||||
args: ['install', '--production'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps npm production install without audit', () => {
|
||||
expect(resolvePackageInstall(PackageManager.NPM, { production: true })).toEqual({
|
||||
command: 'npm',
|
||||
args: ['install', '--no-audit', '--omit=dev'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps yarn and pnpm production installs', () => {
|
||||
expect(resolvePackageInstall(PackageManager.YARN, { production: true })).toEqual({
|
||||
command: 'yarn',
|
||||
args: ['install', '--production'],
|
||||
});
|
||||
expect(resolvePackageInstall(PackageManager.PNPM, { production: true })).toEqual({
|
||||
command: 'pnpm',
|
||||
args: ['install', '--prod'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps non-production installs', () => {
|
||||
expect(resolvePackageInstall(PackageManager.NPM)).toEqual({
|
||||
command: 'npm',
|
||||
args: ['install', '--no-audit'],
|
||||
});
|
||||
expect(resolvePackageInstall(PackageManager.BUN)).toEqual({
|
||||
command: 'bun',
|
||||
args: ['install'],
|
||||
});
|
||||
expect(resolvePackageInstall(PackageManager.YARN)).toEqual({
|
||||
command: 'yarn',
|
||||
args: ['install'],
|
||||
});
|
||||
expect(resolvePackageInstall(PackageManager.PNPM)).toEqual({
|
||||
command: 'pnpm',
|
||||
args: ['install'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageVersionAsync, () => {
|
||||
const logger = {
|
||||
debug: jest.fn(),
|
||||
|
||||
@@ -30,19 +30,100 @@ export function resolvePackageManager(
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const fallback = env.EAS_FALLBACK_PACKAGE_MANAGER;
|
||||
if (fallback) {
|
||||
const parsed = z.enum(PackageManager).safeParse(fallback);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
const allowed = Object.values(PackageManager).join(', ');
|
||||
throw new errors.UserError(
|
||||
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER',
|
||||
`Invalid EAS_FALLBACK_PACKAGE_MANAGER value "${fallback}" (expected one of: ${allowed}).`
|
||||
);
|
||||
return resolveFallbackPackageManager(env) ?? PackageManager.YARN;
|
||||
}
|
||||
|
||||
function parsePackageManagerEnvValue(
|
||||
value: string | undefined,
|
||||
envName: string,
|
||||
errorCode: string
|
||||
): PackageManager | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = z.enum(PackageManager).safeParse(value);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
const allowed = Object.values(PackageManager).join(', ');
|
||||
throw new errors.UserError(
|
||||
errorCode,
|
||||
`Invalid ${envName} value "${value}" (expected one of: ${allowed}).`
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveFallbackPackageManager(env: BuildStepEnv): PackageManager | undefined {
|
||||
return parsePackageManagerEnvValue(
|
||||
env.EAS_FALLBACK_PACKAGE_MANAGER,
|
||||
'EAS_FALLBACK_PACKAGE_MANAGER',
|
||||
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER'
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveOverridePackageManager(env: BuildStepEnv): PackageManager | undefined {
|
||||
return parsePackageManagerEnvValue(
|
||||
env.EAS_OVERRIDE_PACKAGE_MANAGER,
|
||||
'EAS_OVERRIDE_PACKAGE_MANAGER',
|
||||
'EAS_INVALID_OVERRIDE_PACKAGE_MANAGER'
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveConfiguredPackageManager(
|
||||
env: BuildStepEnv,
|
||||
whenUnset: PackageManager
|
||||
): PackageManager {
|
||||
return resolveOverridePackageManager(env) ?? resolveFallbackPackageManager(env) ?? whenUnset;
|
||||
}
|
||||
|
||||
export function resolvePackageExec(
|
||||
manager: PackageManager,
|
||||
args: string[]
|
||||
): { command: string; args: string[] } {
|
||||
switch (manager) {
|
||||
case PackageManager.NPM:
|
||||
return { command: 'npx', args: ['--yes', ...args] };
|
||||
case PackageManager.BUN:
|
||||
return { command: 'bun', args: ['x', ...args] };
|
||||
case PackageManager.YARN:
|
||||
return { command: 'npx', args: ['--yes', ...args] };
|
||||
case PackageManager.PNPM:
|
||||
return { command: 'pnpm', args: ['dlx', ...args] };
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePackageAdd(
|
||||
manager: PackageManager,
|
||||
packageSpec: string
|
||||
): { command: string; args: string[] } {
|
||||
switch (manager) {
|
||||
case PackageManager.NPM:
|
||||
return { command: 'npm', args: ['install', '--no-audit', packageSpec] };
|
||||
case PackageManager.BUN:
|
||||
return { command: 'bun', args: ['add', packageSpec] };
|
||||
case PackageManager.YARN:
|
||||
return { command: 'yarn', args: ['add', packageSpec] };
|
||||
case PackageManager.PNPM:
|
||||
return { command: 'pnpm', args: ['add', packageSpec] };
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePackageInstall(
|
||||
manager: PackageManager,
|
||||
{ production }: { production?: boolean } = {}
|
||||
): { command: string; args: string[] } {
|
||||
switch (manager) {
|
||||
case PackageManager.NPM:
|
||||
return {
|
||||
command: 'npm',
|
||||
args: production ? ['install', '--no-audit', '--omit=dev'] : ['install', '--no-audit'],
|
||||
};
|
||||
case PackageManager.BUN:
|
||||
return { command: 'bun', args: production ? ['install', '--production'] : ['install'] };
|
||||
case PackageManager.YARN:
|
||||
return { command: 'yarn', args: production ? ['install', '--production'] : ['install'] };
|
||||
case PackageManager.PNPM:
|
||||
return { command: 'pnpm', args: production ? ['install', '--prod'] : ['install'] };
|
||||
}
|
||||
return PackageManager.YARN;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+133
-133
File diff suppressed because it is too large.
Load diff
Generated
+579
-25
@@ -6680,6 +6680,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "previewApiUrl",
|
||||
"description": "URL of the preview server, for reaching its API rather than its page.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webPreviewToken",
|
||||
"description": "Session token gating the web preview. Null when the preview runs ungated.",
|
||||
@@ -25267,16 +25279,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 +25406,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 +25442,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 +29533,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 +29842,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,
|
||||
@@ -33407,6 +33451,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "previewApiUrl",
|
||||
"description": "URL of the preview server, for reaching its API rather than its page.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webPreviewToken",
|
||||
"description": "Session token gating the web preview. Null when the preview runs ungated.",
|
||||
@@ -36268,6 +36324,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "previewApiUrl",
|
||||
"description": "URL of the preview server, for reaching its API rather than its page.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "toolsAuthToken",
|
||||
"description": null,
|
||||
@@ -76017,6 +76085,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 +77962,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",
|
||||
@@ -78654,6 +78964,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "previewApiUrl",
|
||||
"description": "URL of the preview server, for reaching its API rather than its page.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "previewToken",
|
||||
"description": "Session token gating the preview. Null when the preview runs ungated.",
|
||||
@@ -81266,6 +81588,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 +82193,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,
|
||||
@@ -93176,6 +93718,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "previewApiUrl",
|
||||
"description": "URL of the preview server, for reaching its API rather than its page.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "webPreviewToken",
|
||||
"description": "Session token gating the web preview. Null when the preview runs ungated.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "24.5.0",
|
||||
"version": "24.7.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -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...');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]}`
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
-15
@@ -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',
|
||||
|
||||
+2
-2
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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}` }));
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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(),
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
+1
-5
@@ -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',
|
||||
|
||||
+144
-20
@@ -1102,6 +1102,8 @@ export type AgentDeviceRunSessionRemoteConfig = {
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
/** URL of the preview server, for reaching its API rather than its page. */
|
||||
previewApiUrl?: Maybe<Scalars['String']['output']>;
|
||||
webPreviewToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* URL of the web preview surface for the session. Null when a web preview is
|
||||
@@ -3639,7 +3641,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 +3658,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 +4130,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 +4162,8 @@ export type AppObserveUserEventNamesOrderBy = {
|
||||
|
||||
export enum AppObserveUserEventNamesOrderByField {
|
||||
Count = 'COUNT',
|
||||
FirstSeen = 'FIRST_SEEN',
|
||||
LastSeen = 'LAST_SEEN',
|
||||
Name = 'NAME'
|
||||
}
|
||||
|
||||
@@ -4714,6 +4722,8 @@ export type AppiumRunSessionRemoteConfig = {
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
/** URL of the preview server, for reaching its API rather than its page. */
|
||||
previewApiUrl?: Maybe<Scalars['String']['output']>;
|
||||
webPreviewToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* URL of the web preview surface for the session. Null when a web preview is
|
||||
@@ -5141,6 +5151,8 @@ export type ArgentRunSessionRemoteConfig = {
|
||||
* session does not use local egress.
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** URL of the preview server, for reaching its API rather than its page. */
|
||||
previewApiUrl?: Maybe<Scalars['String']['output']>;
|
||||
toolsAuthToken?: Maybe<Scalars['String']['output']>;
|
||||
toolsUrl: Scalars['String']['output'];
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
@@ -10793,6 +10805,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 +11050,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'];
|
||||
@@ -11177,6 +11220,8 @@ export type ServeSimRunSessionRemoteConfig = {
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the preview. Null when the preview runs ungated. */
|
||||
/** URL of the preview server, for reaching its API rather than its page. */
|
||||
previewApiUrl?: Maybe<Scalars['String']['output']>;
|
||||
previewToken?: Maybe<Scalars['String']['output']>;
|
||||
previewUrl: Scalars['String']['output'];
|
||||
/** @deprecated Use previewUrl instead. */
|
||||
@@ -11546,6 +11591,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 +11696,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 +11713,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. */
|
||||
@@ -13260,6 +13345,8 @@ export type WebPreviewOnlyRunSessionRemoteConfig = {
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
/** URL of the preview server, for reaching its API rather than its page. */
|
||||
previewApiUrl?: Maybe<Scalars['String']['output']>;
|
||||
webPreviewToken?: Maybe<Scalars['String']['output']>;
|
||||
webPreviewUrl: Scalars['String']['output'];
|
||||
};
|
||||
@@ -16309,11 +16396,11 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{
|
||||
|
||||
|
||||
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, name?: string | null, tags: Array<string>, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, updatedAt: any, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, artifacts: Array<{ __typename?: 'DeviceRunSessionArtifact', id: string, name: string, filename: string, downloadUrl: string, fileSizeBytes?: number | null, metadata?: any | null, createdAt: any, updatedAt: any }>, remoteConfig?:
|
||||
| { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null, webPreviewToken?: string | null, egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null }
|
||||
| { __typename: 'AppiumRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, appiumUrl: string, capabilities: any, webPreviewUrl?: string | null, webPreviewToken?: string | null }
|
||||
| { __typename: 'ArgentRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null, webPreviewToken?: string | null }
|
||||
| { __typename: 'ServeSimRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null }
|
||||
| { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null }
|
||||
| { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null, webPreviewToken?: string | null, previewApiUrl?: string | null, egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null }
|
||||
| { __typename: 'AppiumRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, appiumUrl: string, capabilities: any, webPreviewUrl?: string | null, webPreviewToken?: string | null, previewApiUrl?: string | null }
|
||||
| { __typename: 'ArgentRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null, webPreviewToken?: string | null, previewApiUrl?: string | null }
|
||||
| { __typename: 'ServeSimRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null, previewApiUrl?: string | null }
|
||||
| { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewApiUrl?: string | null, previewUrl: string, previewToken?: string | null }
|
||||
| null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
|
||||
|
||||
export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
|
||||
@@ -16714,30 +16801,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 +16992,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 }
|
||||
|
||||
@@ -88,6 +88,7 @@ export const DeviceRunSessionQuery = {
|
||||
agentDeviceRemoteSessionToken
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
previewApiUrl
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
@@ -102,6 +103,7 @@ export const DeviceRunSessionQuery = {
|
||||
toolsAuthToken
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
previewApiUrl
|
||||
}
|
||||
... on AppiumRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
@@ -112,6 +114,7 @@ export const DeviceRunSessionQuery = {
|
||||
capabilities
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
previewApiUrl
|
||||
}
|
||||
... on ServeSimRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
@@ -120,6 +123,7 @@ export const DeviceRunSessionQuery = {
|
||||
egressPort
|
||||
previewUrl
|
||||
previewToken
|
||||
previewApiUrl
|
||||
}
|
||||
... on WebPreviewOnlyRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
@@ -128,6 +132,7 @@ export const DeviceRunSessionQuery = {
|
||||
egressPort
|
||||
previewUrl: webPreviewUrl
|
||||
previewToken: webPreviewToken
|
||||
previewApiUrl
|
||||
}
|
||||
}
|
||||
turtleJobRun {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -240,25 +240,39 @@ describe('simulator resource class flags', () => {
|
||||
});
|
||||
|
||||
describe(formatPreviewUrl, () => {
|
||||
it('appends the session token for a gated preview', () => {
|
||||
expect(formatPreviewUrl('https://preview.example.test', 'tok-1')).toBe(
|
||||
'https://preview.example.test/?token=tok-1'
|
||||
);
|
||||
const PAGE_URL = 'https://expo.dev/simulator-preview/abc';
|
||||
const SERVER_URL = 'https://web-preview-abc.eas-simulator.ngrok.dev';
|
||||
const pageConfig = {
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
|
||||
previewUrl: PAGE_URL,
|
||||
previewToken: 'tok-1',
|
||||
previewApiUrl: SERVER_URL,
|
||||
};
|
||||
const serverConfig = {
|
||||
__typename: 'ServeSimRunSessionRemoteConfig' as const,
|
||||
previewUrl: SERVER_URL,
|
||||
previewToken: 'tok-1',
|
||||
};
|
||||
|
||||
it('puts the token in the fragment for the expo.dev page, where no request carries it', () => {
|
||||
expect(formatPreviewUrl(pageConfig, PAGE_URL, 'tok-1')).toBe(`${PAGE_URL}#token=tok-1`);
|
||||
});
|
||||
|
||||
it('puts the token on the query for a session that predates the expo.dev page', () => {
|
||||
expect(formatPreviewUrl(serverConfig, SERVER_URL, 'tok-1')).toBe(`${SERVER_URL}/?token=tok-1`);
|
||||
});
|
||||
|
||||
it('leaves the url alone when the preview is ungated', () => {
|
||||
expect(formatPreviewUrl('https://preview.example.test', null)).toBe(
|
||||
'https://preview.example.test'
|
||||
);
|
||||
expect(formatPreviewUrl('https://preview.example.test', undefined)).toBe(
|
||||
'https://preview.example.test'
|
||||
);
|
||||
expect(formatPreviewUrl(pageConfig, PAGE_URL, null)).toBe(PAGE_URL);
|
||||
expect(formatPreviewUrl(pageConfig, PAGE_URL, undefined)).toBe(PAGE_URL);
|
||||
});
|
||||
});
|
||||
|
||||
describe(sanitizeRemoteConfigForJson, () => {
|
||||
const PREVIEW_URL = 'https://preview.example.test';
|
||||
|
||||
const PAGE_URL = 'https://expo.dev/simulator-preview/abc';
|
||||
|
||||
it('moves the token into the preview url and drops the standalone field', () => {
|
||||
const sanitized = sanitizeRemoteConfigForJson({
|
||||
__typename: 'ServeSimRunSessionRemoteConfig' as const,
|
||||
@@ -273,6 +287,22 @@ describe(sanitizeRemoteConfigForJson, () => {
|
||||
expect(JSON.stringify(sanitized)).not.toContain('previewToken');
|
||||
});
|
||||
|
||||
it('puts the token on the api url query, for a caller that cannot send a header', () => {
|
||||
const sanitized = sanitizeRemoteConfigForJson({
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
|
||||
previewUrl: PAGE_URL,
|
||||
previewToken: 'tok-1',
|
||||
previewApiUrl: PREVIEW_URL,
|
||||
});
|
||||
|
||||
expect(sanitized).toEqual({
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig',
|
||||
previewUrl: `${PAGE_URL}#token=tok-1`,
|
||||
previewApiUrl: `${PREVIEW_URL}/?token=tok-1`,
|
||||
});
|
||||
expect(JSON.stringify(sanitized)).not.toContain('previewToken');
|
||||
});
|
||||
|
||||
it('does the same for a controller session', () => {
|
||||
const sanitized = sanitizeRemoteConfigForJson({
|
||||
__typename: 'ArgentRunSessionRemoteConfig' as const,
|
||||
@@ -368,6 +398,48 @@ describe('gated preview links', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('prints the fragment link for every session the worker gave a preview api url', () => {
|
||||
const PAGE = 'https://expo.dev/simulator-preview/abc';
|
||||
const API = 'https://web-preview-abc.eas-simulator.ngrok.dev';
|
||||
const sessions = [
|
||||
{
|
||||
__typename: 'ServeSimRunSessionRemoteConfig' as const,
|
||||
previewUrl: PAGE,
|
||||
previewToken: 'tok-1',
|
||||
previewApiUrl: API,
|
||||
},
|
||||
{
|
||||
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
|
||||
previewUrl: PAGE,
|
||||
previewToken: 'tok-1',
|
||||
previewApiUrl: API,
|
||||
},
|
||||
{
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig' as const,
|
||||
agentDeviceRemoteSessionUrl: 'https://daemon.example.test',
|
||||
agentDeviceRemoteSessionToken: 'daemon-token',
|
||||
webPreviewUrl: PAGE,
|
||||
webPreviewToken: 'tok-1',
|
||||
previewApiUrl: API,
|
||||
},
|
||||
{
|
||||
__typename: 'ArgentRunSessionRemoteConfig' as const,
|
||||
toolsUrl: 'https://argent.example.test',
|
||||
toolsAuthToken: 'argent-token',
|
||||
webPreviewUrl: PAGE,
|
||||
webPreviewToken: 'tok-1',
|
||||
previewApiUrl: API,
|
||||
},
|
||||
{ ...iosAppiumConfig, webPreviewUrl: PAGE, webPreviewToken: 'tok-1', previewApiUrl: API },
|
||||
];
|
||||
|
||||
for (const remoteConfig of sessions) {
|
||||
const instructions = formatRemoteSessionInstructions(remoteConfig, 'env');
|
||||
expect(instructions).toContain(`${PAGE}#token=tok-1`);
|
||||
expect(instructions).not.toContain('?token=');
|
||||
}
|
||||
});
|
||||
|
||||
it('prints the plain url when the preview is ungated', () => {
|
||||
const instructions = formatRemoteSessionInstructions(
|
||||
{ __typename: 'ServeSimRunSessionRemoteConfig' as const, previewUrl: PREVIEW },
|
||||
|
||||
@@ -221,11 +221,29 @@ function getControllerEnvironmentVariables(
|
||||
|
||||
type RemoteSessionInstructionsConfigType = 'env' | 'dotenv';
|
||||
|
||||
function previewUrlIsWebsitePage(remoteConfig: DeviceRunSessionRemoteConfig): boolean {
|
||||
return remoteConfig.previewApiUrl != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview link for a session. A gated serve-sim needs the session token, and a browser cannot send
|
||||
* a header on a page load, so it rides the query. serve-sim swaps it for a cookie on the first load.
|
||||
* Preview link for a session. A gated preview needs the session token, and a browser cannot send
|
||||
* a header on a page load. The expo.dev preview page reads it from the fragment, which no request
|
||||
* carries; a preview server reads it from the query and swaps it for a cookie on the first load.
|
||||
*/
|
||||
export function formatPreviewUrl(url: string, token: string | null | undefined): string {
|
||||
export function formatPreviewUrl(
|
||||
remoteConfig: DeviceRunSessionRemoteConfig,
|
||||
url: string,
|
||||
token: string | null | undefined
|
||||
): string {
|
||||
if (!token || !previewUrlIsWebsitePage(remoteConfig)) {
|
||||
return formatPreviewApiUrl(url, token);
|
||||
}
|
||||
const withToken = new URL(url);
|
||||
withToken.hash = new URLSearchParams({ token }).toString();
|
||||
return withToken.toString();
|
||||
}
|
||||
|
||||
function formatPreviewApiUrl(url: string, token: string | null | undefined): string {
|
||||
if (!token) {
|
||||
return url;
|
||||
}
|
||||
@@ -235,9 +253,9 @@ export function formatPreviewUrl(url: string, token: string | null | undefined):
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote config for `--json`. The preview URL carries the token and the standalone token field is
|
||||
* dropped, so a consumer gets one URL that works rather than a bare URL that 401s next to a secret
|
||||
* it has to know to combine.
|
||||
* Remote config for `--json`. Both URLs carry the token and the standalone token field is dropped,
|
||||
* so a consumer gets URLs that work rather than bare URLs that 401 next to a secret it has to know
|
||||
* to combine.
|
||||
*/
|
||||
export function sanitizeRemoteConfigForJson(
|
||||
remoteConfig: DeviceRunSessionRemoteConfig
|
||||
@@ -246,7 +264,13 @@ export function sanitizeRemoteConfigForJson(
|
||||
case 'ServeSimRunSessionRemoteConfig':
|
||||
case 'WebPreviewOnlyRunSessionRemoteConfig': {
|
||||
const { previewToken, ...rest } = remoteConfig;
|
||||
return { ...rest, previewUrl: formatPreviewUrl(remoteConfig.previewUrl, previewToken) };
|
||||
return {
|
||||
...rest,
|
||||
previewUrl: formatPreviewUrl(remoteConfig, remoteConfig.previewUrl, previewToken),
|
||||
previewApiUrl: remoteConfig.previewApiUrl
|
||||
? formatPreviewApiUrl(remoteConfig.previewApiUrl, previewToken)
|
||||
: remoteConfig.previewApiUrl,
|
||||
};
|
||||
}
|
||||
case 'AgentDeviceRunSessionRemoteConfig':
|
||||
case 'ArgentRunSessionRemoteConfig':
|
||||
@@ -255,8 +279,11 @@ export function sanitizeRemoteConfigForJson(
|
||||
return {
|
||||
...rest,
|
||||
webPreviewUrl: remoteConfig.webPreviewUrl
|
||||
? formatPreviewUrl(remoteConfig.webPreviewUrl, webPreviewToken)
|
||||
? formatPreviewUrl(remoteConfig, remoteConfig.webPreviewUrl, webPreviewToken)
|
||||
: remoteConfig.webPreviewUrl,
|
||||
previewApiUrl: remoteConfig.previewApiUrl
|
||||
? formatPreviewApiUrl(remoteConfig.previewApiUrl, webPreviewToken)
|
||||
: remoteConfig.previewApiUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -331,7 +358,7 @@ function formatControllerInstructions(
|
||||
'',
|
||||
'🌐 Open the following URL in your browser to preview the simulator:',
|
||||
'',
|
||||
formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
|
||||
formatPreviewUrl(remoteConfig, remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
@@ -369,7 +396,7 @@ function formatControllerInstructions(
|
||||
'',
|
||||
'🌐 Open the following URL in your browser to preview the simulator:',
|
||||
'',
|
||||
formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
|
||||
formatPreviewUrl(remoteConfig, remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
@@ -397,7 +424,7 @@ function formatControllerInstructions(
|
||||
'',
|
||||
'Open the simulator preview:',
|
||||
'',
|
||||
formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
|
||||
formatPreviewUrl(remoteConfig, remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken)
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
@@ -406,13 +433,13 @@ function formatControllerInstructions(
|
||||
return [
|
||||
'🌐 Open the following URL in your browser to access the simulator:',
|
||||
'',
|
||||
formatPreviewUrl(remoteConfig.previewUrl, remoteConfig.previewToken),
|
||||
formatPreviewUrl(remoteConfig, remoteConfig.previewUrl, remoteConfig.previewToken),
|
||||
].join('\n');
|
||||
case 'WebPreviewOnlyRunSessionRemoteConfig':
|
||||
return [
|
||||
'🌐 Open the following URL in your browser to access the simulator:',
|
||||
'',
|
||||
formatPreviewUrl(remoteConfig.previewUrl, remoteConfig.previewToken),
|
||||
formatPreviewUrl(remoteConfig, remoteConfig.previewUrl, remoteConfig.previewToken),
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "24.5.0",
|
||||
"version": "24.7.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,7 +26,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "24.5.0",
|
||||
"@expo/build-tools": "24.7.0",
|
||||
"@expo/bunyan": "^4.0.1",
|
||||
"@expo/eas-build-job": "24.5.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "24.5.0",
|
||||
"version": "24.7.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "24.5.0",
|
||||
"@expo/build-tools": "24.7.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/eas-build-job": "24.5.0",
|
||||
|
||||
@@ -2291,7 +2291,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:24.5.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:24.7.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
@@ -2972,7 +2972,7 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:24.5.0"
|
||||
"@expo/build-tools": "npm:24.7.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/eas-build-job": "npm:24.5.0"
|
||||
@@ -10415,7 +10415,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:24.5.0"
|
||||
"@expo/build-tools": "npm:24.7.0"
|
||||
"@expo/bunyan": "npm:^4.0.1"
|
||||
"@expo/eas-build-job": "npm:24.5.0"
|
||||
"@expo/logger": "npm:24.0.0"
|
||||
|
||||
Reference in new issue
Block a user