mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92e1b01bd7 | ||
|
|
9adcdfab1c | ||
|
|
444c926bb8 | ||
|
|
3e73b077ce | ||
|
|
3350e5c6f3 | ||
|
|
3ffbbb2f45 | ||
|
|
4e453e5d9c | ||
|
|
4f7ba2d5a3 | ||
|
|
f65472ae0a | ||
|
|
40f2b55f96 | ||
|
|
dc759c0854 | ||
|
|
e38a605d89 | ||
|
|
a586f0cb68 | ||
|
|
eebfa728a7 | ||
|
|
bf478c719c | ||
|
|
a7fcb5140c | ||
|
|
f0a7377069 | ||
|
|
b755469716 | ||
|
|
1830f88603 | ||
|
|
75f83829d8 | ||
|
|
08c92bf937 | ||
|
|
1615d36cf9 |
No files matched your search
@@ -19,7 +19,9 @@
|
||||
"@expo/downloader",
|
||||
"@expo/turtle-spawn",
|
||||
"@expo/template-file",
|
||||
"create-eas-build-function"
|
||||
"create-eas-build-function",
|
||||
"@expo/vm-tools",
|
||||
"@expo/simulator-launcher"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
|
||||
@@ -2,6 +2,37 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [27.2.0](https://github.com/expo/eas-cli/releases/tag/v27.2.0) - 2026-10-29
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Use single-device previews when the session already has a valid device ID. ([#4733](https://github.com/expo/eas-cli/pull/4733) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add an experiment that suggests Expo Update to coding agents after a build when the Free plan account is within 5 builds of the platform limit. ([#4758](https://github.com/expo/eas-cli/pull/4758) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Add `eas observe:traces` to list request trace spans (or a summary of span names and counts), `eas observe:trace` to display one trace as a span tree, and span rows in the `eas observe:session` timeline. ([#4772](https://github.com/expo/eas-cli/pull/4772) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [simulator-launcher] Accept a comma-separated list of warm Simulators in `EXPO_SIMULATOR_DEVICE`; the first is the default. One serve-sim serves every listed device on one port. After the default device is ready, warmup preinstalls the latest stable Expo Go from one download and tries to disable apsd on the other devices, until a job claims one. A claim can select any listed device and reports its preinstalled Expo Go. ([#4764](https://github.com/expo/eas-cli/pull/4764) by [@gwdp](https://github.com/gwdp))
|
||||
- [simulator-launcher] Add `@expo/simulator-launcher`, which warms an iOS Simulator at VM boot and hands it to one EAS job (`start`, `claim`, `prepare-tools`, `stop`). ([#4722](https://github.com/expo/eas-cli/pull/4722) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Show a one-time Expo Simulators hint after a successful build. ([#4768](https://github.com/expo/eas-cli/pull/4768) by [@FiberJW](https://github.com/FiberJW))
|
||||
- [vm-tools] Add a macOS startup script loader that runs `/usr/local/share/vm-tools/startup.sh` once per VM boot. ([#4725](https://github.com/expo/eas-cli/pull/4725) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Explain missing background task identifiers reported by App Store Connect. ([#3465](https://github.com/expo/eas-cli/pull/3465) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain App Store Connect NOT_AUTHORIZED responses in the shared API client. ([#3462](https://github.com/expo/eas-cli/pull/3462) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client. ([#3467](https://github.com/expo/eas-cli/pull/3467) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details. ([#3466](https://github.com/expo/eas-cli/pull/3466) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain. ([#4660](https://github.com/expo/eas-cli/pull/4660) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [worker] Run package-manager version queries in parallel during worker preparation. ([#4710](https://github.com/expo/eas-cli/pull/4710) by [@gwdp](https://github.com/gwdp))
|
||||
- [worker] Start the Xcode version check in the background during worker tool preparation. Apply the Xcode 26.0 upload workaround when the query finishes, without delaying job startup. ([#4707](https://github.com/expo/eas-cli/pull/4707) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Fix a five-minute cleanup delay when a device session is canceled during startup and its screen recorder fails before connecting. ([#4761](https://github.com/expo/eas-cli/pull/4761) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Remove the Free plan build limit guidance experiment for coding agents from `eas build`. ([#4757](https://github.com/expo/eas-cli/pull/4757) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [worker] Allow jobs to skip installing the CocoaPods cache proxy with EAS_BUILD_DISABLE_COCOAPODS_CACHE_PLUGIN=1. ([#4709](https://github.com/expo/eas-cli/pull/4709) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Handle TURN requests for a stopped device session without reporting a warning. ([#4762](https://github.com/expo/eas-cli/pull/4762) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops. ([#4365](https://github.com/expo/eas-cli/pull/4365) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [simulator-launcher] Add the state, request command and service helpers for the warm Simulator launcher. The package stays private until #4722 adds its CLI entry point. ([#4766](https://github.com/expo/eas-cli/pull/4766) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [27.1.0](https://github.com/expo/eas-cli/releases/tag/v27.1.0) - 2026-10-28
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "26.0.0",
|
||||
"PRODUCTION": "26.0.0"
|
||||
"STAGING": "27.1.0",
|
||||
"PRODUCTION": "27.1.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "27.1.0",
|
||||
"version": "27.2.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,22 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 27.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Use single-device previews when the session already has a valid device ID. ([#4733](https://github.com/expo/eas-cli/pull/4733) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Explain missing background task identifiers reported by App Store Connect. ([#3465](https://github.com/expo/eas-cli/pull/3465) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Explain App Store Connect NOT_AUTHORIZED responses in the shared API client. ([#3462](https://github.com/expo/eas-cli/pull/3462) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Explain App Store Connect UNEXPECTED_ERROR responses in the shared API client. ([#3467](https://github.com/expo/eas-cli/pull/3467) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Explain missing app icons reported by App Store Connect, including codes 90022 and 90023, while preserving Apple’s validation details. ([#3466](https://github.com/expo/eas-cli/pull/3466) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- List failed Google Play commit attempts in order, including network failures, when the outcome is uncertain. ([#4660](https://github.com/expo/eas-cli/pull/4660) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- Fix a five-minute cleanup delay when a device session is canceled during startup and its screen recorder fails before connecting. ([#4761](https://github.com/expo/eas-cli/pull/4761) by [@gwdp](https://github.com/gwdp))
|
||||
- Handle TURN requests for a stopped device session without reporting a warning. ([#4762](https://github.com/expo/eas-cli/pull/4762) by [@gwdp](https://github.com/gwdp))
|
||||
- Preserve indented and incomplete Xcode compiler errors in formatted build logs, including the final diagnostic when logging stops. ([#4365](https://github.com/expo/eas-cli/pull/4365) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## 27.1.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "27.1.0",
|
||||
"version": "27.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -42,18 +42,18 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "27.1.0",
|
||||
"@expo/eas-build-job": "27.1.0",
|
||||
"@expo/downloader": "27.2.0",
|
||||
"@expo/eas-build-job": "27.2.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/logger": "27.2.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.1.0",
|
||||
"@expo/template-file": "27.1.0",
|
||||
"@expo/turtle-spawn": "27.1.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@expo/steps": "27.2.0",
|
||||
"@expo/template-file": "27.2.0",
|
||||
"@expo/turtle-spawn": "27.2.0",
|
||||
"@expo/xcpretty": "^4.4.7",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "^6.0.1",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EventEmitter } from 'events';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { ExpoRunFormatter } from '@expo/xcpretty';
|
||||
import fg from 'fast-glob';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
@@ -59,6 +60,47 @@ describe(XcodeBuildLogger, () => {
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushes the final incomplete compiler error when the log reader is interrupted', async () => {
|
||||
const { ExpoRunFormatter: RealFormatter } =
|
||||
jest.requireActual<typeof import('@expo/xcpretty')>('@expo/xcpretty');
|
||||
jest.mocked(ExpoRunFormatter.create).mockImplementation(RealFormatter.create);
|
||||
|
||||
let rejectReader!: (error: Error) => void;
|
||||
const spawnPromise = Object.assign(
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
rejectReader = reject;
|
||||
}),
|
||||
{
|
||||
child: {
|
||||
stdout,
|
||||
kill: () =>
|
||||
rejectReader(Object.assign(new Error('tail interrupted'), { signal: 'SIGINT' })),
|
||||
},
|
||||
}
|
||||
);
|
||||
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
|
||||
const diagnostics =
|
||||
'/project/First.swift:12:3: error: first compiler error\n' +
|
||||
' /project/Last.swift:20:4: error: final compiler error\n';
|
||||
vol.fromJSON({ '/logs/build.log': diagnostics });
|
||||
const logger = createMockLogger();
|
||||
const buildLogger = new XcodeBuildLogger(logger, '/project');
|
||||
await buildLogger.watchLogFiles('/logs');
|
||||
stdout.emit('data', diagnostics);
|
||||
|
||||
await buildLogger.flush();
|
||||
|
||||
const output = jest
|
||||
.mocked(logger.info)
|
||||
.mock.calls.map(([line]) => String(line))
|
||||
.join('\n');
|
||||
expect(output).toContain('first compiler error');
|
||||
expect(output).toContain('final compiler error');
|
||||
expect(output).toContain('Last.swift:20:4');
|
||||
expect(output).toContain('2 error(s)');
|
||||
expect(output.match(/final compiler error/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('falls back to raw logs when the formatter throws', async () => {
|
||||
const logger = createMockLogger();
|
||||
await startWatchingAsync(logger);
|
||||
|
||||
@@ -80,9 +80,12 @@ export class XcodeBuildLogger {
|
||||
this.logger.info(data.toString());
|
||||
}
|
||||
});
|
||||
await this.logReaderPromise;
|
||||
|
||||
this.logger.info(formatter.getBuildSummary());
|
||||
try {
|
||||
await this.logReaderPromise;
|
||||
} finally {
|
||||
// flush() interrupts tail, but the formatter may still hold a final compiler diagnostic.
|
||||
this.logger.info(formatter.getBuildSummary());
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!this.flushing) {
|
||||
this.loggerError = err;
|
||||
|
||||
@@ -382,6 +382,29 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
);
|
||||
}
|
||||
|
||||
it('passes the existing Android selection to the preview host', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
await runSessionAsync(logger);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ previewDeviceId: 'emulator-5554' })
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the selected iOS preview target without enabling Simulator startup', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
const udid = 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA';
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid }, { udid: 'BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB' }] as Awaited<
|
||||
ReturnType<typeof IosSimulatorUtils.getAvailableDevicesAsync>
|
||||
>);
|
||||
await runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN);
|
||||
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
expect(options).toHaveProperty('previewDeviceId', udid);
|
||||
expect(options.iosSimulatorUdid).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves preview settings with Simulator startup %s',
|
||||
async bootSimulator => {
|
||||
|
||||
@@ -198,7 +198,10 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
|
||||
return [204];
|
||||
});
|
||||
const task = submission();
|
||||
await expect(task.run()).rejects.toBeInstanceOf(SystemError);
|
||||
const error = await task.run().catch(error => error);
|
||||
expect(error).toBeInstanceOf(SystemError);
|
||||
expect((error as Error).message).toContain('HTTP 404');
|
||||
expect((error as Error).message).toContain('Could not confirm whether Google Play saved');
|
||||
expect(deleted).toBe(false);
|
||||
expect(task.setters.every(set => set.mock.calls.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
createUploadToAscBuildFunction,
|
||||
isClosedVersionTrainError,
|
||||
isInvalidBundleIdentifierError,
|
||||
isMissingAppIconsError,
|
||||
isMissingBackgroundTaskIdentifiersError,
|
||||
isMissingPurposeStringError,
|
||||
isRedundantBinaryUploadError,
|
||||
isSdkVersionIssueError,
|
||||
@@ -95,6 +97,41 @@ describe(isSdkVersionIssueError, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(isMissingBackgroundTaskIdentifiersError, () => {
|
||||
it('returns true when all errors are missing BG task identifiers codes', () => {
|
||||
expect(isMissingBackgroundTaskIdentifiersError([{ code: '90771' }, { code: '90771' }])).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isMissingBackgroundTaskIdentifiersError([{ code: '90771' }, { code: '90062' }])).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isMissingBackgroundTaskIdentifiersError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isMissingAppIconsError, () => {
|
||||
it.each([['90391', '90713', '90713'], ['90713', '90022', '90023'], ['90022'], ['90023']])(
|
||||
'recognizes missing-icon validation codes %j',
|
||||
(...codes) => {
|
||||
expect(isMissingAppIconsError(codes.map(code => ({ code })))).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isMissingAppIconsError([{ code: '90391' }, { code: '90771' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isMissingAppIconsError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMissingUsageDescriptionKeys, () => {
|
||||
it('extracts missing UsageDescription keys from ASC messages', () => {
|
||||
expect(
|
||||
@@ -191,3 +228,117 @@ describe(createUploadToAscBuildFunction, () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(createUploadToAscBuildFunction, () => {
|
||||
it('keeps Apple background-task details in the public error and the full state as its cause', async () => {
|
||||
const state = {
|
||||
state: 'FAILED',
|
||||
errors: [
|
||||
{
|
||||
code: '90771',
|
||||
description:
|
||||
'Missing BGTaskSchedulerPermittedIdentifiers while UIBackgroundModes includes processing.',
|
||||
},
|
||||
],
|
||||
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
|
||||
infos: [{ code: 'INFO', description: 'Apple information.' }],
|
||||
};
|
||||
await fs.writeFile('/app.ipa', 'ipa');
|
||||
await fs.writeFile('/key.json', '{}');
|
||||
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
|
||||
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
|
||||
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
|
||||
} as never);
|
||||
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
|
||||
data: { id: 'upload-id' },
|
||||
} as never);
|
||||
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
|
||||
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
|
||||
data: { id: 'file-id', attributes: { uploadOperations: [] } },
|
||||
} as never);
|
||||
jest
|
||||
.spyOn(AscApiClient.prototype, 'getAsync')
|
||||
.mockResolvedValueOnce({
|
||||
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
|
||||
} as never)
|
||||
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
|
||||
try {
|
||||
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ staticContextContent: { job: {} } }),
|
||||
{
|
||||
callInputs: {
|
||||
ipa_path: '/app.ipa',
|
||||
asc_api_key_path: '/key.json',
|
||||
apple_app_identifier: 'app-id',
|
||||
bundle_version: '1',
|
||||
bundle_short_version: '1.0',
|
||||
wait_for_processing: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
const error = await step.executeAsync().catch(error => error);
|
||||
expect(error).toBeInstanceOf(UserError);
|
||||
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_MISSING_BG_TASK_IDENTIFIERS');
|
||||
expect(error.cause).toEqual(state);
|
||||
expect(error.toExternalExpoError().message).toContain(
|
||||
'Missing BGTaskSchedulerPermittedIdentifiers while UIBackgroundModes includes processing. (90771)'
|
||||
);
|
||||
} finally {
|
||||
jest.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(createUploadToAscBuildFunction, () => {
|
||||
it('keeps Apple validation details in the public error and the full state as its cause', async () => {
|
||||
const state = {
|
||||
state: 'FAILED',
|
||||
errors: [{ code: '90713', description: 'Missing CFBundleIconName in Watch.app.' }],
|
||||
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
|
||||
infos: [{ code: 'INFO', description: 'Apple information.' }],
|
||||
};
|
||||
await fs.writeFile('/app.ipa', 'ipa');
|
||||
await fs.writeFile('/key.json', '{}');
|
||||
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
|
||||
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
|
||||
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
|
||||
} as never);
|
||||
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
|
||||
data: { id: 'upload-id' },
|
||||
} as never);
|
||||
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
|
||||
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
|
||||
data: { id: 'file-id', attributes: { uploadOperations: [] } },
|
||||
} as never);
|
||||
jest
|
||||
.spyOn(AscApiClient.prototype, 'getAsync')
|
||||
.mockResolvedValueOnce({
|
||||
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
|
||||
} as never)
|
||||
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
|
||||
try {
|
||||
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ staticContextContent: { job: {} } }),
|
||||
{
|
||||
callInputs: {
|
||||
ipa_path: '/app.ipa',
|
||||
asc_api_key_path: '/key.json',
|
||||
apple_app_identifier: 'app-id',
|
||||
bundle_version: '1',
|
||||
bundle_short_version: '1.0',
|
||||
wait_for_processing: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
const error = await step.executeAsync().catch(error => error);
|
||||
expect(error).toBeInstanceOf(UserError);
|
||||
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_MISSING_APP_ICONS');
|
||||
expect(error.cause).toEqual(state);
|
||||
expect(error.toExternalExpoError().message).toContain(
|
||||
'Missing CFBundleIconName in Watch.app. (90713)'
|
||||
);
|
||||
} finally {
|
||||
jest.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -157,6 +157,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
previewDeviceId: device.udid,
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
@@ -276,8 +277,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
|
||||
// expo-device-hub has no serial-selection flag. Device run session workflows must expose
|
||||
// a single booted Android emulator so the Hub and Appium resolve the same device.
|
||||
// The preview host uses the same device selected for Appium.
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
|
||||
@@ -275,6 +275,32 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isMissingBackgroundTaskIdentifiersError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_MISSING_BG_TASK_IDENTIFIERS',
|
||||
'Build upload was rejected by App Store Connect because Info.plist is missing `BGTaskSchedulerPermittedIdentifiers` while `UIBackgroundModes` includes `processing`. ' +
|
||||
'In an Expo project, add `BGTaskSchedulerPermittedIdentifiers` to `ios.infoPlist` in your app config and include every background task identifier the app schedules. ' +
|
||||
'If you do not use Continuous Native Generation (CNG), add the same key to the native target Info.plist. Then rebuild and submit again.' +
|
||||
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/versions/latest/config/app/#infoplist',
|
||||
cause: state,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isMissingAppIconsError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_MISSING_APP_ICONS',
|
||||
'Build upload was rejected by App Store Connect because required app icons are missing or misconfigured in one of the iOS bundles. ' +
|
||||
'In an Expo project, check your icon config (`expo.icon`, `ios.icon`) and any custom native targets or plugins that create extra bundles, such as watch extensions. ' +
|
||||
'Make sure the affected target has the required icon assets and Info.plist icon keys (for example `CFBundleIconName` / `CFBundleIconFiles`), then rebuild and submit again.' +
|
||||
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/develop/user-interface/splash-screen-and-app-icon/',
|
||||
cause: state,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isInvalidBundleIdentifierError(errors)) {
|
||||
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
|
||||
const ipaBundleIdentifier = ipaInfoResult.ok
|
||||
@@ -361,6 +387,17 @@ export function isRedundantBinaryUploadError(messages: { code: string }[]): bool
|
||||
return messages.length > 0 && messages.every(message => message.code === '90189');
|
||||
}
|
||||
|
||||
export function isMissingBackgroundTaskIdentifiersError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90771');
|
||||
}
|
||||
|
||||
export function isMissingAppIconsError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 &&
|
||||
messages.every(message => ['90022', '90023', '90391', '90713'].includes(message.code))
|
||||
);
|
||||
}
|
||||
|
||||
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
|
||||
|
||||
@@ -36,7 +36,7 @@ const SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS =
|
||||
const SERVE_SIM_FINISH_TIMEOUT_MS =
|
||||
SERVE_SIM_CONTROL_REQUEST_TIMEOUT_MS + SERVE_SIM_LEASE_FINALIZE_TIMEOUT_MS;
|
||||
const SERVE_SIM_FORCE_STOP_TIMEOUT_MS = 5_000;
|
||||
const SERVE_SIM_RECORDING_STARTED = 'serve-sim:recording-started';
|
||||
const SERVE_SIM_RECORDING_STARTED_LINE = /(?:^|\n)serve-sim:recording-started\r?\n/;
|
||||
// The serve-sim package the session host started; every recorder runs `record-video` from it.
|
||||
let serveSimPackageSpec: string | null = null;
|
||||
|
||||
@@ -507,14 +507,19 @@ function captureProcessOutput(recordingProcess: ChildProcess): {
|
||||
} {
|
||||
let output = '';
|
||||
let started = false;
|
||||
const appendChunk = (chunk: Buffer | string): void => {
|
||||
output = `${output}${chunk.toString()}`;
|
||||
if (output.includes(SERVE_SIM_RECORDING_STARTED)) {
|
||||
started = true;
|
||||
}
|
||||
output = output.slice(-16_384);
|
||||
const captureStreamOutput = (stream: ChildProcess['stdout']): void => {
|
||||
let streamOutput = '';
|
||||
stream?.on('data', (chunk: Buffer | string) => {
|
||||
const text = chunk.toString();
|
||||
output = `${output}${text}`.slice(-16_384);
|
||||
streamOutput = `${streamOutput}${text}`;
|
||||
if (SERVE_SIM_RECORDING_STARTED_LINE.test(streamOutput)) {
|
||||
started = true;
|
||||
}
|
||||
streamOutput = streamOutput.slice(-16_384);
|
||||
});
|
||||
};
|
||||
recordingProcess.stdout?.on('data', appendChunk);
|
||||
recordingProcess.stderr?.on('data', appendChunk);
|
||||
captureStreamOutput(recordingProcess.stdout);
|
||||
captureStreamOutput(recordingProcess.stderr);
|
||||
return { getOutput: () => output, hasStarted: () => started };
|
||||
}
|
||||
@@ -144,7 +144,10 @@ test('starts serve-sim record-video and returns only a finalized recording', asy
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
await setTimeout(20);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
stderr.write('serve-sim:recording-started\n');
|
||||
child.stdout?.emit('data', 'Downloading');
|
||||
stderr.write('serve-sim:recording-');
|
||||
child.stdout?.emit('data', ' package');
|
||||
stderr.write('started\r\n');
|
||||
stderr.write('x'.repeat(17_000));
|
||||
const recordings = await finishing;
|
||||
|
||||
@@ -362,6 +365,32 @@ test('keeps a recorder active after its package-manager wrapper exits', async ()
|
||||
expect(recordings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('does not mistake bundled source in an error for a recording start', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
jest
|
||||
.mocked(readServeSimServersAsync)
|
||||
.mockResolvedValue([{ udid, url: 'http://127.0.0.1:43563', token: 'session-token' }]);
|
||||
const recorder = mockRecorder();
|
||||
|
||||
await IosSimulatorRecordingUtils.startAsync({ env, logger });
|
||||
await waitForRecorderSpawnAsync();
|
||||
recorder.child.stderr?.emit(
|
||||
'data',
|
||||
'const marker = "serve-sim:recording-started";\nError: No running serve-sim session found.\n'
|
||||
);
|
||||
recorder.fail(new Error('recorder exited before connecting'));
|
||||
|
||||
const finishing = IosSimulatorRecordingUtils.finishAsync({ logger });
|
||||
try {
|
||||
expect(await Promise.race([finishing.then(() => true), setTimeout(250, false)])).toBe(true);
|
||||
} finally {
|
||||
currentTime += 311_000;
|
||||
await finishing;
|
||||
}
|
||||
expect(recorder.child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not signal a recorder that exits before it reports its start', async () => {
|
||||
let currentTime = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => currentTime);
|
||||
|
||||
+43
@@ -7,6 +7,7 @@ import { setImmediate } from 'node:timers/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { downloadBuildAsync } from '../../functions/downloadBuild';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
@@ -266,6 +267,48 @@ describe('with a device run session token', () => {
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles a stopped-session TURN response without a warning or retry', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ errors: [{ code: 'DEVICE_RUN_SESSION_ALREADY_STOPPED', message: 'Stopped.' }] },
|
||||
400
|
||||
)
|
||||
);
|
||||
|
||||
await expect(fetchWebPreviewTurnArgsAsync(ctx, { logger })).resolves.toEqual([]);
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session was stopped before TURN credentials were fetched.'
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([undefined, 'VALIDATION_ERROR'])(
|
||||
'still warns on a TURN failure with code %s',
|
||||
async code => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(
|
||||
{ errors: [{ code, message: 'Device run session has already stopped.' }] },
|
||||
400
|
||||
)
|
||||
);
|
||||
|
||||
await expect(fetchWebPreviewTurnArgsAsync(ctx, { logger })).resolves.toEqual([]);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalled();
|
||||
expect(turtleFetch).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('creates artifact upload sessions through the worker API', async () => {
|
||||
const ctx = createCtx({ withToken: true });
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../deviceRunSessionScreenRecordings';
|
||||
import {
|
||||
captureDeviceRunSessionPreviewAsync,
|
||||
ensureMacosPreviewEncoderInstalledAsync,
|
||||
startDeviceRunSessionPreview,
|
||||
} from '../deviceRunSessionPreview';
|
||||
@@ -68,6 +69,7 @@ jest.mock('../remoteDeviceRunSession', () => ({
|
||||
jest.mock('../deviceRunSessionPreview', () => ({
|
||||
...jest.requireActual('../deviceRunSessionPreview'),
|
||||
ensureMacosPreviewEncoderInstalledAsync: jest.fn(),
|
||||
captureDeviceRunSessionPreviewAsync: jest.fn(),
|
||||
startDeviceRunSessionPreview: jest.fn(),
|
||||
}));
|
||||
jest.mock('../deviceRunSessionScreenRecordings', () => ({
|
||||
@@ -191,6 +193,91 @@ afterEach(async () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([undefined, '', 'invalid serial', 'all', 'booted'])(
|
||||
'starts Hub without adding a device restriction for %j',
|
||||
async previewDeviceId => {
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
previewDeviceId,
|
||||
});
|
||||
try {
|
||||
expect(jest.mocked(spawnDetached).mock.calls[0][0].args).not.toContain('--single-device');
|
||||
} finally {
|
||||
await host.finishAsync();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('uses the supplied Android serial for Hub and thumbnails without selecting another device', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ready', device: 'no-device-id' }),
|
||||
} as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
previewDeviceId: 'emulator-5556',
|
||||
});
|
||||
try {
|
||||
const args = jest.mocked(spawnDetached).mock.calls[0][0].args;
|
||||
expect(
|
||||
args.slice(args.indexOf('--single-device'), args.indexOf('--single-device') + 2)
|
||||
).toEqual(['--single-device', 'emulator-5556']);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await jest
|
||||
.mocked(startDeviceRunSessionPreview)
|
||||
.mock.calls[0][0].captureAsync(new AbortController().signal);
|
||||
expect(captureDeviceRunSessionPreviewAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ device: 'emulator-5556' })
|
||||
);
|
||||
} finally {
|
||||
await host.finishAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('pins an already-booted iOS preview without taking over boot or setup', async () => {
|
||||
const udid = 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA';
|
||||
jest.mocked(turtleFetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ready', device: udid }),
|
||||
} as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
previewDeviceId: udid,
|
||||
});
|
||||
try {
|
||||
const args = jest.mocked(spawnDetached).mock.calls[0][0].args;
|
||||
expect(
|
||||
args.slice(args.indexOf('--single-device'), args.indexOf('--single-device') + 2)
|
||||
).toEqual(['--single-device', udid]);
|
||||
expect(IosSimulatorUtils.disableApsdAsync).not.toHaveBeenCalled();
|
||||
expect(runServeSimActionAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await host.finishAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects readiness on a different Simulator from the selected preview target', async () => {
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
previewDeviceId: 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA',
|
||||
})
|
||||
).rejects.toThrow('but this session requested AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA');
|
||||
expect(stopServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('targets the selected Simulator in the serve-sim invocation', () => {
|
||||
expect(createServeSimArgs({ port: 4321, iosSimulatorUdid: 'session-udid' }).slice(0, 4)).toEqual([
|
||||
'@expo/serve-sim@latest',
|
||||
|
||||
@@ -530,6 +530,22 @@ describe(describeServeSimLaunch, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('only pins hosts when a valid device ID is supplied', () => {
|
||||
const udid = 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA';
|
||||
expect(createServeSimArgs({ port: 4321, iosSimulatorUdid: udid })).toEqual(
|
||||
expect.arrayContaining(['--single-device', udid])
|
||||
);
|
||||
for (const iosSimulatorUdid of [undefined, '', 'booted', 'iPhone 17']) {
|
||||
expect(createServeSimArgs({ port: 4321, iosSimulatorUdid })).not.toContain('--single-device');
|
||||
}
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, device: 'emulator-5554' })).toEqual(
|
||||
expect.arrayContaining(['--single-device', 'emulator-5554'])
|
||||
);
|
||||
for (const device of [undefined, '', 'all', 'booted', 'invalid serial']) {
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, device })).not.toContain('--single-device');
|
||||
}
|
||||
});
|
||||
|
||||
describe(createServeSimArgs, () => {
|
||||
it('uses the latest Expo package and applies the EAS streaming policy', () => {
|
||||
expect(
|
||||
|
||||
@@ -144,6 +144,7 @@ export namespace GooglePlayUtils {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<void> {
|
||||
const attempts: unknown[] = [];
|
||||
const sendCommitAsync = async ({
|
||||
changesNotSentForReview,
|
||||
}: {
|
||||
@@ -161,6 +162,7 @@ export namespace GooglePlayUtils {
|
||||
throw new SystemError('Google did not confirm the committed edit.');
|
||||
}
|
||||
} catch (error) {
|
||||
attempts.push(...(error instanceof AggregateError ? error.errors : [error]));
|
||||
if (error instanceof GooglePlayAuthRequestError) {
|
||||
throw error;
|
||||
}
|
||||
@@ -168,8 +170,14 @@ export namespace GooglePlayUtils {
|
||||
!(error instanceof GooglePlayApiError) ||
|
||||
GooglePlayApiError.isRetryableStatus(error.status)
|
||||
) {
|
||||
const attemptMessages = attempts
|
||||
.map(
|
||||
(attempt, index) =>
|
||||
`${index + 1}. ${attempt instanceof Error ? attempt.message : String(attempt)}`
|
||||
)
|
||||
.join('\n');
|
||||
throw new GooglePlayCommitOutcomeUnknownError(
|
||||
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
|
||||
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.\n\nCommit attempts:\n${attemptMessages}`,
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
|
||||
+38
-1
@@ -84,6 +84,41 @@ it.each([408, 429, 503])('retries a transient HTTP %s commit on the same edit',
|
||||
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
|
||||
await expect(commit()).resolves.toBeUndefined();
|
||||
});
|
||||
it('shows failed commit attempts in order across processing, HTTP, and network retries', async () => {
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
api().post(`${editPath}:commit`).query({}).reply(503);
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.replyWithError(Object.assign(new Error('Lost commit response'), { code: 'ECONNRESET' }));
|
||||
const message = 'You must let us know whether your app includes any health features.';
|
||||
api().post(`${editPath}:commit`).query({}).reply(403, { error: { message } });
|
||||
const error = await commit().catch(error => error);
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(error, packageName);
|
||||
expect(mapped).toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
|
||||
expect((mapped as Error).message).toContain(`Google Play request failed (HTTP 403): ${message}`);
|
||||
expect((mapped as Error).message).toContain('Could not confirm whether Google Play saved');
|
||||
expect((mapped as Error).message).toContain('https://play.google.com/console/');
|
||||
expect(mapped).toMatchObject({
|
||||
metadata: { googlePlayErrorResponse: JSON.stringify({ error: { message } }) },
|
||||
cause: expect.any(AggregateError),
|
||||
});
|
||||
const attempts = (mapped as Error).message.split('Commit attempts:\n')[1]?.split('\n');
|
||||
expect(attempts).toEqual([
|
||||
`1. Google Play request failed (HTTP 400): ${pendingUploadMessage}`,
|
||||
'2. Google Play request failed (HTTP 503).',
|
||||
'3. Google Play request failed before a response was received.',
|
||||
`4. Google Play request failed (HTTP 403): ${message}`,
|
||||
]);
|
||||
expect(((mapped as Error).cause as AggregateError).errors).toHaveLength(3);
|
||||
expect(((mapped as Error).cause as AggregateError).errors[1]).toMatchObject({
|
||||
cause: { code: 'ECONNRESET' },
|
||||
});
|
||||
});
|
||||
it('preserves cancellation before commit is sent', async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('cancelled before commit');
|
||||
@@ -250,7 +285,9 @@ it('keeps the last Google Play error response when the commit outcome is unknown
|
||||
.query({})
|
||||
.times(6)
|
||||
.reply(503, { error: { message: 'Backend error' } });
|
||||
await expect(commit()).rejects.toMatchObject({
|
||||
const error = await commit().catch(error => error);
|
||||
expect(error.message).toContain('Google Play request failed (HTTP 503): Backend error');
|
||||
expect(error).toMatchObject({
|
||||
metadata: {
|
||||
googlePlayRequestPath: `${editPath}:commit`,
|
||||
googlePlayErrorResponse: JSON.stringify({ error: { message: 'Backend error' } }),
|
||||
|
||||
@@ -55,6 +55,8 @@ import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulat
|
||||
import { createStartupTasks } from './startupTasks';
|
||||
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const IOS_SIMULATOR_UDID = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
||||
const ANDROID_DEVICE_SERIAL = /^(?!all$|booted$)[A-Za-z0-9][A-Za-z0-9._:-]*$/;
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1600';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
@@ -157,6 +159,9 @@ export function createServeSimArgs({
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
...(iosSimulatorUdid ? [iosSimulatorUdid] : []),
|
||||
...(iosSimulatorUdid && IOS_SIMULATOR_UDID.test(iosSimulatorUdid)
|
||||
? ['--single-device', iosSimulatorUdid]
|
||||
: []),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
@@ -198,6 +203,7 @@ export function createServeSimArgs({
|
||||
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
device,
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
frameAncestorArgs: frameArgs = [],
|
||||
@@ -206,6 +212,7 @@ export function createExpoDeviceHubArgs({
|
||||
recordingDirectory,
|
||||
}: {
|
||||
port: number;
|
||||
device?: string;
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
frameAncestorArgs?: string[];
|
||||
@@ -215,6 +222,7 @@ export function createExpoDeviceHubArgs({
|
||||
}): string[] {
|
||||
return [
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
...(device && ANDROID_DEVICE_SERIAL.test(device) ? ['--single-device', device] : []),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
@@ -387,6 +395,8 @@ type DeviceSessionHostOptions = {
|
||||
/** Origins whose pages may call the preview host's API, in addition to the website origins. */
|
||||
corsOrigins?: string[];
|
||||
iosSimulatorUdid?: string;
|
||||
/** Selected preview target; does not enable Simulator boot or setup. */
|
||||
previewDeviceId?: string;
|
||||
installAppPath?: string;
|
||||
bootEnv?: Record<string, string>;
|
||||
application?: Promise<ServeSimApplicationOptions | void>;
|
||||
@@ -431,6 +441,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
signal,
|
||||
packageVersion,
|
||||
iosSimulatorUdid,
|
||||
previewDeviceId,
|
||||
installAppPath,
|
||||
application,
|
||||
onStartupError,
|
||||
@@ -479,6 +490,10 @@ async function startDeviceSessionHostInternalAsync(
|
||||
if (isAndroid) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
const deviceIdPattern = isAndroid ? ANDROID_DEVICE_SERIAL : IOS_SIMULATOR_UDID;
|
||||
const validPreviewDevice =
|
||||
previewDeviceId && deviceIdPattern.test(previewDeviceId) ? previewDeviceId : undefined;
|
||||
const previewDevice = isAndroid ? validPreviewDevice : (iosSimulatorUdid ?? validPreviewDevice);
|
||||
const recording: AndroidSessionRecording | null = isAndroid
|
||||
? {
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
@@ -506,6 +521,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
isAndroid
|
||||
? createExpoDeviceHubArgs({
|
||||
port,
|
||||
device: previewDevice,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
recordingDirectory: recording?.directory,
|
||||
@@ -517,7 +533,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
iosSimulatorUdid,
|
||||
iosSimulatorUdid: previewDevice,
|
||||
websiteArgs: websiteOriginArgs(env),
|
||||
frameAncestorArgs: frameAncestorArgs(frameAncestors),
|
||||
corsOriginArgs: corsOriginArgs(corsOrigins),
|
||||
@@ -762,9 +778,9 @@ async function startDeviceSessionHostInternalAsync(
|
||||
}
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = (await readiness).enforceValue();
|
||||
if (iosSimulatorUdid && device.toLowerCase() !== iosSimulatorUdid.toLowerCase()) {
|
||||
if (!isAndroid && previewDevice && device.toLowerCase() !== previewDevice.toLowerCase()) {
|
||||
throw new SystemError(
|
||||
`serve-sim became ready on ${device}, but this session requested ${iosSimulatorUdid}.`
|
||||
`serve-sim became ready on ${device}, but this session requested ${previewDevice}.`
|
||||
);
|
||||
}
|
||||
hostReady = true;
|
||||
@@ -807,7 +823,7 @@ async function startDeviceSessionHostInternalAsync(
|
||||
captureAsync: signal =>
|
||||
captureDeviceRunSessionPreviewAsync({
|
||||
runtimePlatform,
|
||||
device,
|
||||
device: previewDevice ?? device,
|
||||
env,
|
||||
signal,
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import * as jose from 'jose';
|
||||
@@ -617,6 +617,24 @@ export class AscApiClient {
|
||||
);
|
||||
if (parsedAscErrorResponse.ok) {
|
||||
const errors = parsedAscErrorResponse.value.errors.map(ascError => {
|
||||
if (response.status === 401 && ascError.code === 'NOT_AUTHORIZED') {
|
||||
return new UserError(
|
||||
'EAS_ASC_INVALID_AUTH',
|
||||
'App Store Connect rejected the API authentication credentials. ' +
|
||||
'Check that the API key is active and belongs to the correct App Store Connect account. ' +
|
||||
'If the key is stored in EAS credentials, replace or reconfigure it. ' +
|
||||
'For a local key, check `key_id` and `key`, and `issuer_id` for a team key. Then try again.',
|
||||
{ cause: ascError, docsUrl: 'https://docs.expo.dev/submit/ios/' }
|
||||
);
|
||||
}
|
||||
if (response.status === 500 && ascError.code === 'UNEXPECTED_ERROR') {
|
||||
return new SystemError(
|
||||
'App Store Connect returned an unexpected server error. ' +
|
||||
'Check Apple’s service status at https://developer.apple.com/system-status/ and verify whether the requested operation completed in App Store Connect before trying again. ' +
|
||||
'If the error continues, try again later or contact Apple Developer Support.',
|
||||
{ cause: ascError, trackingCode: 'EAS_ASC_SERVER_ERROR' }
|
||||
);
|
||||
}
|
||||
if (ascError.code === 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED') {
|
||||
return new UserError(
|
||||
'EAS_ASC_REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import * as jose from 'jose';
|
||||
import nock from 'nock';
|
||||
|
||||
@@ -85,6 +85,57 @@ describe(AscApiClient, () => {
|
||||
}
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'explains NOT_AUTHORIZED without losing other errors (mixed: %s)',
|
||||
async mixed => {
|
||||
const known = { status: '401', code: 'NOT_AUTHORIZED' };
|
||||
const other = { status: '401', code: 'OTHER_ERROR' };
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/apps/app')
|
||||
.query(true)
|
||||
.reply(401, { errors: mixed ? [known, other] : [known] });
|
||||
const error = await client
|
||||
.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: 'app' })
|
||||
.catch(error => error);
|
||||
const mapped = mixed ? error.errors[0] : error;
|
||||
expect(mapped).toBeInstanceOf(UserError);
|
||||
expect(mapped).toMatchObject({ errorCode: 'EAS_ASC_INVALID_AUTH', cause: known });
|
||||
if (mixed) {
|
||||
expect(error).toBeInstanceOf(AggregateError);
|
||||
expect(error.errors[1]).toBeInstanceOf(AscApiRequestError);
|
||||
expect(error.errors[1].code).toBe('OTHER_ERROR');
|
||||
}
|
||||
expect(scope.isDone()).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'explains UNEXPECTED_ERROR without losing other errors (mixed: %s)',
|
||||
async mixed => {
|
||||
const known = { status: '500', code: 'UNEXPECTED_ERROR' };
|
||||
const other = { status: '500', code: 'OTHER_ERROR' };
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/apps/app')
|
||||
.query(true)
|
||||
.reply(500, { errors: mixed ? [known, other] : [known] });
|
||||
const error = await client
|
||||
.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: 'app' })
|
||||
.catch(error => error);
|
||||
const mapped = mixed ? error.errors[0] : error;
|
||||
expect(mapped).toBeInstanceOf(SystemError);
|
||||
expect(mapped).toMatchObject({
|
||||
trackingCode: 'EAS_ASC_SERVER_ERROR',
|
||||
cause: known,
|
||||
});
|
||||
if (mixed) {
|
||||
expect(error).toBeInstanceOf(AggregateError);
|
||||
expect(error.errors[1]).toBeInstanceOf(AscApiRequestError);
|
||||
expect(error.errors[1].code).toBe('OTHER_ERROR');
|
||||
}
|
||||
expect(scope.isDone()).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('fetches app info', async () => {
|
||||
const appId = '1491144534';
|
||||
const responseFixture = require('./fixtures/apps/get-apps-200.json');
|
||||
|
||||
@@ -391,7 +391,9 @@ describe('commitBuildUploadFileAsync', () => {
|
||||
.reply(status, { errors: [{ code }] });
|
||||
await expect(
|
||||
AscApiUtils.commitBuildUploadFileAsync({ client, fileId: 'file', logger })
|
||||
).rejects.toMatchObject({ status, code });
|
||||
).rejects.toMatchObject(
|
||||
code === 'NOT_AUTHORIZED' ? { errorCode: 'EAS_ASC_INVALID_AUTH' } : { status, code }
|
||||
);
|
||||
});
|
||||
|
||||
it('does not commit when the initial state read fails', async () => {
|
||||
|
||||
@@ -524,6 +524,14 @@ export async function fetchWebPreviewTurnArgsAsync(
|
||||
}
|
||||
return args;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof DeviceRunSessionWorkerApiError &&
|
||||
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
|
||||
) {
|
||||
logger.info('Device run session was stopped before TURN credentials were fetched.');
|
||||
return [];
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "27.1.0",
|
||||
"version": "27.2.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "27.1.0"
|
||||
"@expo/steps": "27.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "27.1.0",
|
||||
"version": "27.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "27.1.0",
|
||||
"version": "27.2.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "27.1.0",
|
||||
"version": "27.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/logger": "27.2.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "27.1.0",
|
||||
"@expo/turtle-spawn": "27.2.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# eas-cli
|
||||
|
||||
## 27.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add an experiment that suggests Expo Update to coding agents after a build when the Free plan account is within 5 builds of the platform limit. ([#4758](https://github.com/expo/eas-cli/pull/4758) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- Add `eas observe:traces` to list request trace spans (or a summary of span names and counts), `eas observe:trace` to display one trace as a span tree, and span rows in the `eas observe:session` timeline. ([#4772](https://github.com/expo/eas-cli/pull/4772) by [@kadikraman](https://github.com/kadikraman))
|
||||
- Show a one-time Expo Simulators hint after a successful build. ([#4768](https://github.com/expo/eas-cli/pull/4768) by [@FiberJW](https://github.com/FiberJW))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Remove the Free plan build limit guidance experiment for coding agents from `eas build`. ([#4757](https://github.com/expo/eas-cli/pull/4757) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
|
||||
## 27.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
+221
-146
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "27.1.0",
|
||||
"version": "27.2.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,12 +48,12 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "27.1.0",
|
||||
"@expo/eas-json": "27.1.0",
|
||||
"@expo/eas-build-job": "27.2.0",
|
||||
"@expo/eas-json": "27.2.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/logger": "27.2.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "27.1.0",
|
||||
"@expo/steps": "27.2.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -70,6 +70,8 @@ export enum BuildEvent {
|
||||
BUILD_REQUEST_SUCCESS = 'build cli build request success',
|
||||
BUILD_REQUEST_FAIL = 'build cli build request fail',
|
||||
FREE_TIER_LIMIT_CHECKOUT_PROMPT = 'build cli free tier limit checkout prompt',
|
||||
FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP = 'build cli free tier near limit agent updates tip',
|
||||
SIMULATORS_LAUNCH_HINT_SHOWN = 'build cli simulators launch hint shown',
|
||||
|
||||
BUILD_STATUS_COMMAND = 'build cli build status',
|
||||
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import type { ExperimentationDefinition } from '@expo/experimentation';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { getAgentTelemetryContext } from '../../analytics/agent';
|
||||
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
|
||||
import {
|
||||
EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
ExperimentationClient,
|
||||
createLoggerConfig,
|
||||
} from '../../experimentation/ExperimentationClient';
|
||||
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
|
||||
import { createMockAnalytics, findUnitsByValue } from '../../experimentation/__tests__/fixtures';
|
||||
import {
|
||||
FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
|
||||
getFreeTierLimitAgentGuidanceExperiment,
|
||||
maybeAppendFreeTierLimitAgentGuidanceAsync,
|
||||
resolveFreeTierLimitAgentGuidanceVariantAsync,
|
||||
} from '../freeTierLimitGuidance';
|
||||
|
||||
jest.mock('../../analytics/agent');
|
||||
|
||||
const EXPERIMENT_NAME = 'cli-build-free-tier-limit-agent-guidance';
|
||||
|
||||
/** Every account is in the experiment: 50% control, 50% treatment. */
|
||||
const ACCOUNT_DEFINITION: ExperimentationDefinition = {
|
||||
experiments: [
|
||||
{
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
paramDefinitions: {
|
||||
variant: { type: 'uniform_choice', choices: ['control', 'treatment'] },
|
||||
},
|
||||
exposureLoggerType: 'rudderstack',
|
||||
},
|
||||
],
|
||||
namespaces: [
|
||||
{
|
||||
name: FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
|
||||
numSegments: 1000,
|
||||
segmentExperimentSetupDefinitions: [
|
||||
{
|
||||
method: 'ADD',
|
||||
name: `${EXPERIMENT_NAME} full`,
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
numSegments: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function createClient(): {
|
||||
client: ExperimentationClient;
|
||||
analytics: ReturnType<typeof createMockAnalytics>;
|
||||
} {
|
||||
const analytics = createMockAnalytics();
|
||||
const client = new ExperimentationClient(
|
||||
{
|
||||
user: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
account: ACCOUNT_DEFINITION,
|
||||
device: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
},
|
||||
{ user: null, device: null },
|
||||
ExperimentOverrides.empty(),
|
||||
createLoggerConfig(analytics)
|
||||
);
|
||||
return { client, analytics };
|
||||
}
|
||||
|
||||
/** Probes a throwaway client, so the exposures it logs do not affect the client under test. */
|
||||
function findAccountIdsByVariant(): Map<string, string> {
|
||||
const { client } = createClient();
|
||||
return findUnitsByValue(
|
||||
accountId =>
|
||||
client.getAccountNamespace(FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, {
|
||||
accountId,
|
||||
}),
|
||||
'variant',
|
||||
'control',
|
||||
['control', 'treatment']
|
||||
);
|
||||
}
|
||||
|
||||
describe(getFreeTierLimitAgentGuidanceExperiment, () => {
|
||||
it('returns the handle when the command has an experimentation client', () => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
getFreeTierLimitAgentGuidanceExperiment({ experimentation, accountId: 'account-id' })
|
||||
).toEqual({ experimentation, accountId: 'account-id' });
|
||||
});
|
||||
|
||||
it('returns undefined when the command has no experimentation client', () => {
|
||||
expect(getFreeTierLimitAgentGuidanceExperiment({ accountId: 'account-id' })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveFreeTierLimitAgentGuidanceVariantAsync, () => {
|
||||
const accountIdsByVariant = findAccountIdsByVariant();
|
||||
const treatmentAccountId = accountIdsByVariant.get('treatment')!;
|
||||
const controlAccountId = accountIdsByVariant.get('control')!;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
});
|
||||
|
||||
it('does not load the client when no coding agent runs the CLI', async () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
const loadAsync = jest.fn(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: new LazyExperimentationClient(loadAsync),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('control');
|
||||
expect(loadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads the client once across repeated reads', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
const loadAsync = jest.fn(async () => client);
|
||||
const experiment = {
|
||||
experimentation: new LazyExperimentationClient(loadAsync),
|
||||
accountId: treatmentAccountId,
|
||||
};
|
||||
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync(experiment);
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync(experiment);
|
||||
|
||||
expect(loadAsync).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns control and logs no exposure when no coding agent runs the CLI', async () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the account variant and logs the exposure once when a coding agent runs the CLI', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('treatment');
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('treatment');
|
||||
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'treatment',
|
||||
unit: treatmentAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs the exposure for control accounts too', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: controlAccountId,
|
||||
})
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'control',
|
||||
unit: controlAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns control and logs no exposure when the namespace is missing from the config', async () => {
|
||||
const analytics = createMockAnalytics();
|
||||
const client = new ExperimentationClient(
|
||||
{
|
||||
user: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
account: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
device: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
},
|
||||
{ user: null, device: null },
|
||||
ExperimentOverrides.empty(),
|
||||
createLoggerConfig(analytics)
|
||||
);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(maybeAppendFreeTierLimitAgentGuidanceAsync, () => {
|
||||
const treatmentAccountId = findAccountIdsByVariant().get('treatment')!;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
});
|
||||
|
||||
it('returns the message unchanged and logs no exposure when the command has no experimentation client', async () => {
|
||||
expect(await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.ANDROID, undefined)).toBe(
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the exposure and appends the guidance in the treatment arm', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.IOS, {
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe(
|
||||
[
|
||||
'Error 1',
|
||||
'Fix: Human required: iOS builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
|
||||
].join('\n')
|
||||
);
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'treatment',
|
||||
unit: treatmentAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the message unchanged in the control arm', async () => {
|
||||
const client = ExperimentationClient.createDisabled(
|
||||
new ExperimentOverrides(
|
||||
new Map([
|
||||
[FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, new Map([['variant', 'control']])],
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
expect(
|
||||
await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.ANDROID, {
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('Error 1');
|
||||
});
|
||||
|
||||
it('returns the message unchanged and logs no exposure when no coding agent runs the CLI', async () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await maybeAppendFreeTierLimitAgentGuidanceAsync('Error 1', Platform.ANDROID, {
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
accountId: treatmentAccountId,
|
||||
})
|
||||
).toBe('Error 1');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,880 @@
|
||||
import type { ExperimentationDefinition } from '@expo/experimentation';
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
|
||||
import { getAgentTelemetryContext } from '../../analytics/agent';
|
||||
import { createGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
|
||||
import {
|
||||
EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
ExperimentationClient,
|
||||
createLoggerConfig,
|
||||
} from '../../experimentation/ExperimentationClient';
|
||||
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
|
||||
import { createMockAnalytics, findUnitsByValue } from '../../experimentation/__tests__/fixtures';
|
||||
import {
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
BuildStatus,
|
||||
EasServiceMetric,
|
||||
UsageMetricType,
|
||||
} from '../../graphql/generated';
|
||||
import { UserPreferenceMutation } from '../../graphql/mutations/UserPreferenceMutation';
|
||||
import { AccountQuery } from '../../graphql/queries/AccountQuery';
|
||||
import { UserPreferenceQuery } from '../../graphql/queries/UserPreferenceQuery';
|
||||
import Log from '../../log';
|
||||
import { isExpoUpdatesInstalled, isUsingEASUpdate } from '../../project/projectUtils';
|
||||
import { LoggedInAuthenticationInfo } from '../../user/SessionManager';
|
||||
import { Actor } from '../../user/User';
|
||||
import {
|
||||
FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE,
|
||||
FreeTierNearLimitAgentUpdatesTipExperiment,
|
||||
FreeTierNearLimitAgentUpdatesTipPlatformProject,
|
||||
formatFreeTierNearLimitAgentUpdatesTip,
|
||||
getFreeTierBuildUsageAsync,
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment,
|
||||
getFreeTierNearLimitAgentUpdatesTipPreferenceKey,
|
||||
isEligibleForFreeTierNearLimitAgentUpdatesTip,
|
||||
isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip,
|
||||
isWithinFreeTierNearLimitBand,
|
||||
maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync,
|
||||
resolveFreeTierNearLimitAgentUpdatesTipVariantAsync,
|
||||
} from '../freeTierNearLimitAgentUpdatesTip';
|
||||
|
||||
jest.mock('../../analytics/agent');
|
||||
jest.mock('../../commandUtils/context/contextUtils/createGraphqlClient');
|
||||
jest.mock('../../graphql/queries/AccountQuery');
|
||||
jest.mock('../../graphql/queries/UserPreferenceQuery');
|
||||
jest.mock('../../graphql/mutations/UserPreferenceMutation');
|
||||
jest.mock('../../project/projectUtils', () => ({
|
||||
...jest.requireActual('../../project/projectUtils'),
|
||||
isExpoUpdatesInstalled: jest.fn(),
|
||||
isUsingEASUpdate: jest.fn(),
|
||||
}));
|
||||
|
||||
const EXPERIMENT_NAME = 'cli-build-free-tier-near-limit-agent-updates-tip';
|
||||
const ANALYTICS_EVENT_PROPERTIES = { tracking_id: 'tracking-id' };
|
||||
const EXP = { name: 'app', slug: 'app' } as ExpoConfig;
|
||||
const IOS_EXP = { name: 'app', slug: 'app', ios: { bundleIdentifier: 'com.app' } } as ExpoConfig;
|
||||
const USER = { __typename: 'User', id: 'user-id' } as Actor;
|
||||
const SSO_USER = { __typename: 'SSOUser', id: 'sso-user-id' } as Actor;
|
||||
const ROBOT = { __typename: 'Robot', id: 'robot-id' } as Actor;
|
||||
const PARTNER = { __typename: 'PartnerActor', id: 'partner-id' } as Actor;
|
||||
const AUTHENTICATION_INFO: LoggedInAuthenticationInfo = {
|
||||
accessToken: 'access-token',
|
||||
sessionSecret: null,
|
||||
};
|
||||
const GRAPHQL_CLIENT = {} as FreeTierNearLimitAgentUpdatesTipExperiment['graphqlClient'];
|
||||
|
||||
const ANDROID_KEY = 'free-tier-near-limit-agent-updates-tip-shown/account-id/android';
|
||||
const IOS_KEY = 'free-tier-near-limit-agent-updates-tip-shown/account-id/ios';
|
||||
|
||||
const ANDROID_TIP_11_OF_15 = [
|
||||
"Tip: this account has used 11 of 15 Android builds on the Free plan this period. This project isn't set up for Expo Update, so every change needs a new build.",
|
||||
'With Expo Update, JavaScript, styling and image changes reach installed builds without a new build. Setup takes effect from the next build, so it saves the most builds if done now.',
|
||||
'Next: eas update:configure --non-interactive, then build once more.',
|
||||
'Tell the user: this adds expo-updates and changes app.json and eas.json.',
|
||||
'Docs: https://docs.expo.dev/eas-update/getting-started/',
|
||||
].join('\n');
|
||||
|
||||
function accountDefinition(enrolledSegments: number): ExperimentationDefinition {
|
||||
return {
|
||||
experiments: [
|
||||
{
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
paramDefinitions: {
|
||||
variant: { type: 'uniform_choice', choices: ['control', 'treatment'] },
|
||||
},
|
||||
exposureLoggerType: 'rudderstack',
|
||||
},
|
||||
],
|
||||
namespaces: [
|
||||
{
|
||||
name: FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE,
|
||||
numSegments: 1000,
|
||||
segmentExperimentSetupDefinitions: [
|
||||
{
|
||||
method: 'ADD',
|
||||
name: `${EXPERIMENT_NAME} rollout`,
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
numSegments: enrolledSegments,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Every account is in the experiment: 50% control, 50% treatment. */
|
||||
const ACCOUNT_DEFINITION = accountDefinition(1000);
|
||||
|
||||
/** Like the production soft launch: 20% of accounts are in the experiment. */
|
||||
const PARTIAL_ROLLOUT_DEFINITION = accountDefinition(200);
|
||||
|
||||
function createClient(definition: ExperimentationDefinition = ACCOUNT_DEFINITION): {
|
||||
client: ExperimentationClient;
|
||||
analytics: ReturnType<typeof createMockAnalytics>;
|
||||
} {
|
||||
const analytics = createMockAnalytics();
|
||||
const client = new ExperimentationClient(
|
||||
{
|
||||
user: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
account: definition,
|
||||
device: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
},
|
||||
{ user: null, device: null },
|
||||
ExperimentOverrides.empty(),
|
||||
createLoggerConfig(analytics)
|
||||
);
|
||||
return { client, analytics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes a throwaway client, so the exposures it logs do not affect the client under test. The
|
||||
* probe uses a null default, so accounts outside the rollout can be told apart from control.
|
||||
*/
|
||||
function findAccountIdsByVariant(
|
||||
definition: ExperimentationDefinition,
|
||||
variants: (string | null)[]
|
||||
): Map<string | null, string> {
|
||||
const { client } = createClient(definition);
|
||||
return findUnitsByValue(
|
||||
accountId =>
|
||||
client.getAccountNamespace(FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE, {
|
||||
accountId,
|
||||
}),
|
||||
'variant',
|
||||
null,
|
||||
variants
|
||||
);
|
||||
}
|
||||
|
||||
function createOverriddenClient(variant: string): ExperimentationClient {
|
||||
return ExperimentationClient.createDisabled(
|
||||
new ExperimentOverrides(
|
||||
new Map([
|
||||
[FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE, new Map([['variant', variant]])],
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function project(
|
||||
overrides: Partial<FreeTierNearLimitAgentUpdatesTipPlatformProject> = {}
|
||||
): FreeTierNearLimitAgentUpdatesTipPlatformProject {
|
||||
return {
|
||||
developmentClient: false,
|
||||
projectDir: '/app',
|
||||
projectId: 'project-id',
|
||||
exp: EXP,
|
||||
manifestHostOverride: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createExperiment(
|
||||
accountId: string,
|
||||
{
|
||||
client = createClient().client,
|
||||
runFromCI = false,
|
||||
json = false,
|
||||
isUser = true,
|
||||
analytics = createMockAnalytics(),
|
||||
projectByPlatform = { [AppPlatform.Android]: project(), [AppPlatform.Ios]: project() },
|
||||
}: {
|
||||
client?: ExperimentationClient;
|
||||
runFromCI?: boolean;
|
||||
json?: boolean;
|
||||
isUser?: boolean;
|
||||
analytics?: ReturnType<typeof createMockAnalytics>;
|
||||
projectByPlatform?: FreeTierNearLimitAgentUpdatesTipExperiment['projectByPlatform'];
|
||||
} = {}
|
||||
): FreeTierNearLimitAgentUpdatesTipExperiment {
|
||||
return {
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
graphqlClient: GRAPHQL_CLIENT,
|
||||
analytics,
|
||||
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
|
||||
accountId,
|
||||
isUser,
|
||||
runFromCI,
|
||||
json,
|
||||
projectByPlatform,
|
||||
};
|
||||
}
|
||||
|
||||
function build(platform: AppPlatform, status: BuildStatus = BuildStatus.Finished): BuildFragment {
|
||||
return { id: `${platform}-build`, platform, status } as BuildFragment;
|
||||
}
|
||||
|
||||
type PlatformUsage = { value: number; limit: number };
|
||||
|
||||
function usageResponse({
|
||||
subscriptionName = 'Free' as string | null,
|
||||
android = { value: 11, limit: 15 } as PlatformUsage,
|
||||
ios = { value: 3, limit: 15 } as PlatformUsage,
|
||||
billingPeriodId = 'period-1',
|
||||
platformBreakdown = true,
|
||||
} = {}): Awaited<ReturnType<typeof AccountQuery.getFreeTierBuildUsageAsync>> {
|
||||
return {
|
||||
id: 'account-id',
|
||||
subscription: subscriptionName === null ? null : { id: 'subscription-id', name: subscriptionName },
|
||||
billingPeriod: {
|
||||
id: billingPeriodId,
|
||||
start: '2026-10-01T00:00:00.000Z',
|
||||
end: '2026-11-01T00:00:00.000Z',
|
||||
},
|
||||
usageMetrics: {
|
||||
EAS_BUILD: {
|
||||
id: 'eas-build',
|
||||
planMetrics: [
|
||||
{
|
||||
id: 'builds',
|
||||
serviceMetric: EasServiceMetric.Builds,
|
||||
metricType: UsageMetricType.Build,
|
||||
value: android.value + ios.value,
|
||||
limit: android.limit + ios.limit,
|
||||
platformBreakdown: platformBreakdown ? { android, ios } : null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const accountIdsByVariant = findAccountIdsByVariant(ACCOUNT_DEFINITION, ['control', 'treatment']);
|
||||
const treatmentAccountId = accountIdsByVariant.get('treatment')!;
|
||||
const controlAccountId = accountIdsByVariant.get('control')!;
|
||||
/** An account outside the partial rollout. */
|
||||
const notEnrolledAccountId = findAccountIdsByVariant(PARTIAL_ROLLOUT_DEFINITION, [null]).get(null)!;
|
||||
|
||||
/** In-memory stand-in for the server-side user preference store, keyed by preference key. */
|
||||
let shownTips: Record<string, unknown>;
|
||||
let logSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
shownTips = {};
|
||||
jest
|
||||
.mocked(UserPreferenceQuery.getValueAsync)
|
||||
.mockImplementation(async (_graphqlClient, key) => shownTips[key] ?? null);
|
||||
jest
|
||||
.mocked(UserPreferenceMutation.setAsync)
|
||||
.mockImplementation(async (_graphqlClient, key, value) => {
|
||||
shownTips[key] = value;
|
||||
});
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue({ id: 'claude-code', sessionId: 's-1' });
|
||||
jest.mocked(createGraphqlClient).mockReturnValue(GRAPHQL_CLIENT);
|
||||
jest.mocked(isExpoUpdatesInstalled).mockReturnValue(false);
|
||||
jest.mocked(isUsingEASUpdate).mockReturnValue(false);
|
||||
jest.mocked(AccountQuery.getFreeTierBuildUsageAsync).mockResolvedValue(usageResponse());
|
||||
logSpy = jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe(getFreeTierNearLimitAgentUpdatesTipExperiment, () => {
|
||||
const ctx = {
|
||||
authenticationInfo: AUTHENTICATION_INFO,
|
||||
analytics: createMockAnalytics(),
|
||||
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
|
||||
accountId: 'account-id',
|
||||
user: USER,
|
||||
developmentClient: false,
|
||||
projectDir: '/app',
|
||||
projectId: 'project-id',
|
||||
exp: EXP,
|
||||
easJsonCliConfig: { updateManifestHostOverride: 'https://u.example.com' },
|
||||
runFromCI: false,
|
||||
};
|
||||
|
||||
it('returns the handle when the command has an experimentation client', () => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment(
|
||||
{ [AppPlatform.Android]: { ...ctx, experimentation } },
|
||||
{ json: true }
|
||||
)
|
||||
).toEqual({
|
||||
experimentation,
|
||||
graphqlClient: GRAPHQL_CLIENT,
|
||||
analytics: ctx.analytics,
|
||||
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
|
||||
accountId: 'account-id',
|
||||
isUser: true,
|
||||
runFromCI: false,
|
||||
json: true,
|
||||
projectByPlatform: {
|
||||
[AppPlatform.Android]: {
|
||||
developmentClient: false,
|
||||
projectDir: '/app',
|
||||
projectId: 'project-id',
|
||||
exp: EXP,
|
||||
manifestHostOverride: 'https://u.example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the project state of each platform', () => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment(
|
||||
{
|
||||
[AppPlatform.Android]: { ...ctx, experimentation },
|
||||
[AppPlatform.Ios]: {
|
||||
...ctx,
|
||||
experimentation,
|
||||
exp: IOS_EXP,
|
||||
developmentClient: true,
|
||||
easJsonCliConfig: undefined,
|
||||
},
|
||||
},
|
||||
{ json: false }
|
||||
)?.projectByPlatform
|
||||
).toEqual({
|
||||
[AppPlatform.Android]: project({ manifestHostOverride: 'https://u.example.com' }),
|
||||
[AppPlatform.Ios]: project({ exp: IOS_EXP, developmentClient: true }),
|
||||
});
|
||||
});
|
||||
|
||||
it('creates one GraphQL client with a request timeout', () => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment(
|
||||
{
|
||||
[AppPlatform.Android]: { ...ctx, experimentation },
|
||||
[AppPlatform.Ios]: { ...ctx, experimentation },
|
||||
},
|
||||
{ json: false }
|
||||
);
|
||||
|
||||
expect(createGraphqlClient).toHaveBeenCalledTimes(1);
|
||||
expect(createGraphqlClient).toHaveBeenCalledWith(AUTHENTICATION_INFO, {
|
||||
requestTimeoutMs: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a regular user', USER, true],
|
||||
['an SSO user', SSO_USER, true],
|
||||
['a robot', ROBOT, false],
|
||||
['a partner actor', PARTNER, false],
|
||||
])('marks %s with isUser %p', (_label, user, isUser) => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment(
|
||||
{ [AppPlatform.Android]: { ...ctx, user, experimentation } },
|
||||
{ json: false }
|
||||
)?.isUser
|
||||
).toBe(isUser);
|
||||
});
|
||||
|
||||
it('returns undefined when the command has no experimentation client', () => {
|
||||
expect(
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment({ [AppPlatform.Android]: ctx }, { json: false })
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the command has no authentication info', () => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment(
|
||||
{ [AppPlatform.Android]: { ...ctx, authenticationInfo: undefined, experimentation } },
|
||||
{ json: false }
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(createGraphqlClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns undefined without build contexts', () => {
|
||||
expect(getFreeTierNearLimitAgentUpdatesTipExperiment({}, { json: false })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe(isEligibleForFreeTierNearLimitAgentUpdatesTip, () => {
|
||||
it('accepts agent runs', () => {
|
||||
expect(isEligibleForFreeTierNearLimitAgentUpdatesTip(createExperiment('account-id'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects runs without a coding agent', () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
|
||||
expect(isEligibleForFreeTierNearLimitAgentUpdatesTip(createExperiment('account-id'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects runs from CI', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierNearLimitAgentUpdatesTip(createExperiment('account-id', { runFromCI: true }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects robot and partner actors, which cannot store user preferences', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierNearLimitAgentUpdatesTip(createExperiment('account-id', { isUser: false }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects --json runs', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierNearLimitAgentUpdatesTip(createExperiment('account-id', { json: true }))
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip, () => {
|
||||
it('accepts a project without Expo Update', () => {
|
||||
expect(isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(project())).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a project with expo-updates installed but no Expo Update URL', () => {
|
||||
jest.mocked(isExpoUpdatesInstalled).mockReturnValue(true);
|
||||
|
||||
expect(isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(project())).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a project that is already set up for Expo Update', () => {
|
||||
jest.mocked(isExpoUpdatesInstalled).mockReturnValue(true);
|
||||
jest.mocked(isUsingEASUpdate).mockReturnValue(true);
|
||||
|
||||
expect(isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(project())).toBe(false);
|
||||
expect(isUsingEASUpdate).toHaveBeenCalledWith(EXP, 'project-id', null);
|
||||
});
|
||||
|
||||
it('rejects development builds, which load JavaScript from the dev server', () => {
|
||||
expect(
|
||||
isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(project({ developmentClient: true }))
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isWithinFreeTierNearLimitBand, () => {
|
||||
it.each([
|
||||
[9, false],
|
||||
[10, true],
|
||||
[11, true],
|
||||
[14, true],
|
||||
[15, false],
|
||||
[16, false],
|
||||
])('used %s of 15 → %s', (used, expected) => {
|
||||
expect(isWithinFreeTierNearLimitBand({ used, limit: 15 })).toBe(expected);
|
||||
});
|
||||
|
||||
it('is false without a limit', () => {
|
||||
expect(isWithinFreeTierNearLimitBand({ used: 0, limit: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getFreeTierBuildUsageAsync, () => {
|
||||
it('returns the per-platform usage for a Free account', async () => {
|
||||
expect(await getFreeTierBuildUsageAsync(createExperiment('account-id'))).toEqual({
|
||||
billingPeriodId: 'period-1',
|
||||
byPlatform: {
|
||||
[AppPlatform.Android]: { used: 11, limit: 15 },
|
||||
[AppPlatform.Ios]: { used: 3, limit: 15 },
|
||||
},
|
||||
});
|
||||
expect(AccountQuery.getFreeTierBuildUsageAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'account-id',
|
||||
expect.any(Date)
|
||||
);
|
||||
});
|
||||
|
||||
it('treats an account without a subscription as Free', async () => {
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ subscriptionName: null }));
|
||||
|
||||
expect(await getFreeTierBuildUsageAsync(createExperiment('account-id'))).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a paid plan', async () => {
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ subscriptionName: 'Starter' }));
|
||||
|
||||
expect(await getFreeTierBuildUsageAsync(createExperiment('account-id'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without a platform breakdown', async () => {
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ platformBreakdown: false }));
|
||||
|
||||
expect(await getFreeTierBuildUsageAsync(createExperiment('account-id'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveFreeTierNearLimitAgentUpdatesTipVariantAsync, () => {
|
||||
it('returns the account variant and logs the exposure once', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
const experiment = createExperiment(treatmentAccountId, { client });
|
||||
|
||||
expect(await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(experiment)).toBe('treatment');
|
||||
expect(await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(experiment)).toBe('treatment');
|
||||
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'treatment',
|
||||
unit: treatmentAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs the exposure for control accounts too', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(
|
||||
createExperiment(controlAccountId, { client })
|
||||
)
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'control',
|
||||
unit: controlAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns control and logs no exposure for an account outside the rollout', async () => {
|
||||
const { client, analytics } = createClient(PARTIAL_ROLLOUT_DEFINITION);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(
|
||||
createExperiment(notEnrolledAccountId, { client })
|
||||
)
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns control and logs no exposure when the namespace is missing from the config', async () => {
|
||||
const { client, analytics } = createClient(EMPTY_EXPERIMENTATION_DEFINITION);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(
|
||||
createExperiment(treatmentAccountId, { client })
|
||||
)
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(getFreeTierNearLimitAgentUpdatesTipPreferenceKey, () => {
|
||||
it('scopes the key to the account and the lowercase platform', () => {
|
||||
expect(getFreeTierNearLimitAgentUpdatesTipPreferenceKey('account-id', AppPlatform.Android)).toBe(
|
||||
ANDROID_KEY
|
||||
);
|
||||
expect(getFreeTierNearLimitAgentUpdatesTipPreferenceKey('account-id', AppPlatform.Ios)).toBe(
|
||||
IOS_KEY
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatFreeTierNearLimitAgentUpdatesTip, () => {
|
||||
it('formats the Android tip', () => {
|
||||
expect(
|
||||
formatFreeTierNearLimitAgentUpdatesTip({ platform: AppPlatform.Android, used: 11, limit: 15 })
|
||||
).toBe(ANDROID_TIP_11_OF_15);
|
||||
});
|
||||
|
||||
it('formats the iOS tip', () => {
|
||||
expect(
|
||||
formatFreeTierNearLimitAgentUpdatesTip({ platform: AppPlatform.Ios, used: 14, limit: 15 })
|
||||
).toContain('Tip: this account has used 14 of 15 iOS builds on the Free plan this period.');
|
||||
});
|
||||
});
|
||||
|
||||
describe(maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync, () => {
|
||||
function treatmentExperiment(
|
||||
options: Parameters<typeof createExperiment>[1] = {}
|
||||
): FreeTierNearLimitAgentUpdatesTipExperiment {
|
||||
return createExperiment('account-id', {
|
||||
client: createOverriddenClient('treatment'),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
it('does nothing when the command has no experiment handle', async () => {
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], undefined);
|
||||
|
||||
expect(AccountQuery.getFreeTierBuildUsageAsync).not.toHaveBeenCalled();
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests no usage for ineligible runs', async () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android)],
|
||||
treatmentExperiment()
|
||||
);
|
||||
|
||||
expect(AccountQuery.getFreeTierBuildUsageAsync).not.toHaveBeenCalled();
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests no usage when no build finished', async () => {
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android, BuildStatus.Errored), null],
|
||||
treatmentExperiment()
|
||||
);
|
||||
|
||||
expect(AccountQuery.getFreeTierBuildUsageAsync).not.toHaveBeenCalled();
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests no usage when no finished platform is eligible', async () => {
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android)],
|
||||
treatmentExperiment({
|
||||
projectByPlatform: { [AppPlatform.Android]: project({ developmentClient: true }) },
|
||||
})
|
||||
);
|
||||
|
||||
expect(AccountQuery.getFreeTierBuildUsageAsync).not.toHaveBeenCalled();
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks each platform against its own build context', async () => {
|
||||
// Only the iOS build evaluated a config with the Expo Update URL.
|
||||
jest.mocked(isExpoUpdatesInstalled).mockReturnValue(true);
|
||||
jest.mocked(isUsingEASUpdate).mockImplementation(exp => exp === IOS_EXP);
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ ios: { value: 14, limit: 15 } }));
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android), build(AppPlatform.Ios)],
|
||||
treatmentExperiment({
|
||||
projectByPlatform: {
|
||||
[AppPlatform.Android]: project(),
|
||||
[AppPlatform.Ios]: project({ exp: IOS_EXP }),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(shownTips).toEqual({ [ANDROID_KEY]: { billingPeriodId: 'period-1' } });
|
||||
});
|
||||
|
||||
it('prints the tip in the treatment arm and remembers it for the billing period', async () => {
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(shownTips).toEqual({ [ANDROID_KEY]: { billingPeriodId: 'period-1' } });
|
||||
expect(UserPreferenceMutation.setAsync).toHaveBeenCalledWith(GRAPHQL_CLIENT, ANDROID_KEY, {
|
||||
billingPeriodId: 'period-1',
|
||||
});
|
||||
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier near limit agent updates tip',
|
||||
{
|
||||
...ANALYTICS_EVENT_PROPERTIES,
|
||||
platform: AppPlatform.Android,
|
||||
builds_used: 11,
|
||||
builds_limit: 15,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('prints nothing and writes no marker in the control arm, but records the exposure', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
const experiment = createExperiment(controlAccountId, { client, analytics });
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment);
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(shownTips).toEqual({});
|
||||
expect(UserPreferenceMutation.setAsync).not.toHaveBeenCalled();
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'control',
|
||||
unit: controlAccountId,
|
||||
});
|
||||
expect(analytics.logEvent).not.toHaveBeenCalledWith(
|
||||
'build cli free tier near limit agent updates tip',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing for an account outside the experiment', async () => {
|
||||
const { client, analytics } = createClient(PARTIAL_ROLLOUT_DEFINITION);
|
||||
const experiment = createExperiment(notEnrolledAccountId, { client, analytics });
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment);
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
expect(shownTips).toEqual({});
|
||||
expect(UserPreferenceMutation.setAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs the exposure only after the usage checks pass', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ android: { value: 9, limit: 15 } }));
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android)],
|
||||
createExperiment(treatmentAccountId, { client, analytics })
|
||||
);
|
||||
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(UserPreferenceQuery.getValueAsync).not.toHaveBeenCalled();
|
||||
expect(UserPreferenceMutation.setAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing for paid plans', async () => {
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ subscriptionName: 'Production' }));
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment);
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not repeat the tip within the same billing period for this user', async () => {
|
||||
shownTips = { [ANDROID_KEY]: { billingPeriodId: 'period-1' } };
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment);
|
||||
|
||||
expect(UserPreferenceQuery.getValueAsync).toHaveBeenCalledWith(GRAPHQL_CLIENT, ANDROID_KEY);
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).not.toHaveBeenCalled();
|
||||
expect(UserPreferenceMutation.setAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the tip again in a new billing period', async () => {
|
||||
const otherAccountIosKey = 'free-tier-near-limit-agent-updates-tip-shown/other-account/ios';
|
||||
shownTips = {
|
||||
[ANDROID_KEY]: { billingPeriodId: 'period-0' },
|
||||
[otherAccountIosKey]: { billingPeriodId: 'period-1' },
|
||||
};
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android)],
|
||||
treatmentExperiment()
|
||||
);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(shownTips).toEqual({
|
||||
[ANDROID_KEY]: { billingPeriodId: 'period-1' },
|
||||
[otherAccountIosKey]: { billingPeriodId: 'period-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a malformed stored preference as not shown', async () => {
|
||||
shownTips = { [ANDROID_KEY]: 'period-1' };
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android)],
|
||||
treatmentExperiment()
|
||||
);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(shownTips).toEqual({ [ANDROID_KEY]: { billingPeriodId: 'period-1' } });
|
||||
});
|
||||
|
||||
it('checks each finished platform with one usage request', async () => {
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ ios: { value: 14, limit: 15 } }));
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android), build(AppPlatform.Ios)],
|
||||
treatmentExperiment()
|
||||
);
|
||||
|
||||
expect(AccountQuery.getFreeTierBuildUsageAsync).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy).toHaveBeenCalledTimes(2);
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('used 14 of 15 iOS builds on the Free plan')
|
||||
);
|
||||
expect(shownTips).toEqual({
|
||||
[ANDROID_KEY]: { billingPeriodId: 'period-1' },
|
||||
[IOS_KEY]: { billingPeriodId: 'period-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('skips platforms outside the band', async () => {
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android), build(AppPlatform.Ios)],
|
||||
treatmentExperiment()
|
||||
);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(shownTips).toEqual({ [ANDROID_KEY]: { billingPeriodId: 'period-1' } });
|
||||
});
|
||||
|
||||
it('still shows the tip and logs the events when the marker write fails', async () => {
|
||||
jest.mocked(UserPreferenceMutation.setAsync).mockRejectedValue(new Error('forbidden'));
|
||||
const { client, analytics } = createClient();
|
||||
const experiment = createExperiment(treatmentAccountId, { client, analytics });
|
||||
|
||||
await expect(
|
||||
maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'treatment',
|
||||
unit: treatmentAccountId,
|
||||
});
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier near limit agent updates tip',
|
||||
expect.objectContaining({ platform: AppPlatform.Android })
|
||||
);
|
||||
expect(shownTips).toEqual({});
|
||||
});
|
||||
|
||||
it('still checks the next platform when a marker write fails', async () => {
|
||||
jest
|
||||
.mocked(UserPreferenceMutation.setAsync)
|
||||
.mockRejectedValueOnce(new Error('forbidden'))
|
||||
.mockImplementation(async (_graphqlClient, key, value) => {
|
||||
shownTips[key] = value;
|
||||
});
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockResolvedValue(usageResponse({ ios: { value: 14, limit: 15 } }));
|
||||
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
[build(AppPlatform.Android), build(AppPlatform.Ios)],
|
||||
treatmentExperiment()
|
||||
);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledTimes(2);
|
||||
expect(logSpy).toHaveBeenCalledWith(ANDROID_TIP_11_OF_15);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('used 14 of 15 iOS builds on the Free plan')
|
||||
);
|
||||
expect(shownTips).toEqual({ [IOS_KEY]: { billingPeriodId: 'period-1' } });
|
||||
});
|
||||
|
||||
it('fails open when the usage request fails', async () => {
|
||||
jest
|
||||
.mocked(AccountQuery.getFreeTierBuildUsageAsync)
|
||||
.mockRejectedValue(new Error('network down'));
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
await expect(
|
||||
maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync([build(AppPlatform.Android)], experiment)
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,8 @@ import { GraphQLError } from 'graphql/error';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getErrorAsync } from '../../__tests__/commands/utils';
|
||||
import { getAgentTelemetryContext } from '../../analytics/agent';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import Build from '../../commands/build';
|
||||
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
|
||||
import { ExperimentationClient } from '../../experimentation/ExperimentationClient';
|
||||
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
|
||||
import Log, { link } from '../../log';
|
||||
import { handleBuildRequestErrorAsync } from '../build';
|
||||
import {
|
||||
@@ -30,12 +26,7 @@ import {
|
||||
FreeTierLimitCheckoutExperiment,
|
||||
maybeOfferFreeTierLimitCheckoutAsync,
|
||||
} from '../freeTierLimitCheckout';
|
||||
import {
|
||||
FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
|
||||
FreeTierLimitAgentGuidanceExperiment,
|
||||
} from '../freeTierLimitGuidance';
|
||||
|
||||
jest.mock('../../analytics/agent');
|
||||
jest.mock('../freeTierLimitCheckout', () => ({
|
||||
...jest.requireActual('../freeTierLimitCheckout'),
|
||||
maybeOfferFreeTierLimitCheckoutAsync: jest.fn(),
|
||||
@@ -45,26 +36,9 @@ const CHECKOUT_EXPERIMENT = { accountId: 'account-id' } as FreeTierLimitCheckout
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
/** An experiment handle whose client is forced to the given variant through an override. */
|
||||
function createExperiment(variant: string): FreeTierLimitAgentGuidanceExperiment {
|
||||
return {
|
||||
accountId: 'account-id',
|
||||
experimentation: LazyExperimentationClient.fromClient(
|
||||
ExperimentationClient.createDisabled(
|
||||
new ExperimentOverrides(
|
||||
new Map([
|
||||
[FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, new Map([['variant', variant]])],
|
||||
])
|
||||
)
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const EXPECTED_STRINGIFIED_GRAPHQL_ERROR_JSON = `Build request error details:
|
||||
{
|
||||
"message": "[GraphQL] Error 1\\n[GraphQL] Error 2\\n[GraphQL] Error 3",
|
||||
@@ -357,115 +331,6 @@ describe(Build.name, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for free tier limit errors in the agent guidance experiment', () => {
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
});
|
||||
|
||||
it('appends the human required block for Android in the treatment arm', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('treatment'));
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
[
|
||||
'Error 1',
|
||||
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
|
||||
'Fix: Human required: Android builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('appends the human required block for iOS in the treatment arm', async () => {
|
||||
const platform = Platform.IOS;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
await getErrorAsync<EasBuildFreeTierIosLimitExceededError>(async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('treatment'));
|
||||
});
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierIosLimitExceededError,
|
||||
[
|
||||
'Error 1',
|
||||
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
|
||||
'Fix: Human required: iOS builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('does not append the block in the control arm', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('control'));
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not append the block when no coding agent runs the CLI', async () => {
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, accountName, createExperiment('treatment'));
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not append the block to other server-side errors', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierDisabledError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(error, platform, undefined, createExperiment('treatment'));
|
||||
}
|
||||
);
|
||||
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for free tier limit errors in the checkout experiment', () => {
|
||||
it('offers the checkout with the limit message and marks the message as printed', async () => {
|
||||
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(true);
|
||||
@@ -482,7 +347,6 @@ describe(Build.name, () => {
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
}
|
||||
@@ -515,7 +379,6 @@ describe(Build.name, () => {
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
});
|
||||
@@ -544,7 +407,6 @@ describe(Build.name, () => {
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
}
|
||||
@@ -559,35 +421,6 @@ describe(Build.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the agent guidance along when both experiments are present', async () => {
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
await getErrorAsync(async () => {
|
||||
await handleBuildRequestErrorAsync(
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
createExperiment('treatment'),
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
});
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledWith(
|
||||
[
|
||||
'Error 1',
|
||||
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
|
||||
'Fix: Human required: Android builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
|
||||
].join('\n'),
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
});
|
||||
|
||||
it('does not offer the checkout for other server-side errors', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
|
||||
@@ -599,7 +432,6 @@ describe(Build.name, () => {
|
||||
error,
|
||||
platform,
|
||||
undefined,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,11 +34,6 @@ import {
|
||||
getFreeTierLimitCheckoutExperiment,
|
||||
maybeOfferFreeTierLimitCheckoutAsync,
|
||||
} from './freeTierLimitCheckout';
|
||||
import {
|
||||
FreeTierLimitAgentGuidanceExperiment,
|
||||
getFreeTierLimitAgentGuidanceExperiment,
|
||||
maybeAppendFreeTierLimitAgentGuidanceAsync,
|
||||
} from './freeTierLimitGuidance';
|
||||
import { transformMetadata } from './graphql';
|
||||
import { LocalBuildMode, runLocalBuildAsync } from './local';
|
||||
import {
|
||||
@@ -236,7 +231,6 @@ export async function prepareBuildRequestForPlatformAsync<
|
||||
error,
|
||||
job.platform,
|
||||
ctx.accountName,
|
||||
getFreeTierLimitAgentGuidanceExperiment(ctx),
|
||||
getFreeTierLimitCheckoutExperiment(ctx)
|
||||
);
|
||||
}
|
||||
@@ -263,7 +257,6 @@ export async function handleBuildRequestErrorAsync(
|
||||
error: any,
|
||||
platform: Platform,
|
||||
accountName?: string,
|
||||
freeTierLimitAgentGuidanceExperiment?: FreeTierLimitAgentGuidanceExperiment,
|
||||
freeTierLimitCheckoutExperiment?: FreeTierLimitCheckoutExperiment
|
||||
): Promise<never> {
|
||||
logBuildRequestErrorDebugInfo(error);
|
||||
@@ -283,11 +276,7 @@ export async function handleBuildRequestErrorAsync(
|
||||
if (!isFreeTierLimitError) {
|
||||
throw new ErrorClass(message);
|
||||
}
|
||||
const limitMessage = await maybeAppendFreeTierLimitAgentGuidanceAsync(
|
||||
`${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`,
|
||||
platform,
|
||||
freeTierLimitAgentGuidanceExperiment
|
||||
);
|
||||
const limitMessage = `${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`;
|
||||
const messageAlreadyPrinted = await maybeOfferFreeTierLimitCheckoutAsync(
|
||||
limitMessage,
|
||||
freeTierLimitCheckoutExperiment
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { getAgentTelemetryContext } from '../analytics/agent';
|
||||
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
|
||||
import { requestedPlatformDisplayNames } from '../platform';
|
||||
|
||||
export const FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE =
|
||||
'cli-build-free-tier-limit-agent-guidance';
|
||||
|
||||
export type FreeTierLimitAgentGuidanceVariant = 'control' | 'treatment';
|
||||
|
||||
export type FreeTierLimitAgentGuidanceExperiment = {
|
||||
experimentation: LazyExperimentationClient;
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
export function getFreeTierLimitAgentGuidanceExperiment(ctx: {
|
||||
experimentation?: LazyExperimentationClient;
|
||||
accountId: string;
|
||||
}): FreeTierLimitAgentGuidanceExperiment | undefined {
|
||||
return ctx.experimentation
|
||||
? { experimentation: ctx.experimentation, accountId: ctx.accountId }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the account's variant. Only runs driven by a coding agent are eligible. Other runs get
|
||||
* control before the client loads, so they make no config request and log no exposure.
|
||||
*/
|
||||
export async function resolveFreeTierLimitAgentGuidanceVariantAsync({
|
||||
experimentation,
|
||||
accountId,
|
||||
}: FreeTierLimitAgentGuidanceExperiment): Promise<FreeTierLimitAgentGuidanceVariant> {
|
||||
if (!getAgentTelemetryContext()) {
|
||||
return 'control';
|
||||
}
|
||||
const client = await experimentation.getClientAsync();
|
||||
return client
|
||||
.getAccountNamespace(FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE, { accountId })
|
||||
.getParam<FreeTierLimitAgentGuidanceVariant>('variant', 'control');
|
||||
}
|
||||
|
||||
export function formatFreeTierLimitAgentGuidance(platform: Platform): string {
|
||||
const platformName = requestedPlatformDisplayNames[platform];
|
||||
return `Fix: Human required: ${platformName} builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.`;
|
||||
}
|
||||
|
||||
export async function maybeAppendFreeTierLimitAgentGuidanceAsync(
|
||||
message: string,
|
||||
platform: Platform,
|
||||
experiment: FreeTierLimitAgentGuidanceExperiment | undefined
|
||||
): Promise<string> {
|
||||
if (
|
||||
!experiment ||
|
||||
(await resolveFreeTierLimitAgentGuidanceVariantAsync(experiment)) !== 'treatment'
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
return `${message}\n${formatFreeTierLimitAgentGuidance(platform)}`;
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
|
||||
import { getAgentTelemetryContext } from '../analytics/agent';
|
||||
import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/AnalyticsManager';
|
||||
import {
|
||||
ExpoGraphqlClient,
|
||||
createGraphqlClient,
|
||||
} from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
|
||||
import {
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
BuildStatus,
|
||||
EasServiceMetric,
|
||||
UsageMetricType,
|
||||
} from '../graphql/generated';
|
||||
import { UserPreferenceMutation } from '../graphql/mutations/UserPreferenceMutation';
|
||||
import { AccountQuery } from '../graphql/queries/AccountQuery';
|
||||
import { UserPreferenceQuery } from '../graphql/queries/UserPreferenceQuery';
|
||||
import Log from '../log';
|
||||
import { appPlatformDisplayNames } from '../platform';
|
||||
import { isExpoUpdatesInstalled, isUsingEASUpdate } from '../project/projectUtils';
|
||||
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
|
||||
import { Actor } from '../user/User';
|
||||
|
||||
export const FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE =
|
||||
'cli-build-free-tier-near-limit-agent-updates-tip';
|
||||
|
||||
/** The tip shows when this many builds or fewer are left on the platform. */
|
||||
export const FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_BUILDS_LEFT = 5;
|
||||
|
||||
export const EAS_UPDATE_GETTING_STARTED_URL = 'https://docs.expo.dev/eas-update/getting-started/';
|
||||
|
||||
/** Server-side user preference key prefix. The full key is `<prefix>/<accountId>/<platform>`. */
|
||||
const USER_PREFERENCE_KEY_PREFIX = 'free-tier-near-limit-agent-updates-tip-shown';
|
||||
|
||||
/** The tip's requests run after the build, so a stalled request must not delay the exit. */
|
||||
const REQUEST_TIMEOUT_MS = 3000;
|
||||
|
||||
export type FreeTierNearLimitAgentUpdatesTipVariant = 'control' | 'treatment';
|
||||
|
||||
/** The project state of one platform's build. Each build profile evaluates its own config. */
|
||||
export type FreeTierNearLimitAgentUpdatesTipPlatformProject = {
|
||||
developmentClient: boolean;
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
exp: ExpoConfig;
|
||||
manifestHostOverride: string | null;
|
||||
};
|
||||
|
||||
export type FreeTierNearLimitAgentUpdatesTipExperiment = {
|
||||
experimentation: LazyExperimentationClient;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
analyticsEventProperties: AnalyticsEventProperties;
|
||||
accountId: string;
|
||||
/** Only regular and SSO users can store user preferences. */
|
||||
isUser: boolean;
|
||||
runFromCI: boolean;
|
||||
json: boolean;
|
||||
projectByPlatform: Partial<Record<AppPlatform, FreeTierNearLimitAgentUpdatesTipPlatformProject>>;
|
||||
};
|
||||
|
||||
export type FreeTierNearLimitAgentUpdatesTipBuildContext = {
|
||||
experimentation?: LazyExperimentationClient;
|
||||
authenticationInfo?: LoggedInAuthenticationInfo;
|
||||
analytics: Analytics;
|
||||
analyticsEventProperties: AnalyticsEventProperties;
|
||||
accountId: string;
|
||||
user: Actor;
|
||||
developmentClient: boolean;
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
exp: ExpoConfig;
|
||||
easJsonCliConfig: { updateManifestHostOverride?: string } | undefined;
|
||||
runFromCI: boolean;
|
||||
};
|
||||
|
||||
export type FreeTierPlatformBuildUsage = {
|
||||
used: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type FreeTierBuildUsage = {
|
||||
billingPeriodId: string;
|
||||
byPlatform: Record<AppPlatform, FreeTierPlatformBuildUsage>;
|
||||
};
|
||||
|
||||
/** All build contexts belong to one command run, so the account and the actor are shared. */
|
||||
export function getFreeTierNearLimitAgentUpdatesTipExperiment(
|
||||
buildCtxByPlatform: Partial<Record<AppPlatform, FreeTierNearLimitAgentUpdatesTipBuildContext>>,
|
||||
{ json }: { json: boolean }
|
||||
): FreeTierNearLimitAgentUpdatesTipExperiment | undefined {
|
||||
const ctx = Object.values(buildCtxByPlatform)[0];
|
||||
if (!ctx?.experimentation || !ctx.authenticationInfo) {
|
||||
return undefined;
|
||||
}
|
||||
const projectByPlatform: FreeTierNearLimitAgentUpdatesTipExperiment['projectByPlatform'] = {};
|
||||
for (const [platform, platformCtx] of Object.entries(buildCtxByPlatform)) {
|
||||
projectByPlatform[platform as AppPlatform] = {
|
||||
developmentClient: platformCtx.developmentClient,
|
||||
projectDir: platformCtx.projectDir,
|
||||
projectId: platformCtx.projectId,
|
||||
exp: platformCtx.exp,
|
||||
manifestHostOverride: platformCtx.easJsonCliConfig?.updateManifestHostOverride ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
experimentation: ctx.experimentation,
|
||||
graphqlClient: createGraphqlClient(ctx.authenticationInfo, {
|
||||
requestTimeoutMs: REQUEST_TIMEOUT_MS,
|
||||
}),
|
||||
analytics: ctx.analytics,
|
||||
analyticsEventProperties: ctx.analyticsEventProperties,
|
||||
accountId: ctx.accountId,
|
||||
isUser: ctx.user.__typename === 'User' || ctx.user.__typename === 'SSOUser',
|
||||
runFromCI: ctx.runFromCI,
|
||||
json,
|
||||
projectByPlatform,
|
||||
};
|
||||
}
|
||||
|
||||
export function isProjectConfiguredForEASUpdate({
|
||||
projectDir,
|
||||
exp,
|
||||
projectId,
|
||||
manifestHostOverride,
|
||||
}: Pick<
|
||||
FreeTierNearLimitAgentUpdatesTipPlatformProject,
|
||||
'projectDir' | 'exp' | 'projectId' | 'manifestHostOverride'
|
||||
>): boolean {
|
||||
return (
|
||||
isExpoUpdatesInstalled(projectDir) && isUsingEASUpdate(exp, projectId, manifestHostOverride)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only runs driven by a coding agent logged in as a user, outside CI and without --json are
|
||||
* eligible. Everything else gets no tip and no usage request.
|
||||
*/
|
||||
export function isEligibleForFreeTierNearLimitAgentUpdatesTip(
|
||||
experiment: FreeTierNearLimitAgentUpdatesTipExperiment
|
||||
): boolean {
|
||||
return !(
|
||||
experiment.runFromCI ||
|
||||
experiment.json ||
|
||||
!experiment.isUser ||
|
||||
!getAgentTelemetryContext()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A platform is eligible when its project is not set up for Expo Update. Development builds load
|
||||
* JavaScript from the dev server, so the tip does not apply to them.
|
||||
*/
|
||||
export function isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(
|
||||
project: FreeTierNearLimitAgentUpdatesTipPlatformProject
|
||||
): boolean {
|
||||
return !project.developmentClient && !isProjectConfiguredForEASUpdate(project);
|
||||
}
|
||||
|
||||
/** True when the platform still has builds left, but no more than the tip's threshold. */
|
||||
export function isWithinFreeTierNearLimitBand({ used, limit }: FreeTierPlatformBuildUsage): boolean {
|
||||
return limit > 0 && used < limit && limit - used <= FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_BUILDS_LEFT;
|
||||
}
|
||||
|
||||
/** Returns the Free plan build usage per platform, or null when the account is not on the Free plan. */
|
||||
export async function getFreeTierBuildUsageAsync({
|
||||
graphqlClient,
|
||||
accountId,
|
||||
}: Pick<FreeTierNearLimitAgentUpdatesTipExperiment, 'graphqlClient' | 'accountId'>): Promise<FreeTierBuildUsage | null> {
|
||||
const account = await AccountQuery.getFreeTierBuildUsageAsync(graphqlClient, accountId, new Date());
|
||||
if ((account.subscription?.name ?? 'Free') !== 'Free') {
|
||||
return null;
|
||||
}
|
||||
const buildMetric = account.usageMetrics.EAS_BUILD.planMetrics.find(
|
||||
metric =>
|
||||
metric.serviceMetric === EasServiceMetric.Builds &&
|
||||
metric.metricType === UsageMetricType.Build
|
||||
);
|
||||
const breakdown = buildMetric?.platformBreakdown;
|
||||
if (!breakdown) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
billingPeriodId: account.billingPeriod.id,
|
||||
byPlatform: {
|
||||
[AppPlatform.Android]: { used: breakdown.android.value, limit: breakdown.android.limit },
|
||||
[AppPlatform.Ios]: { used: breakdown.ios.value, limit: breakdown.ios.limit },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The first call logs the exposure, so call it only when the tip would show. Accounts outside the
|
||||
* rollout get the control default and no exposure.
|
||||
*/
|
||||
export async function resolveFreeTierNearLimitAgentUpdatesTipVariantAsync({
|
||||
experimentation,
|
||||
accountId,
|
||||
}: Pick<FreeTierNearLimitAgentUpdatesTipExperiment, 'experimentation' | 'accountId'>): Promise<FreeTierNearLimitAgentUpdatesTipVariant> {
|
||||
const client = await experimentation.getClientAsync();
|
||||
return client
|
||||
.getAccountNamespace(FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP_EXPERIMENT_NAMESPACE, { accountId })
|
||||
.getParam<FreeTierNearLimitAgentUpdatesTipVariant>('variant', 'control');
|
||||
}
|
||||
|
||||
export function formatFreeTierNearLimitAgentUpdatesTip({
|
||||
platform,
|
||||
used,
|
||||
limit,
|
||||
}: FreeTierPlatformBuildUsage & { platform: AppPlatform }): string {
|
||||
const platformName = appPlatformDisplayNames[platform];
|
||||
return [
|
||||
`Tip: this account has used ${used} of ${limit} ${platformName} builds on the Free plan this period. This project isn't set up for Expo Update, so every change needs a new build.`,
|
||||
'With Expo Update, JavaScript, styling and image changes reach installed builds without a new build. Setup takes effect from the next build, so it saves the most builds if done now.',
|
||||
'Next: eas update:configure --non-interactive, then build once more.',
|
||||
'Tell the user: this adds expo-updates and changes app.json and eas.json.',
|
||||
`Docs: ${EAS_UPDATE_GETTING_STARTED_URL}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function getFreeTierNearLimitAgentUpdatesTipPreferenceKey(
|
||||
accountId: string,
|
||||
platform: AppPlatform
|
||||
): string {
|
||||
return `${USER_PREFERENCE_KEY_PREFIX}/${accountId}/${platform.toLowerCase()}`;
|
||||
}
|
||||
|
||||
type ShownTipPreference = { billingPeriodId: string };
|
||||
|
||||
function isShownTipPreference(value: unknown): value is ShownTipPreference {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
typeof (value as ShownTipPreference).billingPeriodId === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tip shows at most once per user, account, platform and billing period. The marker is a
|
||||
* user preference on the server, so it follows the user across machines and fresh agent
|
||||
* sandboxes.
|
||||
*/
|
||||
export async function wasFreeTierNearLimitAgentUpdatesTipShownAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
platform: AppPlatform,
|
||||
billingPeriodId: string
|
||||
): Promise<boolean> {
|
||||
const value = await UserPreferenceQuery.getValueAsync(
|
||||
graphqlClient,
|
||||
getFreeTierNearLimitAgentUpdatesTipPreferenceKey(accountId, platform)
|
||||
);
|
||||
return isShownTipPreference(value) && value.billingPeriodId === billingPeriodId;
|
||||
}
|
||||
|
||||
export async function markFreeTierNearLimitAgentUpdatesTipShownAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
platform: AppPlatform,
|
||||
billingPeriodId: string
|
||||
): Promise<void> {
|
||||
const value: ShownTipPreference = { billingPeriodId };
|
||||
await UserPreferenceMutation.setAsync(
|
||||
graphqlClient,
|
||||
getFreeTierNearLimitAgentUpdatesTipPreferenceKey(accountId, platform),
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After finished builds, suggests Expo Update to coding agents whose Free plan account is close to
|
||||
* the platform build limit. The exposure logs only when every eligibility check passed, so both
|
||||
* arms see the same population. The treatment arm prints the tip, logs the tip event, then
|
||||
* writes the marker, so a failed write means at most a repeated tip. Fails open on any error.
|
||||
*/
|
||||
export async function maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
builds: (BuildFragment | null)[],
|
||||
experiment: FreeTierNearLimitAgentUpdatesTipExperiment | undefined
|
||||
): Promise<void> {
|
||||
if (!experiment || !isEligibleForFreeTierNearLimitAgentUpdatesTip(experiment)) {
|
||||
return;
|
||||
}
|
||||
const eligiblePlatforms = new Set(
|
||||
builds
|
||||
.filter((build): build is BuildFragment => build?.status === BuildStatus.Finished)
|
||||
.map(build => build.platform)
|
||||
.filter(platform => {
|
||||
const project = experiment.projectByPlatform[platform];
|
||||
return project && isPlatformEligibleForFreeTierNearLimitAgentUpdatesTip(project);
|
||||
})
|
||||
);
|
||||
if (eligiblePlatforms.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const usage = await getFreeTierBuildUsageAsync(experiment);
|
||||
if (!usage) {
|
||||
return;
|
||||
}
|
||||
for (const platform of eligiblePlatforms) {
|
||||
const platformUsage = usage.byPlatform[platform];
|
||||
if (
|
||||
!isWithinFreeTierNearLimitBand(platformUsage) ||
|
||||
(await wasFreeTierNearLimitAgentUpdatesTipShownAsync(
|
||||
experiment.graphqlClient,
|
||||
experiment.accountId,
|
||||
platform,
|
||||
usage.billingPeriodId
|
||||
))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const variant = await resolveFreeTierNearLimitAgentUpdatesTipVariantAsync(experiment);
|
||||
if (variant !== 'treatment') {
|
||||
continue;
|
||||
}
|
||||
Log.newLine();
|
||||
Log.log(formatFreeTierNearLimitAgentUpdatesTip({ platform, ...platformUsage }));
|
||||
experiment.analytics.logEvent(BuildEvent.FREE_TIER_NEAR_LIMIT_AGENT_UPDATES_TIP, {
|
||||
...experiment.analyticsEventProperties,
|
||||
platform,
|
||||
builds_used: platformUsage.used,
|
||||
builds_limit: platformUsage.limit,
|
||||
});
|
||||
try {
|
||||
await markFreeTierNearLimitAgentUpdatesTipShownAsync(
|
||||
experiment.graphqlClient,
|
||||
experiment.accountId,
|
||||
platform,
|
||||
usage.billingPeriodId
|
||||
);
|
||||
} catch (error: any) {
|
||||
// The tip was shown. A missing marker means at most a repeated tip, so keep going.
|
||||
Log.debug('Failed to mark the Expo Update tip as shown:', error?.message ?? error);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
Log.debug('Failed to run the Expo Update tip check:', error?.message ?? error);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ import { prepareIosBuildAsync } from './ios/build';
|
||||
import { LocalBuildMode, LocalBuildOptions } from './local';
|
||||
import { ensureLockfileExistsAsync } from './validateLockfile';
|
||||
import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient';
|
||||
import {
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment,
|
||||
maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync,
|
||||
} from './freeTierNearLimitAgentUpdatesTip';
|
||||
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
|
||||
import { ensureRepoIsCleanAsync } from './utils/repository';
|
||||
import { Analytics } from '../analytics/AnalyticsManager';
|
||||
@@ -139,6 +143,7 @@ export async function runBuildAndSubmitAsync({
|
||||
}): Promise<{
|
||||
buildIds: string[];
|
||||
buildProfiles?: ProfileData<'build'>[];
|
||||
builds?: MaybeBuildFragment[];
|
||||
}> {
|
||||
await vcsClient.ensureRepoExistsAsync();
|
||||
await ensureRepoIsCleanAsync(vcsClient, flags.nonInteractive);
|
||||
@@ -339,6 +344,10 @@ export async function runBuildAndSubmitAsync({
|
||||
if (!flags.json) {
|
||||
printBuildResults(builds);
|
||||
}
|
||||
await maybeSuggestEASUpdateToAgentNearFreeTierLimitAsync(
|
||||
builds,
|
||||
getFreeTierNearLimitAgentUpdatesTipExperiment(buildCtxByPlatform, { json: flags.json })
|
||||
);
|
||||
|
||||
const haveAllBuildsFailedOrCanceled = builds.every(
|
||||
build =>
|
||||
@@ -372,6 +381,7 @@ export async function runBuildAndSubmitAsync({
|
||||
return {
|
||||
buildIds: startedBuilds.map(({ build }) => build.id),
|
||||
buildProfiles,
|
||||
builds,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { boolish } from 'getenv';
|
||||
|
||||
import { Analytics, BuildEvent } from '../analytics/AnalyticsManager';
|
||||
import { createGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { UserPreferenceMutation } from '../graphql/mutations/UserPreferenceMutation';
|
||||
import { UserPreferenceQuery } from '../graphql/queries/UserPreferenceQuery';
|
||||
import Log, { link } from '../log';
|
||||
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
|
||||
import { Actor } from '../user/User';
|
||||
|
||||
const SIMULATORS_LAUNCH_AT = new Date('2026-11-05T18:00:00Z');
|
||||
const SIMULATORS_LAUNCH_HINT_PREFERENCE_KEY = 'simulators-launch-cli-hint-seen';
|
||||
const REQUEST_TIMEOUT_MS = 3000;
|
||||
const SIMULATORS_QUICKSTART_URL =
|
||||
'https://docs.expo.dev/preview/simulators/get-started/?utm_source=eas-cli&utm_medium=cli&utm_campaign=simulators-launch';
|
||||
|
||||
export async function maybeShowSimulatorsLaunchHintAsync(
|
||||
authenticationInfo: LoggedInAuthenticationInfo,
|
||||
analytics: Analytics,
|
||||
{ actor, nonInteractive }: { actor: Actor; nonInteractive: boolean }
|
||||
): Promise<void> {
|
||||
if (
|
||||
nonInteractive ||
|
||||
boolish('CI', false) ||
|
||||
Date.now() < SIMULATORS_LAUNCH_AT.getTime() ||
|
||||
(actor.__typename !== 'User' && actor.__typename !== 'SSOUser')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const graphqlClient = createGraphqlClient(authenticationInfo, {
|
||||
requestTimeoutMs: REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
const value = await UserPreferenceQuery.getValueAsync(
|
||||
graphqlClient,
|
||||
SIMULATORS_LAUNCH_HINT_PREFERENCE_KEY
|
||||
);
|
||||
if (value && typeof value === 'object' && 'isDismissed' in value && value.isDismissed) {
|
||||
return;
|
||||
}
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`✨ New: run your build in Expo Simulators, cloud Android and iOS simulators for you and your agents. ${link(SIMULATORS_QUICKSTART_URL)}`
|
||||
);
|
||||
analytics.logEvent(BuildEvent.SIMULATORS_LAUNCH_HINT_SHOWN, {});
|
||||
await UserPreferenceMutation.setAsync(graphqlClient, SIMULATORS_LAUNCH_HINT_PREFERENCE_KEY, {
|
||||
isDismissed: true,
|
||||
});
|
||||
} catch (error) {
|
||||
Log.debug('Failed to show the Expo Simulators hint', error);
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,13 @@ import path from 'path';
|
||||
|
||||
import { LocalBuildMode } from '../../build/local';
|
||||
import { BuildFlags, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit';
|
||||
import { maybeShowSimulatorsLaunchHintAsync } from '../../build/simulatorsLaunchHint';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { StatuspageServiceName } from '../../graphql/generated';
|
||||
import { BuildStatus, StatuspageServiceName } from '../../graphql/generated';
|
||||
import Log, { link } from '../../log';
|
||||
import { RequestedPlatform, selectRequestedPlatformAsync } from '../../platform';
|
||||
import { selectAsync } from '../../prompts';
|
||||
@@ -176,7 +177,7 @@ export default class Build extends EasCommand {
|
||||
|
||||
const flagsWithPlatform = await this.ensurePlatformSelectedAsync(flags);
|
||||
|
||||
const { buildProfiles } = await runBuildAndSubmitAsync({
|
||||
const { buildProfiles, builds } = await runBuildAndSubmitAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
@@ -192,6 +193,13 @@ export default class Build extends EasCommand {
|
||||
buildProfiles,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
});
|
||||
|
||||
if (builds?.some(build => build?.status === BuildStatus.Finished)) {
|
||||
await maybeShowSimulatorsLaunchHintAsync(authenticationInfo, analytics, {
|
||||
actor,
|
||||
nonInteractive: flags.nonInteractive,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeFlags(
|
||||
|
||||
@@ -112,6 +112,7 @@ describe(ObserveSession, () => {
|
||||
metadata: null,
|
||||
hasMoreMetricEvents: false,
|
||||
hasMoreLogEvents: false,
|
||||
hasMoreSpans: false,
|
||||
});
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([]);
|
||||
mockFetchSessionLogCandidatesAsync.mockResolvedValue([]);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
|
||||
import { buildObserveTraceJson, buildObserveTraceTable } from '../../../observe/formatSpans';
|
||||
import { EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE } from '../../../observe/planGating';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveTrace from '../trace';
|
||||
|
||||
jest.mock('../../../observe/formatSpans', () => ({
|
||||
buildObserveTraceTable: jest.fn().mockReturnValue('trace-table'),
|
||||
buildObserveTraceJson: jest.fn().mockReturnValue({ id: 'trace-json' }),
|
||||
}));
|
||||
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
|
||||
ObserveQuery: {
|
||||
traceByIdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockTraceByIdAsync = jest.mocked(ObserveQuery.traceByIdAsync);
|
||||
const mockBuildObserveTraceTable = jest.mocked(buildObserveTraceTable);
|
||||
const mockBuildObserveTraceJson = jest.mocked(buildObserveTraceJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
const TRACE_ID = '0123456789abcdef0123456789abcdef';
|
||||
const trace = { id: TRACE_ID, isTruncated: false, spans: [] } as any;
|
||||
|
||||
describe(ObserveTrace, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockTraceByIdAsync.mockResolvedValue(trace);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveTrace {
|
||||
const command = new ObserveTrace(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('requires a trace ID argument', async () => {
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow();
|
||||
expect(mockTraceByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a malformed trace ID before querying', async () => {
|
||||
await expect(createCommand(['not-a-trace-id']).runAsync()).rejects.toThrow(
|
||||
/not a valid trace ID/
|
||||
);
|
||||
expect(mockTraceByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts an uppercase trace ID', async () => {
|
||||
await createCommand([TRACE_ID.toUpperCase()]).runAsync();
|
||||
expect(mockTraceByIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
id: TRACE_ID.toUpperCase(),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the trace table by default', async () => {
|
||||
await createCommand([TRACE_ID]).runAsync();
|
||||
|
||||
expect(mockTraceByIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
id: TRACE_ID,
|
||||
});
|
||||
expect(mockBuildObserveTraceTable).toHaveBeenCalledWith(trace);
|
||||
expect(mockBuildObserveTraceJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits JSON with --json', async () => {
|
||||
await createCommand([TRACE_ID, '--json', '--non-interactive']).runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveTraceJson).toHaveBeenCalledWith(trace);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ id: 'trace-json' });
|
||||
expect(mockBuildObserveTraceTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails with a not-found message when the trace is unknown', async () => {
|
||||
mockTraceByIdAsync.mockResolvedValue(null);
|
||||
await expect(createCommand([TRACE_ID]).runAsync()).rejects.toThrow(/No trace found with ID/);
|
||||
});
|
||||
|
||||
it('surfaces the plan-gate message', async () => {
|
||||
const serverMessage =
|
||||
'Access to this feature is not included in your current plan. ' +
|
||||
'Upgrade your plan: https://expo.dev/accounts/acme/settings/billing';
|
||||
mockTraceByIdAsync.mockRejectedValueOnce(
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(serverMessage, null, null, null, null, null, {
|
||||
errorCode: EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
await expect(createCommand([TRACE_ID]).runAsync()).rejects.toThrow(serverMessage);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import {
|
||||
AppObservePlatform,
|
||||
AppObserveSpanKind,
|
||||
AppObserveSpanStatusCode,
|
||||
} from '../../../graphql/generated';
|
||||
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveSpansAsync } from '../../../observe/fetchSpans';
|
||||
import {
|
||||
buildObserveSpanNamesJson,
|
||||
buildObserveSpanNamesTable,
|
||||
buildObserveSpansJson,
|
||||
buildObserveSpansTable,
|
||||
} from '../../../observe/formatSpans';
|
||||
import { EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE } from '../../../observe/planGating';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveTraces from '../traces';
|
||||
|
||||
jest.mock('../../../observe/fetchSpans');
|
||||
jest.mock('../../../observe/formatSpans', () => ({
|
||||
buildObserveSpansTable: jest.fn().mockReturnValue('spans-table'),
|
||||
buildObserveSpansJson: jest.fn().mockReturnValue({ spans: [] }),
|
||||
buildObserveSpanNamesTable: jest.fn().mockReturnValue('names-table'),
|
||||
buildObserveSpanNamesJson: jest.fn().mockReturnValue({ names: [], isTruncated: false }),
|
||||
}));
|
||||
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
|
||||
ObserveQuery: {
|
||||
spanNamesAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveSpansAsync = jest.mocked(fetchObserveSpansAsync);
|
||||
const mockSpanNamesAsync = jest.mocked(ObserveQuery.spanNamesAsync);
|
||||
const mockBuildObserveSpansTable = jest.mocked(buildObserveSpansTable);
|
||||
const mockBuildObserveSpansJson = jest.mocked(buildObserveSpansJson);
|
||||
const mockBuildObserveSpanNamesTable = jest.mocked(buildObserveSpanNamesTable);
|
||||
const mockBuildObserveSpanNamesJson = jest.mocked(buildObserveSpanNamesJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveTraces, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveSpansAsync.mockResolvedValue({
|
||||
spans: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
mockSpanNamesAsync.mockResolvedValue({ names: [], isTruncated: false });
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveTraces {
|
||||
const command = new ObserveTraces(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
function planGateError(): CombinedError {
|
||||
const serverMessage =
|
||||
'Access to this feature is not included in your current plan. ' +
|
||||
'Upgrade your plan: https://expo.dev/accounts/acme/settings/billing';
|
||||
return new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError(serverMessage, null, null, null, null, null, {
|
||||
errorCode: EAS_OBSERVE_PLAN_UPGRADE_REQUIRED_ERROR_CODE,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it('shows the span names summary when no span filter is given', async () => {
|
||||
const command = createCommand(['--days', '7', '--platform', 'ios', '--environment', 'prod']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockSpanNamesAsync).toHaveBeenCalledTimes(1);
|
||||
const variables = mockSpanNamesAsync.mock.calls[0][1];
|
||||
expect(variables.appId).toBe(projectId);
|
||||
expect(variables.input.platforms).toEqual([AppObservePlatform.Ios]);
|
||||
expect(variables.input.environment).toBe('prod');
|
||||
expect(variables.input.startTime).toBeDefined();
|
||||
expect(variables.input.endTime).toBeDefined();
|
||||
expect(mockFetchObserveSpansAsync).not.toHaveBeenCalled();
|
||||
expect(mockBuildObserveSpanNamesTable).toHaveBeenCalled();
|
||||
expect(mockBuildObserveSpansTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes release filters to the summary', async () => {
|
||||
const command = createCommand([
|
||||
'--app-version',
|
||||
'1.2.0',
|
||||
'--build-number',
|
||||
'42',
|
||||
'--update-id',
|
||||
'update-1',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const { input } = mockSpanNamesAsync.mock.calls[0][1];
|
||||
expect(input.appVersion).toBe('1.2.0');
|
||||
expect(input.appBuildNumber).toBe('42');
|
||||
expect(input.appUpdateId).toBe('update-1');
|
||||
});
|
||||
|
||||
it('emits the summary as JSON with --json', async () => {
|
||||
const command = createCommand(['--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveSpanNamesJson).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveSpanNamesTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists spans with --all-spans', async () => {
|
||||
const command = createCommand(['--all-spans']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockSpanNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchObserveSpansAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockBuildObserveSpansTable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists spans when paging with --after', async () => {
|
||||
const command = createCommand(['--after', 'cursor-1']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockSpanNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchObserveSpansAsync.mock.calls[0][2].after).toBe('cursor-1');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['--name', 'GET'],
|
||||
['--kind', 'client'],
|
||||
['--status', 'error'],
|
||||
['--host', 'api.example.com'],
|
||||
['--http-status', '500'],
|
||||
['--min-duration', '100'],
|
||||
['--session-id', 'session-1'],
|
||||
])('lists spans when %s is given', async (flag, value) => {
|
||||
const command = createCommand([flag, value]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockSpanNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchObserveSpansAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lists spans with --root-only', async () => {
|
||||
const command = createCommand(['--root-only']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockSpanNamesAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchObserveSpansAsync.mock.calls[0][2].rootOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('maps every span filter flag to the fetch options', async () => {
|
||||
const command = createCommand([
|
||||
'--name',
|
||||
'GET',
|
||||
'--name',
|
||||
'POST',
|
||||
'--kind',
|
||||
'client',
|
||||
'--status',
|
||||
'error',
|
||||
'--status',
|
||||
'unset',
|
||||
'--host',
|
||||
'api.example.com',
|
||||
'--http-status',
|
||||
'500',
|
||||
'--http-status',
|
||||
'502',
|
||||
'--min-duration',
|
||||
'250',
|
||||
'--root-only',
|
||||
'--session-id',
|
||||
'session-1',
|
||||
'--platform',
|
||||
'android',
|
||||
'--app-version',
|
||||
'1.2.0',
|
||||
'--build-number',
|
||||
'42',
|
||||
'--update-id',
|
||||
'update-1',
|
||||
'--environment',
|
||||
'prod',
|
||||
'--limit',
|
||||
'50',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveSpansAsync.mock.calls[0][2];
|
||||
expect(options).toEqual(
|
||||
expect.objectContaining({
|
||||
limit: 50,
|
||||
names: ['GET', 'POST'],
|
||||
kinds: [AppObserveSpanKind.Client],
|
||||
statusCodes: [AppObserveSpanStatusCode.Error, AppObserveSpanStatusCode.Unset],
|
||||
hosts: ['api.example.com'],
|
||||
httpStatusCodes: [500, 502],
|
||||
minDurationMs: 250,
|
||||
rootOnly: true,
|
||||
sessionId: 'session-1',
|
||||
platforms: [AppObservePlatform.Android],
|
||||
appVersion: '1.2.0',
|
||||
buildNumber: '42',
|
||||
updateId: 'update-1',
|
||||
environment: 'prod',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults the span list limit to 20 and caps it at 100', async () => {
|
||||
await createCommand(['--all-spans']).runAsync();
|
||||
expect(mockFetchObserveSpansAsync.mock.calls[0][2].limit).toBe(20);
|
||||
|
||||
await expect(createCommand(['--all-spans', '--limit', '101']).runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an unknown --kind or --status value', async () => {
|
||||
await expect(createCommand(['--kind', 'bogus']).runAsync()).rejects.toThrow();
|
||||
await expect(createCommand(['--status', 'bogus']).runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('emits the span list as JSON with --json', async () => {
|
||||
const command = createCommand(['--all-spans', '--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveSpansJson).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveSpansTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the plan-gate message when a session filter is not available on the plan', async () => {
|
||||
mockFetchObserveSpansAsync.mockRejectedValueOnce(planGateError());
|
||||
const command = createCommand(['--session-id', 'session-1']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/not included in your current plan/);
|
||||
});
|
||||
|
||||
it('surfaces the plan-gate message from the summary query', async () => {
|
||||
mockSpanNamesAsync.mockRejectedValueOnce(planGateError());
|
||||
const command = createCommand([]);
|
||||
await expect(command.runAsync()).rejects.toThrow(/not included in your current plan/);
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,7 @@ const PICKER_CANDIDATE_LIMIT = 25;
|
||||
|
||||
export default class ObserveSession extends EasCommand {
|
||||
static override description =
|
||||
'display the timeline of metric and log events for a specific session';
|
||||
'display the timeline of metric, log, and request trace span events for a specific session';
|
||||
|
||||
static override args = {
|
||||
sessionId: Args.string({
|
||||
@@ -129,7 +129,7 @@ export default class ObserveSession extends EasCommand {
|
||||
});
|
||||
}
|
||||
|
||||
const { entries, metadata, hasMoreMetricEvents, hasMoreLogEvents } =
|
||||
const { entries, metadata, hasMoreMetricEvents, hasMoreLogEvents, hasMoreSpans } =
|
||||
await withObservePlanGateHandlingAsync(() =>
|
||||
fetchObserveSessionEventsAsync(graphqlClient, projectId, {
|
||||
sessionId,
|
||||
@@ -144,7 +144,8 @@ export default class ObserveSession extends EasCommand {
|
||||
sessionId,
|
||||
metadata,
|
||||
hasMoreMetricEvents,
|
||||
hasMoreLogEvents
|
||||
hasMoreLogEvents,
|
||||
hasMoreSpans
|
||||
)
|
||||
);
|
||||
} else {
|
||||
@@ -154,6 +155,7 @@ export default class ObserveSession extends EasCommand {
|
||||
metadata,
|
||||
hasMoreMetricEvents,
|
||||
hasMoreLogEvents,
|
||||
hasMoreSpans,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Args } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import Log from '../../log';
|
||||
import { buildObserveTraceJson, buildObserveTraceTable } from '../../observe/formatSpans';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
// A trace ID is the 32-character hex string of the OTLP JSON encoding.
|
||||
const TRACE_ID_REGEX = /^[0-9a-f]{32}$/i;
|
||||
|
||||
export default class ObserveTrace extends EasCommand {
|
||||
static override description =
|
||||
'display every span of a single request trace by its trace ID, oldest first, with child spans indented under their parent. Trace IDs are shown by `eas observe:traces`. In `--json` output a span with no `parentSpanId` is a root span, and a span with no `http` object is not an HTTP request';
|
||||
|
||||
static override args = {
|
||||
traceId: Args.string({
|
||||
description: 'ID of the trace to display',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveTrace);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const traceId = args.traceId;
|
||||
if (!TRACE_ID_REGEX.test(traceId)) {
|
||||
throw new EasCommandError(
|
||||
`"${traceId}" is not a valid trace ID. A trace ID is 32 hexadecimal characters; copy it from the Trace ID column of \`eas observe:traces\`.`
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveTrace, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const trace = await withObservePlanGateHandlingAsync(() =>
|
||||
ObserveQuery.traceByIdAsync(graphqlClient, { appId: projectId, id: traceId })
|
||||
);
|
||||
|
||||
if (!trace) {
|
||||
throw new EasCommandError(
|
||||
`No trace found with ID "${traceId}". Trace IDs come from \`eas observe:traces\`, and traces age out of retention.`
|
||||
);
|
||||
}
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveTraceJson(trace));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildObserveTraceTable(trace));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
EasProjectIdFlag,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveSpansAsync } from '../../observe/fetchSpans';
|
||||
import {
|
||||
ObserveAfterFlag,
|
||||
ObserveAppVersionFlag,
|
||||
ObserveBuildNumberFlag,
|
||||
ObserveEnvironmentFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
import {
|
||||
buildObserveSpanNamesJson,
|
||||
buildObserveSpanNamesTable,
|
||||
buildObserveSpansJson,
|
||||
buildObserveSpansTable,
|
||||
} from '../../observe/formatSpans';
|
||||
import { withObservePlanGateHandlingAsync } from '../../observe/planGating';
|
||||
import { observePlatformsFromFlag } from '../../observe/platforms';
|
||||
import {
|
||||
allowedSpanKindFlagValues,
|
||||
allowedSpanStatusFlagValues,
|
||||
spanKindsFromFlag,
|
||||
spanStatusCodesFromFlag,
|
||||
} from '../../observe/spanFilters';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const DEFAULT_SPANS_LIMIT = 20;
|
||||
|
||||
export default class ObserveTraces extends EasCommand {
|
||||
static override description =
|
||||
'display request trace spans recorded by the app, newest first. With no span filters, a summary of span names and kinds with counts is returned. Pass --all-spans or a span filter (--name, --kind, --status, --host, --http-status, --min-duration, --root-only, --session-id) to list individual spans';
|
||||
|
||||
static override flags = {
|
||||
...ObservePlatformFlag,
|
||||
...ObserveAfterFlag,
|
||||
limit: getLimitFlagWithCustomValues({
|
||||
defaultTo: DEFAULT_SPANS_LIMIT,
|
||||
limit: 100,
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveAppVersionFlag,
|
||||
...ObserveBuildNumberFlag,
|
||||
...ObserveUpdateIdFlag,
|
||||
...ObserveEnvironmentFlag,
|
||||
'session-id': Flags.string({
|
||||
description: 'Filter by session ID',
|
||||
}),
|
||||
name: Flags.string({
|
||||
description: 'Filter by span name, such as GET or POST (can be specified multiple times)',
|
||||
multiple: true,
|
||||
}),
|
||||
kind: Flags.option({
|
||||
description: 'Filter by span kind (can be specified multiple times)',
|
||||
multiple: true,
|
||||
options: allowedSpanKindFlagValues,
|
||||
})(),
|
||||
status: Flags.option({
|
||||
description: 'Filter by span status (can be specified multiple times)',
|
||||
multiple: true,
|
||||
options: allowedSpanStatusFlagValues,
|
||||
})(),
|
||||
host: Flags.string({
|
||||
description: 'Filter by request host (can be specified multiple times)',
|
||||
multiple: true,
|
||||
}),
|
||||
'http-status': Flags.integer({
|
||||
description: 'Filter by HTTP response status code (can be specified multiple times)',
|
||||
multiple: true,
|
||||
}),
|
||||
'min-duration': Flags.integer({
|
||||
description: 'Only spans that took at least this many milliseconds',
|
||||
min: 0,
|
||||
}),
|
||||
'root-only': Flags.boolean({
|
||||
description: 'Only root spans (spans with no parent)',
|
||||
default: false,
|
||||
}),
|
||||
'all-spans': Flags.boolean({
|
||||
description:
|
||||
'List individual spans instead of the summary of span names + counts, even with no span filter',
|
||||
default: false,
|
||||
}),
|
||||
...EasProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveTraces);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ObserveTraces, {
|
||||
nonInteractive,
|
||||
projectIdOverride: flags['project-id'],
|
||||
});
|
||||
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
const platforms = observePlatformsFromFlag(flags.platform);
|
||||
|
||||
const hasSpanFilter =
|
||||
flags['session-id'] !== undefined ||
|
||||
(flags.name?.length ?? 0) > 0 ||
|
||||
(flags.kind?.length ?? 0) > 0 ||
|
||||
(flags.status?.length ?? 0) > 0 ||
|
||||
(flags.host?.length ?? 0) > 0 ||
|
||||
(flags['http-status']?.length ?? 0) > 0 ||
|
||||
flags['min-duration'] !== undefined ||
|
||||
flags['root-only'];
|
||||
|
||||
// The names summary has no span-level filters and no pagination, so any of
|
||||
// those (or --all-spans) means the caller wants the span list.
|
||||
if (!hasSpanFilter && !flags['all-spans'] && !flags.after) {
|
||||
const { names, isTruncated } = await withObservePlanGateHandlingAsync(() =>
|
||||
ObserveQuery.spanNamesAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
input: {
|
||||
startTime,
|
||||
endTime,
|
||||
...(platforms?.length && { platforms }),
|
||||
...(flags.environment && { environment: flags.environment }),
|
||||
...(flags['app-version'] && { appVersion: flags['app-version'] }),
|
||||
...(flags['build-number'] && { appBuildNumber: flags['build-number'] }),
|
||||
...(flags['update-id'] && { appUpdateId: flags['update-id'] }),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveSpanNamesJson(names, isTruncated));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveSpanNamesTable(names, {
|
||||
daysBack,
|
||||
startTime,
|
||||
endTime,
|
||||
isTruncated,
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { spans, pageInfo } = await withObservePlanGateHandlingAsync(() =>
|
||||
fetchObserveSpansAsync(graphqlClient, projectId, {
|
||||
limit: flags.limit ?? DEFAULT_SPANS_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
startTime,
|
||||
endTime,
|
||||
platforms,
|
||||
appVersion: flags['app-version'],
|
||||
buildNumber: flags['build-number'],
|
||||
updateId: flags['update-id'],
|
||||
environment: flags.environment,
|
||||
sessionId: flags['session-id'],
|
||||
names: flags.name,
|
||||
kinds: spanKindsFromFlag(flags.kind),
|
||||
statusCodes: spanStatusCodesFromFlag(flags.status),
|
||||
hosts: flags.host,
|
||||
httpStatusCodes: flags['http-status'],
|
||||
minDurationMs: flags['min-duration'],
|
||||
rootOnly: flags['root-only'],
|
||||
})
|
||||
);
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveSpansJson(spans, pageInfo));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveSpansTable(spans, pageInfo, {
|
||||
daysBack,
|
||||
startTime,
|
||||
endTime,
|
||||
sessionId: flags['session-id'],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
-1
@@ -15896,6 +15896,14 @@ export type CreateAppScopedUploadSessionMutationVariables = Exact<{
|
||||
|
||||
export type CreateAppScopedUploadSessionMutation = { __typename?: 'RootMutation', uploadSession: { __typename?: 'UploadSession', createAppScopedUploadSession: any } };
|
||||
|
||||
export type SetUserPreferenceMutationVariables = Exact<{
|
||||
key: Scalars['String']['input'];
|
||||
value: Scalars['JSON']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type SetUserPreferenceMutation = { __typename?: 'RootMutation', userPreference: { __typename?: 'UserPreferenceMutation', set: { __typename?: 'UserPreference', id: string, key: string, value: any } } };
|
||||
|
||||
export type CreateWebhookMutationVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
webhookInput: WebhookInput;
|
||||
@@ -15989,6 +15997,14 @@ export type AccountFullUsageQueryVariables = Exact<{
|
||||
|
||||
export type AccountFullUsageQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, name: string, subscription?: { __typename?: 'SubscriptionDetails', id: string, name?: string | null, status?: string | null, nextInvoice?: any | null, nextInvoiceAmountDueCents?: number | null, recurringCents?: number | null, price: number, concurrencies?: { __typename?: 'Concurrencies', total: number, android: number, ios: number } | null, addons: Array<{ __typename?: 'AddonDetails', id: string, name: string, quantity?: number | null }>, upcomingInvoice?: { __typename?: 'Invoice', id: string, total: number, lineItems: Array<{ __typename?: 'InvoiceLineItem', id: string, description: string, amount: number, period: { __typename?: 'InvoicePeriod', start: any, end: any } }> } | null } | null, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, usageMetrics: { __typename?: 'AccountUsageMetrics', MEDIUM_ANDROID_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, LARGE_ANDROID_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, MEDIUM_IOS_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, LARGE_IOS_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, EAS_BUILD: { __typename?: 'UsageMetricTotal', id: string, totalCost: number, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null, jobTypeBreakdown?: { __typename?: 'EstimatedUsageJobTypeBreakdown', workflows: number, simulator: number, other: number } | null }>, overageMetrics: Array<{ __typename?: 'EstimatedOverageAndCost', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, totalCost: number, metadata?: { __typename?: 'AccountUsageEASBuildMetadata', billingResourceClass?: EasBuildBillingResourceClass | null, platform?: AppPlatform | null } | null }> }, EAS_UPDATE: { __typename?: 'UsageMetricTotal', id: string, totalCost: number, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null, jobTypeBreakdown?: { __typename?: 'EstimatedUsageJobTypeBreakdown', workflows: number, simulator: number, other: number } | null }>, overageMetrics: Array<{ __typename?: 'EstimatedOverageAndCost', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, totalCost: number, metadata?: { __typename?: 'AccountUsageEASBuildMetadata', billingResourceClass?: EasBuildBillingResourceClass | null, platform?: AppPlatform | null } | null }> }, EAS_SIMULATOR: { __typename?: 'UsageMetricTotal', id: string, totalCost: number, unavailableServiceUsageMetrics: Array<{ __typename?: 'UnavailableServiceUsageMetric', service: EasService }>, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null, jobTypeBreakdown?: { __typename?: 'EstimatedUsageJobTypeBreakdown', workflows: number, simulator: number, other: number } | null }>, overageMetrics: Array<{ __typename?: 'EstimatedOverageAndCost', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, totalCost: number, metadata?: { __typename?: 'AccountUsageEASBuildMetadata', billingResourceClass?: EasBuildBillingResourceClass | nulLine truncated
|
||||
|
||||
export type AccountFreeTierBuildUsageQueryVariables = Exact<{
|
||||
accountId: Scalars['String']['input'];
|
||||
currentDate: Scalars['DateTime']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type AccountFreeTierBuildUsageQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, subscription?: { __typename?: 'SubscriptionDetails', id: string, name?: string | null } | null, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any }, usageMetrics: { __typename?: 'AccountUsageMetrics', EAS_BUILD: { __typename?: 'UsageMetricTotal', id: string, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null }> } } } } };
|
||||
|
||||
export type AccountUsageForOverageWarningQueryVariables = Exact<{
|
||||
accountId: Scalars['String']['input'];
|
||||
currentDate: Scalars['DateTime']['input'];
|
||||
@@ -16529,13 +16545,41 @@ export type AppObserveSessionEventsQueryVariables = Exact<{
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
metricsOrderBy?: InputMaybe<AppObserveMetricsListOrderBy>;
|
||||
logsOrderBy?: InputMaybe<AppObserveLogsOrderBy>;
|
||||
spansOrderBy?: InputMaybe<AppObserveSpanListOrderBy>;
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveSessionEventsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', session: { __typename?: 'AppObserveSession', id: string, metrics: { __typename?: 'AppObserveMetricConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveMetricEdge', node: { __typename?: 'AppObserveMetric', id: string, name: string, value: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string, customParams?: any | null, routeName?: string | null } }> }, logs: { __typename?: 'AppObserveLogConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveLogEdge', node:
|
||||
| { __typename: 'AppObserveError', id: string, timestamp: any, sessionId?: string | null, severityNumber?: number | null, severityText?: string | null, type?: string | null, message?: string | null, source?: string | null, fingerprint?: string | null, isFatal?: boolean | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, properties: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }> }
|
||||
| { __typename: 'AppObserveUserEvent', id: string, name: string, timestamp: any, sessionId?: string | null, severityNumber?: number | null, severityText?: string | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, properties: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }> }
|
||||
}> } } } } } };
|
||||
}> }, spans: { __typename?: 'AppObserveSpanConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveSpanEdge', node: { __typename?: 'AppObserveSpan', id: string, traceId: string, spanId: string, parentSpanId?: string | null, name: string, kind: AppObserveSpanKind, startTimestamp: any, endTimestamp: any, durationMs: number, statusCode: AppObserveSpanStatusCode, statusMessage?: string | null, sessionId?: string | null, droppedAttributesCount: number, droppedEventsCount: number, droppedLinksCount: number, ingestedAt?: any | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, expoSdkVersion?: string | null, reactNativeVersion?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, http?: { __typename?: 'AppObserveSpanHttp', method?: string | null, statusCode?: number | null, urlHost?: string | null, urlFull?: string | null, protocolVersion?: string | null, requestSize?: number | null, responseSize?: number | null, errorType?: string | null } | null, attributes: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }>, events: Array<{ __typename?: 'AppObserveSpanEvent', name: string, timestamp: any, attributes: any }> } }> } } } } } };
|
||||
|
||||
export type AppObserveSpanListQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
filter: AppObserveSpanListFilter;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
orderBy?: InputMaybe<AppObserveSpanListOrderBy>;
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveSpanListQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', traces: { __typename?: 'AppObserveTraces', spans: { __typename?: 'AppObserveSpanConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveSpanEdge', cursor: string, node: { __typename?: 'AppObserveSpan', id: string, traceId: string, spanId: string, parentSpanId?: string | null, name: string, kind: AppObserveSpanKind, startTimestamp: any, endTimestamp: any, durationMs: number, statusCode: AppObserveSpanStatusCode, statusMessage?: string | null, sessionId?: string | null, droppedAttributesCount: number, droppedEventsCount: number, droppedLinksCount: number, ingestedAt?: any | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, expoSdkVersion?: string | null, reactNativeVersion?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, http?: { __typename?: 'AppObserveSpanHttp', method?: string | null, statusCode?: number | null, urlHost?: string | null, urlFull?: string | null, protocolVersion?: string | null, requestSize?: number | null, responseSize?: number | null, errorType?: string | null } | null, attributes: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }>, events: Array<{ __typename?: 'AppObserveSpanEvent', name: string, timestamp: any, attributes: any }> } }> } } } } } };
|
||||
|
||||
export type AppObserveSpanNamesQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
input: AppObserveSpanNamesInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveSpanNamesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', traces: { __typename?: 'AppObserveTraces', names: { __typename?: 'AppObserveSpanNames', isTruncated: boolean, names: Array<{ __typename?: 'AppObserveSpanName', name: string, kind: AppObserveSpanKind, count: number }> } } } } } };
|
||||
|
||||
export type AppObserveTraceByIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
id: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveTraceByIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', traces: { __typename?: 'AppObserveTraces', trace?: { __typename?: 'AppObserveTrace', id: string, isTruncated: boolean, spans: Array<{ __typename?: 'AppObserveSpan', id: string, traceId: string, spanId: string, parentSpanId?: string | null, name: string, kind: AppObserveSpanKind, startTimestamp: any, endTimestamp: any, durationMs: number, statusCode: AppObserveSpanStatusCode, statusMessage?: string | null, sessionId?: string | null, droppedAttributesCount: number, droppedEventsCount: number, droppedLinksCount: number, ingestedAt?: any | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, expoSdkVersion?: string | null, reactNativeVersion?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, http?: { __typename?: 'AppObserveSpanHttp', method?: string | null, statusCode?: number | null, urlHost?: string | null, urlFull?: string | null, protocolVersion?: string | null, requestSize?: number | null, responseSize?: number | null, errorType?: string | null } | null, attributes: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }>, events: Array<{ __typename?: 'AppObserveSpanEvent', name: string, timestamp: any, attributes: any }> }> } | null } } } } };
|
||||
|
||||
export type AppObserveMetricByIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
@@ -16769,6 +16813,13 @@ export type UpdateByIdQuery = { __typename?: 'RootQuery', updates: { __typename?
|
||||
| { __typename: 'User', username: string, id: string }
|
||||
| null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null, rolloutControlUpdate?: { __typename?: 'Update', id: string, group: string } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, source?: { __typename?: 'FingerprintSource', type: FingerprintSourceType, bucketKey: string, isDebugFingerprint?: boolean | null } | null } | null } } };
|
||||
|
||||
export type UserPreferenceByKeyQueryVariables = Exact<{
|
||||
key: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type UserPreferenceByKeyQuery = { __typename?: 'RootQuery', userPreference: { __typename?: 'UserPreferenceQuery', get?: { __typename?: 'UserPreference', id: string, key: string, value: any } | null } };
|
||||
|
||||
export type CurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@@ -17059,6 +17110,8 @@ export type AppObserveErrorGroupFragment = { __typename?: 'AppObserveErrorGroup'
|
||||
|
||||
export type AppObserveAppVersionFragment = { __typename?: 'AppObserveAppVersion', appVersion: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, buildNumbers: Array<{ __typename?: 'AppObserveAppBuildNumber', appBuildNumber: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, updates: Array<{ __typename?: 'AppObserveAppUpdate', appUpdateId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, metrics: Array<{ __typename?: 'AppObserveAppVersionMetric', metricName: string, eventCount: number, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> };
|
||||
|
||||
export type AppObserveSpanFragment = { __typename?: 'AppObserveSpan', id: string, traceId: string, spanId: string, parentSpanId?: string | null, name: string, kind: AppObserveSpanKind, startTimestamp: any, endTimestamp: any, durationMs: number, statusCode: AppObserveSpanStatusCode, statusMessage?: string | null, sessionId?: string | null, droppedAttributesCount: number, droppedEventsCount: number, droppedLinksCount: number, ingestedAt?: any | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, expoSdkVersion?: string | null, reactNativeVersion?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, http?: { __typename?: 'AppObserveSpanHttp', method?: string | null, statusCode?: number | null, urlHost?: string | null, urlFull?: string | null, protocolVersion?: string | null, requestSize?: number | null, responseSize?: number | null, errorType?: string | null } | null, attributes: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }>, events: Array<{ __typename?: 'AppObserveSpanEvent', name: string, timestamp: any, attributes: any }> };
|
||||
|
||||
export type PostHogOrganizationConnectionFragment = { __typename?: 'PostHogOrganizationConnection', id: string, posthogOrganizationIdentifier: string, posthogOrganizationName: string, posthogRegion: PostHogRegion, createdAt: any, updatedAt: any };
|
||||
|
||||
export type PostHogProjectFragment = { __typename?: 'PostHogProject', id: string, posthogProjectIdentifier: string, posthogProjectName: string, posthogProjectToken: string, posthogHost: string, createdAt: any, updatedAt: any, posthogOrganizationConnection: { __typename?: 'PostHogOrganizationConnection', id: string, posthogOrganizationIdentifier: string, posthogOrganizationName: string, posthogRegion: PostHogRegion, createdAt: any, updatedAt: any } };
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import { SetUserPreferenceMutation, SetUserPreferenceMutationVariables } from '../generated';
|
||||
|
||||
export const UserPreferenceMutation = {
|
||||
/** Stores the JSON value under the key for the current user. Rejects for robot viewers. */
|
||||
async setAsync(graphqlClient: ExpoGraphqlClient, key: string, value: unknown): Promise<void> {
|
||||
await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<SetUserPreferenceMutation, SetUserPreferenceMutationVariables>(
|
||||
gql`
|
||||
mutation SetUserPreference($key: String!, $value: JSON!) {
|
||||
userPreference {
|
||||
set(key: $key, value: $value) {
|
||||
id
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ key, value },
|
||||
{ additionalTypenames: ['UserPreference'], noRetry: true }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { print } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { UserPreferenceMutation } from '../UserPreferenceMutation';
|
||||
|
||||
function makeGraphqlClient(result: { data?: unknown; error?: CombinedError }): ExpoGraphqlClient & {
|
||||
mutation: jest.Mock;
|
||||
} {
|
||||
return {
|
||||
mutation: jest.fn().mockReturnValue({ toPromise: jest.fn().mockResolvedValue(result) }),
|
||||
} as unknown as ExpoGraphqlClient & { mutation: jest.Mock };
|
||||
}
|
||||
|
||||
describe('UserPreferenceMutation.setAsync', () => {
|
||||
it('sets the preference by key', async () => {
|
||||
const graphqlClient = makeGraphqlClient({
|
||||
data: {
|
||||
userPreference: {
|
||||
set: { id: 'preference-id', key: 'some-key', value: { billingPeriodId: 'period-1' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
UserPreferenceMutation.setAsync(graphqlClient, 'some-key', { billingPeriodId: 'period-1' })
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const [document, variables, context] = graphqlClient.mutation.mock.calls[0];
|
||||
const printed = print(document);
|
||||
expect(printed).toContain('mutation SetUserPreference');
|
||||
expect(printed).toContain('set(key: $key, value: $value)');
|
||||
expect(variables).toEqual({ key: 'some-key', value: { billingPeriodId: 'period-1' } });
|
||||
expect(context).toEqual({ additionalTypenames: ['UserPreference'], noRetry: true });
|
||||
});
|
||||
|
||||
it('rejects on a GraphQL error', async () => {
|
||||
const error = new CombinedError({ networkError: new Error('forbidden') });
|
||||
const graphqlClient = makeGraphqlClient({ error });
|
||||
|
||||
await expect(
|
||||
UserPreferenceMutation.setAsync(graphqlClient, 'some-key', { billingPeriodId: 'period-1' })
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AccountByNameQuery as AccountByNameQueryType,
|
||||
AccountByNameQueryVariables,
|
||||
AccountFreeTierBuildUsageQuery,
|
||||
AccountFreeTierBuildUsageQueryVariables,
|
||||
AccountFullUsageQuery as AccountFullUsageQueryType,
|
||||
AccountFullUsageQueryVariables,
|
||||
AccountSubscriptionQuery as AccountSubscriptionQueryType,
|
||||
@@ -21,6 +23,9 @@ import {
|
||||
} from '../types/Account';
|
||||
|
||||
export type AccountFullUsageData = NonNullable<AccountFullUsageQueryType['account']['byId']>;
|
||||
export type AccountFreeTierBuildUsageData = NonNullable<
|
||||
AccountFreeTierBuildUsageQuery['account']['byId']
|
||||
>;
|
||||
export type AccountSubscriptionInfo = NonNullable<
|
||||
NonNullable<AccountSubscriptionQueryType['account']['byId']>['subscription']
|
||||
>;
|
||||
@@ -208,6 +213,72 @@ export const AccountQuery = {
|
||||
return data.account.byId;
|
||||
},
|
||||
|
||||
async getFreeTierBuildUsageAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
currentDate: Date
|
||||
): Promise<AccountFreeTierBuildUsageData> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AccountFreeTierBuildUsageQuery, AccountFreeTierBuildUsageQueryVariables>(
|
||||
gql`
|
||||
query AccountFreeTierBuildUsage($accountId: String!, $currentDate: DateTime!) {
|
||||
account {
|
||||
byId(accountId: $accountId) {
|
||||
id
|
||||
subscription {
|
||||
id
|
||||
name
|
||||
}
|
||||
billingPeriod(date: $currentDate) {
|
||||
id
|
||||
start
|
||||
end
|
||||
}
|
||||
usageMetrics {
|
||||
EAS_BUILD: byBillingPeriod(date: $currentDate, service: BUILDS) {
|
||||
id
|
||||
planMetrics {
|
||||
id
|
||||
serviceMetric
|
||||
metricType
|
||||
value
|
||||
limit
|
||||
platformBreakdown {
|
||||
ios {
|
||||
value
|
||||
limit
|
||||
}
|
||||
android {
|
||||
value
|
||||
limit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ accountId, currentDate: currentDate.toISOString() },
|
||||
{
|
||||
additionalTypenames: [
|
||||
'Account',
|
||||
'AccountUsageMetrics',
|
||||
'UsageMetricTotal',
|
||||
'BillingPeriod',
|
||||
'EstimatedUsage',
|
||||
],
|
||||
noRetry: true,
|
||||
}
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.account.byId;
|
||||
},
|
||||
|
||||
async getUsageForOverageWarningAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
AppObserveNavigationRoute,
|
||||
AppObservePlatform,
|
||||
AppObserveReleasesInput,
|
||||
AppObserveSpan,
|
||||
AppObserveSpanListFilter,
|
||||
AppObserveSpanListOrderBy,
|
||||
AppObserveSpanName,
|
||||
AppObserveSpanNamesInput,
|
||||
AppObserveTrace,
|
||||
AppObserveUserEvent,
|
||||
AppObserveUserEventListFilter,
|
||||
AppObserveUserEventListOrderBy,
|
||||
@@ -31,6 +37,7 @@ import {
|
||||
AppObserveErrorGroupFragmentNode,
|
||||
AppObserveErrorOccurrenceFragmentNode,
|
||||
AppObserveMetricFragmentNode,
|
||||
AppObserveSpanFragmentNode,
|
||||
AppObserveUserEventFragmentNode,
|
||||
} from '../types/Observe';
|
||||
|
||||
@@ -222,6 +229,10 @@ type AppObserveSessionEventsQuery = {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{ node: AppObserveSessionLog }>;
|
||||
};
|
||||
spans: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{ node: AppObserveSpan }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -234,6 +245,68 @@ type AppObserveSessionEventsQueryVariables = {
|
||||
first?: number;
|
||||
metricsOrderBy?: AppObserveMetricsListOrderBy;
|
||||
logsOrderBy?: AppObserveLogsOrderBy;
|
||||
spansOrderBy?: AppObserveSpanListOrderBy;
|
||||
};
|
||||
|
||||
type AppObserveSpanListQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
traces: {
|
||||
spans: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{
|
||||
cursor: string;
|
||||
node: AppObserveSpan;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveSpanListQueryVariables = {
|
||||
appId: string;
|
||||
filter: AppObserveSpanListFilter;
|
||||
first?: number;
|
||||
after?: string;
|
||||
orderBy?: AppObserveSpanListOrderBy;
|
||||
};
|
||||
|
||||
type AppObserveSpanNamesQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
traces: {
|
||||
names: {
|
||||
isTruncated: boolean;
|
||||
names: AppObserveSpanName[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveSpanNamesQueryVariables = {
|
||||
appId: string;
|
||||
input: AppObserveSpanNamesInput;
|
||||
};
|
||||
|
||||
type AppObserveTraceByIdQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
traces: {
|
||||
trace: AppObserveTrace | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveMetricByIdQuery = {
|
||||
@@ -645,8 +718,10 @@ export const ObserveQuery = {
|
||||
): Promise<{
|
||||
metrics: AppObserveMetric[];
|
||||
logs: AppObserveSessionLog[];
|
||||
spans: AppObserveSpan[];
|
||||
metricsPageInfo: PageInfo;
|
||||
logsPageInfo: PageInfo;
|
||||
spansPageInfo: PageInfo;
|
||||
}> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
@@ -658,6 +733,7 @@ export const ObserveQuery = {
|
||||
$first: Int
|
||||
$metricsOrderBy: AppObserveMetricsListOrderBy
|
||||
$logsOrderBy: AppObserveLogsOrderBy
|
||||
$spansOrderBy: AppObserveSpanListOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
@@ -697,6 +773,19 @@ export const ObserveQuery = {
|
||||
}
|
||||
}
|
||||
}
|
||||
spans(first: $first, orderBy: $spansOrderBy) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...AppObserveSpanFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -705,21 +794,150 @@ export const ObserveQuery = {
|
||||
${print(AppObserveMetricFragmentNode)}
|
||||
${print(AppObserveUserEventFragmentNode)}
|
||||
${print(AppObserveErrorFragmentNode)}
|
||||
${print(AppObserveSpanFragmentNode)}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
const { metrics, logs } = data.app.byId.observe.session;
|
||||
const { metrics, logs, spans } = data.app.byId.observe.session;
|
||||
return {
|
||||
metrics: metrics.edges.map(edge => edge.node),
|
||||
logs: logs.edges.map(edge => edge.node),
|
||||
spans: spans.edges.map(edge => edge.node),
|
||||
metricsPageInfo: metrics.pageInfo,
|
||||
logsPageInfo: logs.pageInfo,
|
||||
spansPageInfo: spans.pageInfo,
|
||||
};
|
||||
},
|
||||
|
||||
async spanListAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
variables: AppObserveSpanListQueryVariables
|
||||
): Promise<{ spans: AppObserveSpan[]; pageInfo: PageInfo }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveSpanListQuery, AppObserveSpanListQueryVariables>(
|
||||
gql`
|
||||
query AppObserveSpanList(
|
||||
$appId: String!
|
||||
$filter: AppObserveSpanListFilter!
|
||||
$first: Int
|
||||
$after: String
|
||||
$orderBy: AppObserveSpanListOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
traces {
|
||||
spans(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
...AppObserveSpanFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveSpanFragmentNode)}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
const { edges, pageInfo } = data.app.byId.observe.traces.spans;
|
||||
return {
|
||||
spans: edges.map(edge => edge.node),
|
||||
pageInfo,
|
||||
};
|
||||
},
|
||||
|
||||
async spanNamesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
variables: AppObserveSpanNamesQueryVariables
|
||||
): Promise<{ names: AppObserveSpanName[]; isTruncated: boolean }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveSpanNamesQuery, AppObserveSpanNamesQueryVariables>(
|
||||
gql`
|
||||
query AppObserveSpanNames($appId: String!, $input: AppObserveSpanNamesInput!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
traces {
|
||||
names(input: $input) {
|
||||
isTruncated
|
||||
names {
|
||||
name
|
||||
kind
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.traces.names;
|
||||
},
|
||||
|
||||
async traceByIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, id }: AppObserveByIdQueryVariables
|
||||
): Promise<AppObserveTrace | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveTraceByIdQuery, AppObserveByIdQueryVariables>(
|
||||
gql`
|
||||
query AppObserveTraceById($appId: String!, $id: ID!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
traces {
|
||||
trace(id: $id) {
|
||||
id
|
||||
isTruncated
|
||||
spans {
|
||||
id
|
||||
...AppObserveSpanFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AppObserveSpanFragmentNode)}
|
||||
`,
|
||||
{ appId, id }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.observe.traces.trace ?? null;
|
||||
},
|
||||
|
||||
async metricByIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ appId, id }: AppObserveByIdQueryVariables
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import { UserPreferenceByKeyQuery, UserPreferenceByKeyQueryVariables } from '../generated';
|
||||
|
||||
export const UserPreferenceQuery = {
|
||||
/**
|
||||
* Returns the JSON value stored under the key for the current user, or null when the key is not
|
||||
* set or the viewer is not a user (for example a robot).
|
||||
*/
|
||||
async getValueAsync(graphqlClient: ExpoGraphqlClient, key: string): Promise<unknown> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<UserPreferenceByKeyQuery, UserPreferenceByKeyQueryVariables>(
|
||||
gql`
|
||||
query UserPreferenceByKey($key: String!) {
|
||||
userPreference {
|
||||
get(key: $key) {
|
||||
id
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ key },
|
||||
{ additionalTypenames: ['UserPreference'], noRetry: true }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.userPreference.get?.value ?? null;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { print } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasServiceMetric, UsageMetricType } from '../../generated';
|
||||
import { AccountQuery } from '../AccountQuery';
|
||||
|
||||
function makeGraphqlClient(result: { data?: unknown; error?: CombinedError }): ExpoGraphqlClient & {
|
||||
query: jest.Mock;
|
||||
} {
|
||||
return {
|
||||
query: jest.fn().mockReturnValue({ toPromise: jest.fn().mockResolvedValue(result) }),
|
||||
} as unknown as ExpoGraphqlClient & { query: jest.Mock };
|
||||
}
|
||||
|
||||
describe('AccountQuery.getFreeTierBuildUsageAsync', () => {
|
||||
const currentDate = new Date('2026-10-26T12:00:00.000Z');
|
||||
const account = {
|
||||
id: 'account-id',
|
||||
subscription: { id: 'subscription-id', name: 'Free' },
|
||||
billingPeriod: {
|
||||
id: 'period-1',
|
||||
start: '2026-10-01T00:00:00.000Z',
|
||||
end: '2026-11-01T00:00:00.000Z',
|
||||
},
|
||||
usageMetrics: {
|
||||
EAS_BUILD: {
|
||||
id: 'eas-build',
|
||||
planMetrics: [
|
||||
{
|
||||
id: 'builds',
|
||||
serviceMetric: EasServiceMetric.Builds,
|
||||
metricType: UsageMetricType.Build,
|
||||
value: 14,
|
||||
limit: 30,
|
||||
platformBreakdown: {
|
||||
android: { value: 11, limit: 15 },
|
||||
ios: { value: 3, limit: 15 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('returns the account usage', async () => {
|
||||
const graphqlClient = makeGraphqlClient({ data: { account: { byId: account } } });
|
||||
|
||||
await expect(
|
||||
AccountQuery.getFreeTierBuildUsageAsync(graphqlClient, 'account-id', currentDate)
|
||||
).resolves.toEqual(account);
|
||||
});
|
||||
|
||||
it('selects the plan name, billing period, and per-platform build usage for the date', async () => {
|
||||
const graphqlClient = makeGraphqlClient({ data: { account: { byId: account } } });
|
||||
|
||||
await AccountQuery.getFreeTierBuildUsageAsync(graphqlClient, 'account-id', currentDate);
|
||||
|
||||
const [document, variables, context] = graphqlClient.query.mock.calls[0];
|
||||
const printed = print(document);
|
||||
expect(printed).toContain('query AccountFreeTierBuildUsage');
|
||||
expect(printed).toContain('byId(accountId: $accountId)');
|
||||
expect(printed).toContain('billingPeriod(date: $currentDate)');
|
||||
expect(printed).toContain('EAS_BUILD: byBillingPeriod(date: $currentDate, service: BUILDS)');
|
||||
expect(printed).toContain('platformBreakdown');
|
||||
expect(printed).not.toContain('overageMetrics');
|
||||
expect(variables).toEqual({
|
||||
accountId: 'account-id',
|
||||
currentDate: '2026-10-26T12:00:00.000Z',
|
||||
});
|
||||
expect(context).toEqual({
|
||||
additionalTypenames: [
|
||||
'Account',
|
||||
'AccountUsageMetrics',
|
||||
'UsageMetricTotal',
|
||||
'BillingPeriod',
|
||||
'EstimatedUsage',
|
||||
],
|
||||
noRetry: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects on a GraphQL error', async () => {
|
||||
const error = new CombinedError({ networkError: new Error('offline') });
|
||||
const graphqlClient = makeGraphqlClient({ error });
|
||||
|
||||
await expect(
|
||||
AccountQuery.getFreeTierBuildUsageAsync(graphqlClient, 'account-id', currentDate)
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { print } from 'graphql';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { UserPreferenceQuery } from '../UserPreferenceQuery';
|
||||
|
||||
function makeGraphqlClient(result: { data?: unknown; error?: CombinedError }): ExpoGraphqlClient & {
|
||||
query: jest.Mock;
|
||||
} {
|
||||
return {
|
||||
query: jest.fn().mockReturnValue({ toPromise: jest.fn().mockResolvedValue(result) }),
|
||||
} as unknown as ExpoGraphqlClient & { query: jest.Mock };
|
||||
}
|
||||
|
||||
describe('UserPreferenceQuery.getValueAsync', () => {
|
||||
it('returns the stored value', async () => {
|
||||
const graphqlClient = makeGraphqlClient({
|
||||
data: {
|
||||
userPreference: {
|
||||
get: { id: 'preference-id', key: 'some-key', value: { billingPeriodId: 'period-1' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(UserPreferenceQuery.getValueAsync(graphqlClient, 'some-key')).resolves.toEqual({
|
||||
billingPeriodId: 'period-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the key is not set', async () => {
|
||||
const graphqlClient = makeGraphqlClient({ data: { userPreference: { get: null } } });
|
||||
|
||||
await expect(UserPreferenceQuery.getValueAsync(graphqlClient, 'some-key')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('queries the preference by key', async () => {
|
||||
const graphqlClient = makeGraphqlClient({ data: { userPreference: { get: null } } });
|
||||
|
||||
await UserPreferenceQuery.getValueAsync(graphqlClient, 'some-key');
|
||||
|
||||
const [document, variables, context] = graphqlClient.query.mock.calls[0];
|
||||
const printed = print(document);
|
||||
expect(printed).toContain('query UserPreferenceByKey');
|
||||
expect(printed).toContain('get(key: $key)');
|
||||
expect(variables).toEqual({ key: 'some-key' });
|
||||
expect(context).toEqual({ additionalTypenames: ['UserPreference'], noRetry: true });
|
||||
});
|
||||
|
||||
it('rejects on a GraphQL error', async () => {
|
||||
const error = new CombinedError({ networkError: new Error('offline') });
|
||||
const graphqlClient = makeGraphqlClient({ error });
|
||||
|
||||
await expect(UserPreferenceQuery.getValueAsync(graphqlClient, 'some-key')).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -170,3 +170,56 @@ export const AppObserveAppVersionFragmentNode = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const AppObserveSpanFragmentNode = gql`
|
||||
fragment AppObserveSpanFragment on AppObserveSpan {
|
||||
id
|
||||
traceId
|
||||
spanId
|
||||
parentSpanId
|
||||
name
|
||||
kind
|
||||
startTimestamp
|
||||
endTimestamp
|
||||
durationMs
|
||||
statusCode
|
||||
statusMessage
|
||||
sessionId
|
||||
http {
|
||||
method
|
||||
statusCode
|
||||
urlHost
|
||||
urlFull
|
||||
protocolVersion
|
||||
requestSize
|
||||
responseSize
|
||||
errorType
|
||||
}
|
||||
attributes {
|
||||
key
|
||||
value
|
||||
type
|
||||
}
|
||||
events {
|
||||
name
|
||||
timestamp
|
||||
attributes
|
||||
}
|
||||
droppedAttributesCount
|
||||
droppedEventsCount
|
||||
droppedLinksCount
|
||||
ingestedAt
|
||||
appVersion
|
||||
appBuildNumber
|
||||
appUpdateId
|
||||
appEasBuildId
|
||||
expoSdkVersion
|
||||
reactNativeVersion
|
||||
deviceOs
|
||||
deviceOsVersion
|
||||
deviceModel
|
||||
environment
|
||||
easClientId
|
||||
countryCode
|
||||
}
|
||||
`;
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
AppObserveMetric,
|
||||
AppObserveMetricsListOrderByField,
|
||||
AppObserveOrderDirection,
|
||||
AppObserveSpan,
|
||||
AppObserveSpanKind,
|
||||
AppObserveSpanListOrderByField,
|
||||
AppObserveSpanStatusCode,
|
||||
AppObserveUserEvent,
|
||||
AppObserveUserEventListOrderByField,
|
||||
} from '../../graphql/generated';
|
||||
@@ -106,6 +110,54 @@ function makeErrorLog(overrides: Partial<AppObserveError> = {}): AppObserveSessi
|
||||
} as AppObserveSessionLog;
|
||||
}
|
||||
|
||||
function makeSpan(overrides: Partial<AppObserveSpan> = {}): AppObserveSpan {
|
||||
return {
|
||||
__typename: 'AppObserveSpan' as const,
|
||||
id: 'span-1',
|
||||
traceId: 'a'.repeat(32),
|
||||
spanId: '1'.repeat(16),
|
||||
parentSpanId: null,
|
||||
name: 'GET',
|
||||
kind: AppObserveSpanKind.Client,
|
||||
startTimestamp: '2025-01-15T10:00:01.000Z',
|
||||
endTimestamp: '2025-01-15T10:00:01.123Z',
|
||||
durationMs: 123.4,
|
||||
statusCode: AppObserveSpanStatusCode.Ok,
|
||||
statusMessage: null,
|
||||
sessionId: 'session-1',
|
||||
http: {
|
||||
__typename: 'AppObserveSpanHttp' as const,
|
||||
method: 'GET',
|
||||
statusCode: 200,
|
||||
urlHost: 'api.example.com',
|
||||
urlFull: 'https://api.example.com/users?page=1',
|
||||
protocolVersion: '1.1',
|
||||
requestSize: 100,
|
||||
responseSize: 2000,
|
||||
errorType: null,
|
||||
},
|
||||
attributes: [],
|
||||
events: [],
|
||||
droppedAttributesCount: 0,
|
||||
droppedEventsCount: 0,
|
||||
droppedLinksCount: 0,
|
||||
ingestedAt: null,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
appEasBuildId: null,
|
||||
expoSdkVersion: '54.0.0',
|
||||
reactNativeVersion: '0.81.0',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
environment: 'production',
|
||||
easClientId: 'client-1',
|
||||
countryCode: 'US',
|
||||
...overrides,
|
||||
} as AppObserveSpan;
|
||||
}
|
||||
|
||||
const baseOptions = {
|
||||
limit: 100,
|
||||
};
|
||||
@@ -118,6 +170,8 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
logs: [],
|
||||
metricsPageInfo: noNextPage,
|
||||
logsPageInfo: noNextPage,
|
||||
spans: [],
|
||||
spansPageInfo: noNextPage,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,6 +208,8 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
],
|
||||
metricsPageInfo: noNextPage,
|
||||
logsPageInfo: noNextPage,
|
||||
spans: [],
|
||||
spansPageInfo: noNextPage,
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
@@ -180,6 +236,8 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
],
|
||||
metricsPageInfo: noNextPage,
|
||||
logsPageInfo: noNextPage,
|
||||
spans: [],
|
||||
spansPageInfo: noNextPage,
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
@@ -202,6 +260,8 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
logs: [makeUserLog({ timestamp: '2025-01-15T10:05:00.000Z', appUpdateId: 'update-xyz' })],
|
||||
metricsPageInfo: noNextPage,
|
||||
logsPageInfo: noNextPage,
|
||||
spans: [],
|
||||
spansPageInfo: noNextPage,
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
@@ -236,6 +296,8 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
logs: [],
|
||||
metricsPageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'cm' },
|
||||
logsPageInfo: noNextPage,
|
||||
spans: [],
|
||||
spansPageInfo: noNextPage,
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
@@ -245,6 +307,52 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
|
||||
expect(result.hasMoreMetricEvents).toBe(true);
|
||||
expect(result.hasMoreLogEvents).toBe(false);
|
||||
expect(result.hasMoreSpans).toBe(false);
|
||||
});
|
||||
|
||||
it('queries the session spans oldest-first and merges them into the timeline', async () => {
|
||||
mockSessionEventsAsync.mockResolvedValue({
|
||||
metrics: [makeMetricEvent({ timestamp: '2025-01-15T10:00:00.000Z' })],
|
||||
logs: [makeUserLog({ timestamp: '2025-01-15T10:00:02.000Z' })],
|
||||
spans: [makeSpan({ startTimestamp: '2025-01-15T10:00:01.000Z' })],
|
||||
metricsPageInfo: noNextPage,
|
||||
logsPageInfo: noNextPage,
|
||||
spansPageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'cs' },
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
...baseOptions,
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
expect(mockSessionEventsAsync).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
spansOrderBy: {
|
||||
field: AppObserveSpanListOrderByField.StartTimestamp,
|
||||
direction: AppObserveOrderDirection.Asc,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.entries.map(e => e.source)).toEqual(['metric', 'span', 'log']);
|
||||
const spanEntry = result.entries[1];
|
||||
expect(spanEntry).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'span-1',
|
||||
name: 'GET',
|
||||
timestamp: '2025-01-15T10:00:01.000Z',
|
||||
traceId: 'a'.repeat(32),
|
||||
spanId: '1'.repeat(16),
|
||||
parentSpanId: null,
|
||||
durationMs: 123.4,
|
||||
statusCode: AppObserveSpanStatusCode.Ok,
|
||||
httpMethod: 'GET',
|
||||
httpStatusCode: 200,
|
||||
urlHost: 'api.example.com',
|
||||
urlFull: 'https://api.example.com/users?page=1',
|
||||
})
|
||||
);
|
||||
expect(result.hasMoreSpans).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
AppObserveOrderDirection,
|
||||
AppObservePlatform,
|
||||
AppObserveSpanKind,
|
||||
AppObserveSpanListOrderByField,
|
||||
AppObserveSpanStatusCode,
|
||||
} from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveSpansAsync } from '../fetchSpans';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
|
||||
describe(fetchObserveSpansAsync, () => {
|
||||
const mockSpanListAsync = jest.mocked(ObserveQuery.spanListAsync);
|
||||
const mockGraphqlClient = {} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSpanListAsync.mockClear();
|
||||
mockSpanListAsync.mockResolvedValue({
|
||||
spans: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes appId, time range, and limit to spanListAsync', async () => {
|
||||
await fetchObserveSpansAsync(mockGraphqlClient, 'project-123', {
|
||||
limit: 25,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(mockSpanListAsync).toHaveBeenCalledTimes(1);
|
||||
const callArgs = mockSpanListAsync.mock.calls[0][1];
|
||||
expect(callArgs.appId).toBe('project-123');
|
||||
expect(callArgs.first).toBe(25);
|
||||
expect(callArgs.after).toBeUndefined();
|
||||
expect(callArgs.filter).toEqual({
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('always asks for spans newest first', async () => {
|
||||
await fetchObserveSpansAsync(mockGraphqlClient, 'project-123', {
|
||||
limit: 25,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(mockSpanListAsync.mock.calls[0][1].orderBy).toEqual({
|
||||
field: AppObserveSpanListOrderByField.StartTimestamp,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps every option to its filter key', async () => {
|
||||
await fetchObserveSpansAsync(mockGraphqlClient, 'project-123', {
|
||||
limit: 10,
|
||||
after: 'cursor-1',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppObservePlatform.Ios],
|
||||
appVersion: '1.2.0',
|
||||
buildNumber: '42',
|
||||
updateId: 'update-1',
|
||||
environment: 'production',
|
||||
sessionId: 'session-1',
|
||||
names: ['GET', 'POST'],
|
||||
kinds: [AppObserveSpanKind.Client],
|
||||
statusCodes: [AppObserveSpanStatusCode.Error],
|
||||
hosts: ['api.example.com'],
|
||||
httpStatusCodes: [500, 502],
|
||||
minDurationMs: 250,
|
||||
rootOnly: true,
|
||||
});
|
||||
|
||||
const callArgs = mockSpanListAsync.mock.calls[0][1];
|
||||
expect(callArgs.after).toBe('cursor-1');
|
||||
expect(callArgs.filter).toEqual({
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppObservePlatform.Ios],
|
||||
appVersion: '1.2.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: 'update-1',
|
||||
environment: 'production',
|
||||
sessionId: 'session-1',
|
||||
names: ['GET', 'POST'],
|
||||
kinds: [AppObserveSpanKind.Client],
|
||||
statusCodes: [AppObserveSpanStatusCode.Error],
|
||||
hosts: ['api.example.com'],
|
||||
httpStatusCodes: [500, 502],
|
||||
minDurationMs: 250,
|
||||
rootOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends a zero minimum duration and omits rootOnly when false', async () => {
|
||||
await fetchObserveSpansAsync(mockGraphqlClient, 'project-123', {
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
minDurationMs: 0,
|
||||
rootOnly: false,
|
||||
names: [],
|
||||
});
|
||||
|
||||
const filter = mockSpanListAsync.mock.calls[0][1].filter;
|
||||
expect(filter.minDurationMs).toBe(0);
|
||||
expect(filter).not.toHaveProperty('rootOnly');
|
||||
expect(filter).not.toHaveProperty('names');
|
||||
});
|
||||
|
||||
it('returns the spans and pageInfo from the query', async () => {
|
||||
const span = { id: 'span-1' } as any;
|
||||
mockSpanListAsync.mockResolvedValue({
|
||||
spans: [span],
|
||||
pageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'c' },
|
||||
});
|
||||
|
||||
const result = await fetchObserveSpansAsync(mockGraphqlClient, 'project-123', {
|
||||
limit: 10,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(result.spans).toEqual([span]);
|
||||
expect(result.pageInfo.endCursor).toBe('c');
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,71 @@ function makeLogEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventE
|
||||
};
|
||||
}
|
||||
|
||||
function makeSpanEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventEntry {
|
||||
return {
|
||||
source: 'span',
|
||||
id: 'span-1',
|
||||
timestamp: '2025-01-15T10:00:01.000Z',
|
||||
sessionId: 'session-1',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
easClientId: 'client-1',
|
||||
name: 'GET',
|
||||
environment: 'production',
|
||||
traceId: 'a'.repeat(32),
|
||||
spanId: '1'.repeat(16),
|
||||
parentSpanId: null,
|
||||
durationMs: 123.4,
|
||||
statusCode: 'OK',
|
||||
httpMethod: 'GET',
|
||||
httpStatusCode: 200,
|
||||
httpErrorType: null,
|
||||
urlHost: 'api.example.com',
|
||||
urlFull: 'https://api.example.com/users?page=1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(buildObserveSessionEventsTable, () => {
|
||||
it('renders a span row with its method, host and path, duration, and status', () => {
|
||||
const output = buildObserveSessionEventsTable([makeMetricEntry(), makeSpanEntry()]);
|
||||
const spanLine = output.split('\n').find(line => line.includes('span'));
|
||||
expect(spanLine).toContain('1.00s');
|
||||
expect(spanLine).toContain('GET api.example.com/users?page=1');
|
||||
expect(spanLine).toContain('123ms');
|
||||
expect(spanLine).toContain('status=OK http=200');
|
||||
});
|
||||
|
||||
it('shows the client error type for a span whose request got no response', () => {
|
||||
const output = buildObserveSessionEventsTable([
|
||||
makeSpanEntry({
|
||||
statusCode: 'ERROR',
|
||||
httpStatusCode: null,
|
||||
httpErrorType: 'NSURLErrorTimedOut',
|
||||
}),
|
||||
]);
|
||||
expect(output).toContain('status=ERROR error=NSURLErrorTimedOut');
|
||||
});
|
||||
|
||||
it('strips control characters from a span row name', () => {
|
||||
const output = buildObserveSessionEventsTable([
|
||||
makeSpanEntry({ name: 'GET\u001b[2J', urlHost: 'api.example.com\u0007', urlFull: null }),
|
||||
]);
|
||||
expect(output).not.toContain('\u001b[2J');
|
||||
expect(output).not.toContain('\u0007');
|
||||
expect(output).toContain('GET[2J api.example.com');
|
||||
});
|
||||
|
||||
it('names spans in the "more available" note', () => {
|
||||
const output = buildObserveSessionEventsTable([makeSpanEntry()], { hasMoreSpans: true });
|
||||
expect(output).toContain('More spans are available for this session');
|
||||
});
|
||||
|
||||
it('renders a chronological table with display name for metric rows and event name for log rows', () => {
|
||||
const entries: SessionEventEntry[] = [
|
||||
makeMetricEntry(),
|
||||
@@ -234,19 +298,20 @@ describe(buildObserveSessionEventsJson, () => {
|
||||
firstSeenAt: '2025-01-15T10:00:00.000Z',
|
||||
lastSeenAt: '2025-01-15T10:05:00.000Z',
|
||||
};
|
||||
const result = buildObserveSessionEventsJson(entries, 'session-1', metadata, false, true);
|
||||
const result = buildObserveSessionEventsJson(entries, 'session-1', metadata, false, true, true);
|
||||
expect(result).toEqual({
|
||||
sessionId: 'session-1',
|
||||
metadata,
|
||||
entries,
|
||||
hasMoreMetricEvents: false,
|
||||
hasMoreLogEvents: true,
|
||||
hasMoreSpans: true,
|
||||
});
|
||||
expect(result.entries[0].id).toBe('evt-m-1');
|
||||
});
|
||||
|
||||
it('returns null metadata when there were no entries', () => {
|
||||
const result = buildObserveSessionEventsJson([], 'session-1', null, false, false);
|
||||
const result = buildObserveSessionEventsJson([], 'session-1', null, false, false, false);
|
||||
expect(result.metadata).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import {
|
||||
AppObserveSpan,
|
||||
AppObserveSpanKind,
|
||||
AppObserveSpanName,
|
||||
AppObserveSpanStatusCode,
|
||||
AppObserveTrace,
|
||||
PageInfo,
|
||||
} from '../../graphql/generated';
|
||||
import {
|
||||
buildObserveSpanJson,
|
||||
buildObserveSpanNamesJson,
|
||||
buildObserveSpanNamesTable,
|
||||
buildObserveSpansJson,
|
||||
buildObserveSpansTable,
|
||||
buildObserveTraceJson,
|
||||
buildObserveTraceTable,
|
||||
formatSpanDuration,
|
||||
formatSpanHttpStatus,
|
||||
formatSpanTitle,
|
||||
formatSpanUrlPath,
|
||||
orderTraceSpans,
|
||||
} from '../formatSpans';
|
||||
|
||||
const TRACE_ID = 'a'.repeat(32);
|
||||
|
||||
function makeSpan(overrides: Partial<AppObserveSpan> = {}): AppObserveSpan {
|
||||
return {
|
||||
__typename: 'AppObserveSpan' as const,
|
||||
id: 'span-1',
|
||||
traceId: TRACE_ID,
|
||||
spanId: '1'.repeat(16),
|
||||
parentSpanId: null,
|
||||
name: 'GET',
|
||||
kind: AppObserveSpanKind.Client,
|
||||
startTimestamp: '2025-01-15T10:00:00.000Z',
|
||||
endTimestamp: '2025-01-15T10:00:00.123Z',
|
||||
durationMs: 123.4,
|
||||
statusCode: AppObserveSpanStatusCode.Ok,
|
||||
statusMessage: null,
|
||||
sessionId: 'session-1',
|
||||
http: {
|
||||
__typename: 'AppObserveSpanHttp' as const,
|
||||
method: 'GET',
|
||||
statusCode: 200,
|
||||
urlHost: 'api.example.com',
|
||||
urlFull: 'https://api.example.com/users?page=1',
|
||||
protocolVersion: '1.1',
|
||||
requestSize: 100,
|
||||
responseSize: 2000,
|
||||
errorType: null,
|
||||
},
|
||||
attributes: [{ key: 'expo.http.resource.fetch_type', value: 'network', type: 'STRING' }],
|
||||
events: [{ name: 'exception', timestamp: '2025-01-15T10:00:00.100Z', attributes: { a: 1 } }],
|
||||
droppedAttributesCount: 0,
|
||||
droppedEventsCount: 0,
|
||||
droppedLinksCount: 1,
|
||||
ingestedAt: '2025-01-15T10:00:05.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
appEasBuildId: null,
|
||||
expoSdkVersion: '54.0.0',
|
||||
reactNativeVersion: '0.81.0',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
environment: 'production',
|
||||
easClientId: 'client-1',
|
||||
countryCode: 'US',
|
||||
...overrides,
|
||||
} as AppObserveSpan;
|
||||
}
|
||||
|
||||
function makeSpanName(name: string, kind: AppObserveSpanKind, count: number): AppObserveSpanName {
|
||||
return { __typename: 'AppObserveSpanName' as const, name, kind, count };
|
||||
}
|
||||
|
||||
const noNextPage: PageInfo = { hasNextPage: false, hasPreviousPage: false };
|
||||
const withNextPage: PageInfo = {
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: false,
|
||||
endCursor: 'cursor-abc',
|
||||
};
|
||||
|
||||
describe(formatSpanDuration, () => {
|
||||
it('shows milliseconds under a second and seconds above', () => {
|
||||
expect(formatSpanDuration(0.4)).toBe('0ms');
|
||||
expect(formatSpanDuration(123.4)).toBe('123ms');
|
||||
expect(formatSpanDuration(999.6)).toBe('1.00s');
|
||||
expect(formatSpanDuration(1234)).toBe('1.23s');
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatSpanHttpStatus, () => {
|
||||
it('prefers the status code, then the error type, then a dash', () => {
|
||||
expect(formatSpanHttpStatus(makeSpan())).toBe('200');
|
||||
expect(
|
||||
formatSpanHttpStatus(
|
||||
makeSpan({ http: { statusCode: null, errorType: 'NSURLErrorTimedOut' } as any })
|
||||
)
|
||||
).toBe('NSURLErrorTimedOut');
|
||||
expect(formatSpanHttpStatus(makeSpan({ http: null }))).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatSpanUrlPath, () => {
|
||||
it('keeps the path and query of a URL and returns unparsable input as is', () => {
|
||||
expect(formatSpanUrlPath('https://api.example.com/users?page=1')).toBe('/users?page=1');
|
||||
expect(formatSpanUrlPath('not a url')).toBe('not a url');
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatSpanTitle, () => {
|
||||
it('joins the span name, host, and path for a request span', () => {
|
||||
expect(formatSpanTitle(makeSpan())).toBe('GET api.example.com/users?page=1');
|
||||
});
|
||||
|
||||
it('falls back to the span name for a non-HTTP span', () => {
|
||||
expect(formatSpanTitle(makeSpan({ name: 'render', http: null }))).toBe('render');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveSpanNamesTable, () => {
|
||||
it('lists names with kind and count and a hint to list spans', () => {
|
||||
const output = buildObserveSpanNamesTable(
|
||||
[
|
||||
makeSpanName('GET', AppObserveSpanKind.Client, 1234),
|
||||
makeSpanName('POST', AppObserveSpanKind.Client, 5),
|
||||
],
|
||||
{ daysBack: 7 }
|
||||
);
|
||||
expect(output).toContain('Span names for the last 7 days');
|
||||
expect(output).toContain('GET');
|
||||
expect(output).toContain('CLIENT');
|
||||
expect(output).toContain((1234).toLocaleString());
|
||||
expect(output).toContain('--all-spans');
|
||||
expect(output).not.toContain('truncated');
|
||||
});
|
||||
|
||||
it('strips control characters from span names', () => {
|
||||
const output = buildObserveSpanNamesTable([
|
||||
makeSpanName('GET\u001b[2J', AppObserveSpanKind.Client, 1),
|
||||
]);
|
||||
expect(output).not.toContain('\u001b[2J');
|
||||
expect(output).toContain('GET');
|
||||
});
|
||||
|
||||
it('notes truncation', () => {
|
||||
const output = buildObserveSpanNamesTable([makeSpanName('GET', AppObserveSpanKind.Client, 1)], {
|
||||
isTruncated: true,
|
||||
});
|
||||
expect(output).toContain('truncated');
|
||||
});
|
||||
|
||||
it('reports no spans', () => {
|
||||
expect(buildObserveSpanNamesTable([])).toContain('No spans found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveSpanNamesJson, () => {
|
||||
it('maps names and the truncation flag', () => {
|
||||
expect(
|
||||
buildObserveSpanNamesJson([makeSpanName('GET', AppObserveSpanKind.Client, 3)], true)
|
||||
).toEqual({ names: [{ name: 'GET', kind: 'CLIENT', count: 3 }], isTruncated: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveSpansTable, () => {
|
||||
it('renders one row per span with duration, status, HTTP status, host, and trace id', () => {
|
||||
const output = buildObserveSpansTable([makeSpan()], noNextPage, { daysBack: 7 });
|
||||
expect(output).toContain('Spans for the last 7 days');
|
||||
const row = output.split('\n').find(line => line.includes(TRACE_ID));
|
||||
expect(row).toContain('GET');
|
||||
expect(row).toContain('123ms');
|
||||
expect(row).toContain('OK');
|
||||
expect(row).toContain('200');
|
||||
expect(row).toContain('api.example.com');
|
||||
expect(row).toContain('1.0.0 (42)');
|
||||
expect(row).toContain('iOS 17.0');
|
||||
expect(output).toContain('eas observe:trace <Trace ID>');
|
||||
expect(output).not.toContain('Next page');
|
||||
});
|
||||
|
||||
it('strips control characters from span names and hosts', () => {
|
||||
const span = makeSpan({
|
||||
name: 'GET\u001b[2J',
|
||||
http: { ...makeSpan().http!, urlHost: 'api.example.com\u0007' },
|
||||
});
|
||||
const output = buildObserveSpansTable([span], noNextPage);
|
||||
expect(output).not.toContain('\u001b[2J');
|
||||
expect(output).not.toContain('\u0007');
|
||||
expect(output).toContain('GET');
|
||||
expect(output).toContain('api.example.com');
|
||||
});
|
||||
|
||||
it('titles the table by session when a session filter is set', () => {
|
||||
const output = buildObserveSpansTable([makeSpan()], noNextPage, {
|
||||
daysBack: 7,
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
expect(output).toContain('Spans in session session-1');
|
||||
expect(output).not.toContain('for the last');
|
||||
});
|
||||
|
||||
it('appends the next page hint when hasNextPage is true', () => {
|
||||
const output = buildObserveSpansTable([makeSpan()], withNextPage);
|
||||
expect(output).toContain('Next page: --after cursor-abc');
|
||||
});
|
||||
|
||||
it('reports no spans', () => {
|
||||
expect(buildObserveSpansTable([], noNextPage)).toContain('No spans found.');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveSpanJson, () => {
|
||||
it('maps every field and nulls missing optionals', () => {
|
||||
const json = buildObserveSpanJson(makeSpan());
|
||||
expect(json).toEqual({
|
||||
id: 'span-1',
|
||||
traceId: TRACE_ID,
|
||||
spanId: '1'.repeat(16),
|
||||
parentSpanId: null,
|
||||
name: 'GET',
|
||||
kind: 'CLIENT',
|
||||
startTimestamp: '2025-01-15T10:00:00.000Z',
|
||||
endTimestamp: '2025-01-15T10:00:00.123Z',
|
||||
durationMs: 123.4,
|
||||
statusCode: 'OK',
|
||||
statusMessage: null,
|
||||
sessionId: 'session-1',
|
||||
http: {
|
||||
method: 'GET',
|
||||
statusCode: 200,
|
||||
urlHost: 'api.example.com',
|
||||
urlFull: 'https://api.example.com/users?page=1',
|
||||
protocolVersion: '1.1',
|
||||
requestSize: 100,
|
||||
responseSize: 2000,
|
||||
errorType: null,
|
||||
},
|
||||
attributes: [{ key: 'expo.http.resource.fetch_type', value: 'network', type: 'STRING' }],
|
||||
events: [{ name: 'exception', timestamp: '2025-01-15T10:00:00.100Z', attributes: { a: 1 } }],
|
||||
droppedAttributesCount: 0,
|
||||
droppedEventsCount: 0,
|
||||
droppedLinksCount: 1,
|
||||
ingestedAt: '2025-01-15T10:00:05.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
appEasBuildId: null,
|
||||
expoSdkVersion: '54.0.0',
|
||||
reactNativeVersion: '0.81.0',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
environment: 'production',
|
||||
easClientId: 'client-1',
|
||||
countryCode: 'US',
|
||||
});
|
||||
expect(buildObserveSpanJson(makeSpan({ http: null })).http).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveSpansJson, () => {
|
||||
it('wraps spans with pageInfo', () => {
|
||||
const json = buildObserveSpansJson([makeSpan()], withNextPage);
|
||||
expect(json.spans).toHaveLength(1);
|
||||
expect(json.pageInfo).toEqual({ hasNextPage: true, endCursor: 'cursor-abc' });
|
||||
});
|
||||
});
|
||||
|
||||
function makeTrace(spans: AppObserveSpan[], isTruncated = false): AppObserveTrace {
|
||||
return { __typename: 'AppObserveTrace' as const, id: TRACE_ID, spans, isTruncated };
|
||||
}
|
||||
|
||||
const root = makeSpan({
|
||||
id: 'root',
|
||||
spanId: 'r'.repeat(16),
|
||||
startTimestamp: '2025-01-15T10:00:00.000Z',
|
||||
});
|
||||
const childLater = makeSpan({
|
||||
id: 'child-later',
|
||||
spanId: 'b'.repeat(16),
|
||||
parentSpanId: 'r'.repeat(16),
|
||||
name: 'POST',
|
||||
startTimestamp: '2025-01-15T10:00:00.500Z',
|
||||
durationMs: 50,
|
||||
});
|
||||
const childEarlier = makeSpan({
|
||||
id: 'child-earlier',
|
||||
spanId: 'c'.repeat(16),
|
||||
parentSpanId: 'r'.repeat(16),
|
||||
startTimestamp: '2025-01-15T10:00:00.200Z',
|
||||
durationMs: 2500,
|
||||
});
|
||||
const grandchild = makeSpan({
|
||||
id: 'grandchild',
|
||||
spanId: 'd'.repeat(16),
|
||||
parentSpanId: 'c'.repeat(16),
|
||||
startTimestamp: '2025-01-15T10:00:00.300Z',
|
||||
});
|
||||
const orphan = makeSpan({
|
||||
id: 'orphan',
|
||||
spanId: 'e'.repeat(16),
|
||||
parentSpanId: 'f'.repeat(16),
|
||||
startTimestamp: '2025-01-15T10:00:00.100Z',
|
||||
});
|
||||
|
||||
describe(orderTraceSpans, () => {
|
||||
it('walks children under their parent, siblings oldest first, orphans as roots', () => {
|
||||
const rows = orderTraceSpans([childLater, grandchild, orphan, childEarlier, root]);
|
||||
expect(rows.map(r => [r.span.id, r.depth])).toEqual([
|
||||
['root', 0],
|
||||
['child-earlier', 1],
|
||||
['grandchild', 2],
|
||||
['child-later', 1],
|
||||
['orphan', 0],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveTraceTable, () => {
|
||||
it('renders a header, indented rows with offsets, and a session hint', () => {
|
||||
const output = buildObserveTraceTable(makeTrace([childLater, childEarlier, root]));
|
||||
expect(output).toContain(`Trace ${TRACE_ID}`);
|
||||
expect(output).toContain('Spans: 3');
|
||||
expect(output).toContain('Session: session-1');
|
||||
expect(output).toContain('eas observe:session session-1');
|
||||
|
||||
const lines = output.split('\n');
|
||||
const rootLine = lines.find(
|
||||
line => line.includes('https://api.example.com') && line.includes('0ms')
|
||||
);
|
||||
expect(rootLine).toBeDefined();
|
||||
const indentedLines = lines.filter(
|
||||
line => /\s{2}GET|\s{2}POST/.test(line) && line.includes('ms')
|
||||
);
|
||||
expect(indentedLines.length).toBeGreaterThanOrEqual(1);
|
||||
expect(output).toContain('200ms');
|
||||
expect(output).toContain('500ms');
|
||||
expect(output).toContain('2.50s');
|
||||
expect(output).not.toContain('more spans');
|
||||
});
|
||||
|
||||
it('strips control characters from the device, span names, and URLs', () => {
|
||||
const span = makeSpan({
|
||||
name: 'GET\u001b[2J',
|
||||
deviceModel: 'iPhone\u0007 15',
|
||||
http: { ...makeSpan().http!, urlFull: 'https://api.example.com/\u001b[Husers' },
|
||||
});
|
||||
const output = buildObserveTraceTable(makeTrace([span]));
|
||||
expect(output).not.toContain('\u001b[2J');
|
||||
expect(output).not.toContain('\u001b[H');
|
||||
expect(output).not.toContain('\u0007');
|
||||
expect(output).toContain('Device: iPhone 15');
|
||||
expect(output).toContain('https://api.example.com/[Husers');
|
||||
});
|
||||
|
||||
it('notes truncation', () => {
|
||||
const output = buildObserveTraceTable(makeTrace([root], true));
|
||||
expect(output).toContain('Spans: 1 (truncated)');
|
||||
expect(output).toContain('more spans');
|
||||
});
|
||||
|
||||
it('handles a trace without spans', () => {
|
||||
expect(buildObserveTraceTable(makeTrace([]))).toContain('No spans found for this trace.');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveTraceJson, () => {
|
||||
it('keeps the tree order and the truncation flag', () => {
|
||||
const json = buildObserveTraceJson(makeTrace([childLater, childEarlier, root], true));
|
||||
expect(json.id).toBe(TRACE_ID);
|
||||
expect(json.isTruncated).toBe(true);
|
||||
expect(json.spans.map(s => s.id)).toEqual(['root', 'child-earlier', 'child-later']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AppObserveSpanKind, AppObserveSpanStatusCode } from '../../graphql/generated';
|
||||
import {
|
||||
allowedSpanKindFlagValues,
|
||||
allowedSpanStatusFlagValues,
|
||||
spanKindsFromFlag,
|
||||
spanStatusCodesFromFlag,
|
||||
} from '../spanFilters';
|
||||
|
||||
describe('span filter flags', () => {
|
||||
it('offers the lowercase enum values as flag options', () => {
|
||||
expect(allowedSpanKindFlagValues).toEqual(
|
||||
expect.arrayContaining([
|
||||
'client',
|
||||
'server',
|
||||
'internal',
|
||||
'producer',
|
||||
'consumer',
|
||||
'unspecified',
|
||||
])
|
||||
);
|
||||
expect(allowedSpanStatusFlagValues).toEqual(['error', 'ok', 'unset']);
|
||||
});
|
||||
|
||||
it('maps flag values back to the enums', () => {
|
||||
expect(spanKindsFromFlag(['client', 'server'])).toEqual([
|
||||
AppObserveSpanKind.Client,
|
||||
AppObserveSpanKind.Server,
|
||||
]);
|
||||
expect(spanStatusCodesFromFlag(['error'])).toEqual([AppObserveSpanStatusCode.Error]);
|
||||
});
|
||||
|
||||
it('returns undefined for no values', () => {
|
||||
expect(spanKindsFromFlag(undefined)).toBeUndefined();
|
||||
expect(spanKindsFromFlag([])).toBeUndefined();
|
||||
expect(spanStatusCodesFromFlag(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects unknown values', () => {
|
||||
expect(() => spanKindsFromFlag(['bogus'])).toThrow(/Unknown span kind/);
|
||||
expect(() => spanStatusCodesFromFlag(['bogus'])).toThrow(/Unknown span status/);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
AppObserveMetric,
|
||||
AppObserveMetricsListOrderByField,
|
||||
AppObserveOrderDirection,
|
||||
AppObserveSpan,
|
||||
AppObserveSpanListOrderByField,
|
||||
AppObserveUserEvent,
|
||||
AppObserveUserEventListOrderByField,
|
||||
} from '../graphql/generated';
|
||||
@@ -13,7 +15,7 @@ import { fetchObserveCustomEventsAsync } from './fetchCustomEvents';
|
||||
import { fetchObserveEventsAsync, resolveOrderBy } from './fetchEvents';
|
||||
|
||||
export interface SessionEventEntry {
|
||||
source: 'metric' | 'log';
|
||||
source: 'metric' | 'log' | 'span';
|
||||
id: string;
|
||||
timestamp: string;
|
||||
sessionId: string;
|
||||
@@ -36,6 +38,17 @@ export interface SessionEventEntry {
|
||||
severityNumber?: number | null;
|
||||
properties?: Array<{ key: string; value: string; type: string }>;
|
||||
environment?: string | null;
|
||||
// span-only fields
|
||||
traceId?: string;
|
||||
spanId?: string;
|
||||
parentSpanId?: string | null;
|
||||
durationMs?: number;
|
||||
statusCode?: string;
|
||||
httpMethod?: string | null;
|
||||
httpStatusCode?: number | null;
|
||||
httpErrorType?: string | null;
|
||||
urlHost?: string | null;
|
||||
urlFull?: string | null;
|
||||
}
|
||||
|
||||
function metricEventToEntry(event: AppObserveMetric): SessionEventEntry {
|
||||
@@ -107,6 +120,35 @@ function errorToEntry(event: AppObserveError): SessionEventEntry {
|
||||
};
|
||||
}
|
||||
|
||||
function spanToEntry(span: AppObserveSpan): SessionEventEntry {
|
||||
return {
|
||||
source: 'span',
|
||||
id: span.id,
|
||||
timestamp: span.startTimestamp,
|
||||
sessionId: span.sessionId ?? '',
|
||||
name: span.name,
|
||||
appVersion: span.appVersion,
|
||||
appBuildNumber: span.appBuildNumber,
|
||||
appUpdateId: span.appUpdateId ?? null,
|
||||
deviceModel: span.deviceModel,
|
||||
deviceOs: span.deviceOs,
|
||||
deviceOsVersion: span.deviceOsVersion,
|
||||
countryCode: span.countryCode ?? null,
|
||||
easClientId: span.easClientId,
|
||||
environment: span.environment ?? null,
|
||||
traceId: span.traceId,
|
||||
spanId: span.spanId,
|
||||
parentSpanId: span.parentSpanId ?? null,
|
||||
durationMs: span.durationMs,
|
||||
statusCode: span.statusCode,
|
||||
httpMethod: span.http?.method ?? null,
|
||||
httpStatusCode: span.http?.statusCode ?? null,
|
||||
httpErrorType: span.http?.errorType ?? null,
|
||||
urlHost: span.http?.urlHost ?? null,
|
||||
urlFull: span.http?.urlFull ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionLogToEntry(log: AppObserveSessionLog): SessionEventEntry {
|
||||
return log.__typename === 'AppObserveError' ? errorToEntry(log) : userEventToEntry(log);
|
||||
}
|
||||
@@ -133,6 +175,7 @@ export interface FetchSessionEventsResult {
|
||||
metadata: SessionMetadata | null;
|
||||
hasMoreMetricEvents: boolean;
|
||||
hasMoreLogEvents: boolean;
|
||||
hasMoreSpans: boolean;
|
||||
}
|
||||
|
||||
// A syntactically valid UUID that matches no real session, used only to trip
|
||||
@@ -160,9 +203,8 @@ export async function fetchObserveSessionEventsAsync(
|
||||
appId: string,
|
||||
options: FetchSessionEventsOptions
|
||||
): Promise<FetchSessionEventsResult> {
|
||||
const { metrics, logs, metricsPageInfo, logsPageInfo } = await ObserveQuery.sessionEventsAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
const { metrics, logs, spans, metricsPageInfo, logsPageInfo, spansPageInfo } =
|
||||
await ObserveQuery.sessionEventsAsync(graphqlClient, {
|
||||
appId,
|
||||
id: options.sessionId,
|
||||
first: options.limit,
|
||||
@@ -174,12 +216,17 @@ export async function fetchObserveSessionEventsAsync(
|
||||
field: AppObserveLogsOrderByField.Timestamp,
|
||||
direction: AppObserveOrderDirection.Asc,
|
||||
},
|
||||
}
|
||||
);
|
||||
spansOrderBy: {
|
||||
field: AppObserveSpanListOrderByField.StartTimestamp,
|
||||
direction: AppObserveOrderDirection.Asc,
|
||||
},
|
||||
});
|
||||
|
||||
const entries = [...metrics.map(metricEventToEntry), ...logs.map(sessionLogToEntry)].sort(
|
||||
(a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0)
|
||||
);
|
||||
const entries = [
|
||||
...metrics.map(metricEventToEntry),
|
||||
...logs.map(sessionLogToEntry),
|
||||
...spans.map(spanToEntry),
|
||||
].sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0));
|
||||
|
||||
let metadata: SessionMetadata | null = null;
|
||||
if (entries.length > 0) {
|
||||
@@ -202,6 +249,7 @@ export async function fetchObserveSessionEventsAsync(
|
||||
metadata,
|
||||
hasMoreMetricEvents: metricsPageInfo.hasNextPage,
|
||||
hasMoreLogEvents: logsPageInfo.hasNextPage,
|
||||
hasMoreSpans: spansPageInfo.hasNextPage,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppObserveOrderDirection,
|
||||
AppObservePlatform,
|
||||
AppObserveSpan,
|
||||
AppObserveSpanKind,
|
||||
AppObserveSpanListFilter,
|
||||
AppObserveSpanListOrderBy,
|
||||
AppObserveSpanListOrderByField,
|
||||
AppObserveSpanStatusCode,
|
||||
PageInfo,
|
||||
} from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
|
||||
export interface FetchSpansOptions {
|
||||
limit: number;
|
||||
after?: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platforms?: AppObservePlatform[];
|
||||
appVersion?: string;
|
||||
buildNumber?: string;
|
||||
updateId?: string;
|
||||
environment?: string;
|
||||
sessionId?: string;
|
||||
names?: string[];
|
||||
kinds?: AppObserveSpanKind[];
|
||||
statusCodes?: AppObserveSpanStatusCode[];
|
||||
hosts?: string[];
|
||||
httpStatusCodes?: number[];
|
||||
minDurationMs?: number;
|
||||
rootOnly?: boolean;
|
||||
}
|
||||
|
||||
export const NEWEST_SPANS_FIRST: AppObserveSpanListOrderBy = {
|
||||
field: AppObserveSpanListOrderByField.StartTimestamp,
|
||||
direction: AppObserveOrderDirection.Desc,
|
||||
};
|
||||
|
||||
export interface FetchSpansResult {
|
||||
spans: AppObserveSpan[];
|
||||
pageInfo: PageInfo;
|
||||
}
|
||||
|
||||
export async function fetchObserveSpansAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
options: FetchSpansOptions
|
||||
): Promise<FetchSpansResult> {
|
||||
const filter: AppObserveSpanListFilter = {
|
||||
startTime: options.startTime,
|
||||
endTime: options.endTime,
|
||||
...(options.platforms?.length && { platforms: options.platforms }),
|
||||
...(options.appVersion && { appVersion: options.appVersion }),
|
||||
...(options.buildNumber && { appBuildNumber: options.buildNumber }),
|
||||
...(options.updateId && { appUpdateId: options.updateId }),
|
||||
...(options.environment && { environment: options.environment }),
|
||||
...(options.sessionId && { sessionId: options.sessionId }),
|
||||
...(options.names?.length && { names: options.names }),
|
||||
...(options.kinds?.length && { kinds: options.kinds }),
|
||||
...(options.statusCodes?.length && { statusCodes: options.statusCodes }),
|
||||
...(options.hosts?.length && { hosts: options.hosts }),
|
||||
...(options.httpStatusCodes?.length && { httpStatusCodes: options.httpStatusCodes }),
|
||||
...(options.minDurationMs != null && { minDurationMs: options.minDurationMs }),
|
||||
...(options.rootOnly && { rootOnly: true }),
|
||||
};
|
||||
|
||||
return await ObserveQuery.spanListAsync(graphqlClient, {
|
||||
appId,
|
||||
filter,
|
||||
first: options.limit,
|
||||
orderBy: NEWEST_SPANS_FIRST,
|
||||
...(options.after && { after: options.after }),
|
||||
});
|
||||
}
|
||||
@@ -2,14 +2,17 @@ import chalk from 'chalk';
|
||||
|
||||
import { AppObserveMetric, AppObserveUserEvent } from '../graphql/generated';
|
||||
import { SessionEventEntry, SessionMetadata } from './fetchSessions';
|
||||
import { formatSpanDuration, formatSpanUrlPath } from './formatSpans';
|
||||
import { formatLogTimestamp } from './formatUtils';
|
||||
import { getMetricDisplayName } from './metricNames';
|
||||
import renderTextTable from '../utils/renderTextTable';
|
||||
import { sanitizeTerminalText } from '../utils/terminalText';
|
||||
|
||||
export interface BuildSessionEventsOptions {
|
||||
metadata?: SessionMetadata | null;
|
||||
hasMoreMetricEvents?: boolean;
|
||||
hasMoreLogEvents?: boolean;
|
||||
hasMoreSpans?: boolean;
|
||||
}
|
||||
|
||||
function formatEntryName(entry: SessionEventEntry): string {
|
||||
@@ -17,9 +20,30 @@ function formatEntryName(entry: SessionEventEntry): string {
|
||||
const name = getMetricDisplayName(entry.name);
|
||||
return entry.routeName ? `${name} · ${entry.routeName}` : name;
|
||||
}
|
||||
if (entry.source === 'span') {
|
||||
if (!entry.urlHost) {
|
||||
return sanitizeTerminalText(entry.name);
|
||||
}
|
||||
const path = entry.urlFull ? formatSpanUrlPath(entry.urlFull) : '';
|
||||
return sanitizeTerminalText(`${entry.name} ${entry.urlHost}${path}`);
|
||||
}
|
||||
return entry.name;
|
||||
}
|
||||
|
||||
function formatSpanEntryValue(entry: SessionEventEntry): string {
|
||||
return typeof entry.durationMs === 'number' ? formatSpanDuration(entry.durationMs) : '-';
|
||||
}
|
||||
|
||||
function formatSpanEntryProperties(entry: SessionEventEntry): string {
|
||||
const parts = [`status=${entry.statusCode ?? '-'}`];
|
||||
if (entry.httpStatusCode != null) {
|
||||
parts.push(`http=${entry.httpStatusCode}`);
|
||||
} else if (entry.httpErrorType) {
|
||||
parts.push(`error=${entry.httpErrorType}`);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function formatMetricEntryValue(entry: SessionEventEntry): string {
|
||||
if (typeof entry.value === 'number') {
|
||||
return `${entry.value.toFixed(2)}s`;
|
||||
@@ -81,13 +105,24 @@ export function buildObserveSessionEventsTable(
|
||||
const rows: string[][] = [];
|
||||
for (const entry of entries) {
|
||||
const offset = formatOffsetSeconds(startIso, entry.timestamp);
|
||||
const type = entry.source === 'metric' ? 'metric' : 'log';
|
||||
const type = entry.source;
|
||||
const name = formatEntryName(entry);
|
||||
const severity = formatEntrySeverity(entry);
|
||||
if (entry.source === 'metric') {
|
||||
rows.push([offset, type, name, formatMetricEntryValue(entry), '-', severity]);
|
||||
continue;
|
||||
}
|
||||
if (entry.source === 'span') {
|
||||
rows.push([
|
||||
offset,
|
||||
type,
|
||||
name,
|
||||
formatSpanEntryValue(entry),
|
||||
formatSpanEntryProperties(entry),
|
||||
severity,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
const propLines = primitivePropertyLines(entry);
|
||||
if (propLines.length === 0) {
|
||||
rows.push([offset, type, name, '-', '-', severity]);
|
||||
@@ -101,7 +136,7 @@ export function buildObserveSessionEventsTable(
|
||||
|
||||
lines.push(renderTextTable(headers, rows));
|
||||
|
||||
if (options?.hasMoreMetricEvents || options?.hasMoreLogEvents) {
|
||||
if (options?.hasMoreMetricEvents || options?.hasMoreLogEvents || options?.hasMoreSpans) {
|
||||
const sources: string[] = [];
|
||||
if (options.hasMoreMetricEvents) {
|
||||
sources.push('metric events');
|
||||
@@ -109,6 +144,9 @@ export function buildObserveSessionEventsTable(
|
||||
if (options.hasMoreLogEvents) {
|
||||
sources.push('log events');
|
||||
}
|
||||
if (options.hasMoreSpans) {
|
||||
sources.push('spans');
|
||||
}
|
||||
lines.push(
|
||||
'',
|
||||
chalk.yellow(
|
||||
@@ -126,6 +164,7 @@ export interface ObserveSessionEventsJson {
|
||||
entries: SessionEventEntry[];
|
||||
hasMoreMetricEvents: boolean;
|
||||
hasMoreLogEvents: boolean;
|
||||
hasMoreSpans: boolean;
|
||||
}
|
||||
|
||||
export function buildObserveSessionEventsJson(
|
||||
@@ -133,7 +172,8 @@ export function buildObserveSessionEventsJson(
|
||||
sessionId: string,
|
||||
metadata: SessionMetadata | null,
|
||||
hasMoreMetricEvents: boolean,
|
||||
hasMoreLogEvents: boolean
|
||||
hasMoreLogEvents: boolean,
|
||||
hasMoreSpans: boolean
|
||||
): ObserveSessionEventsJson {
|
||||
return {
|
||||
sessionId,
|
||||
@@ -141,6 +181,7 @@ export function buildObserveSessionEventsJson(
|
||||
entries,
|
||||
hasMoreMetricEvents,
|
||||
hasMoreLogEvents,
|
||||
hasMoreSpans,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
AppObserveSpan,
|
||||
AppObserveSpanName,
|
||||
AppObserveTrace,
|
||||
PageInfo,
|
||||
} from '../graphql/generated';
|
||||
import renderTextTable from '../utils/renderTextTable';
|
||||
import { sanitizeTerminalText } from '../utils/terminalText';
|
||||
import { buildTimeRangeDescription, formatLogTimestamp } from './formatUtils';
|
||||
|
||||
export function formatSpanDuration(durationMs: number): string {
|
||||
const wholeMs = Math.round(durationMs);
|
||||
if (wholeMs >= 1000) {
|
||||
return `${(durationMs / 1000).toFixed(2)}s`;
|
||||
}
|
||||
return `${wholeMs}ms`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The HTTP response status code, or the client-side error type when the
|
||||
* request got no response, or "-" for a span that is not an HTTP request.
|
||||
*/
|
||||
export function formatSpanHttpStatus(span: AppObserveSpan): string {
|
||||
if (span.http?.statusCode != null) {
|
||||
return String(span.http.statusCode);
|
||||
}
|
||||
return span.http?.errorType ?? '-';
|
||||
}
|
||||
|
||||
/** The path and query of a full URL; the URL itself when it does not parse. */
|
||||
export function formatSpanUrlPath(urlFull: string): string {
|
||||
try {
|
||||
const url = new URL(urlFull);
|
||||
return `${url.pathname}${url.search}`;
|
||||
} catch {
|
||||
return urlFull;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line title for a span: the HTTP method, host, and path for a request
|
||||
* span (`GET api.example.com/users`), else the span name.
|
||||
*/
|
||||
export function formatSpanTitle(span: AppObserveSpan): string {
|
||||
const host = span.http?.urlHost;
|
||||
if (!host) {
|
||||
return span.name;
|
||||
}
|
||||
const path = span.http?.urlFull ? formatSpanUrlPath(span.http.urlFull) : '';
|
||||
return `${span.name} ${host}${path}`;
|
||||
}
|
||||
|
||||
export interface ObserveSpanHttpJson {
|
||||
method: string | null;
|
||||
statusCode: number | null;
|
||||
urlHost: string | null;
|
||||
urlFull: string | null;
|
||||
protocolVersion: string | null;
|
||||
requestSize: number | null;
|
||||
responseSize: number | null;
|
||||
errorType: string | null;
|
||||
}
|
||||
|
||||
export interface ObserveSpanJson {
|
||||
id: string;
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId: string | null;
|
||||
name: string;
|
||||
kind: string;
|
||||
startTimestamp: string;
|
||||
endTimestamp: string;
|
||||
durationMs: number;
|
||||
statusCode: string;
|
||||
statusMessage: string | null;
|
||||
sessionId: string | null;
|
||||
http: ObserveSpanHttpJson | null;
|
||||
attributes: Array<{ key: string; value: string; type: string }>;
|
||||
events: Array<{ name: string; timestamp: string; attributes: unknown }>;
|
||||
droppedAttributesCount: number;
|
||||
droppedEventsCount: number;
|
||||
droppedLinksCount: number;
|
||||
ingestedAt: string | null;
|
||||
appVersion: string;
|
||||
appBuildNumber: string;
|
||||
appUpdateId: string | null;
|
||||
appEasBuildId: string | null;
|
||||
expoSdkVersion: string | null;
|
||||
reactNativeVersion: string | null;
|
||||
deviceModel: string;
|
||||
deviceOs: string;
|
||||
deviceOsVersion: string;
|
||||
environment: string | null;
|
||||
easClientId: string;
|
||||
countryCode: string | null;
|
||||
}
|
||||
|
||||
export function buildObserveSpanJson(span: AppObserveSpan): ObserveSpanJson {
|
||||
return {
|
||||
id: span.id,
|
||||
traceId: span.traceId,
|
||||
spanId: span.spanId,
|
||||
parentSpanId: span.parentSpanId ?? null,
|
||||
name: span.name,
|
||||
kind: span.kind,
|
||||
startTimestamp: span.startTimestamp,
|
||||
endTimestamp: span.endTimestamp,
|
||||
durationMs: span.durationMs,
|
||||
statusCode: span.statusCode,
|
||||
statusMessage: span.statusMessage ?? null,
|
||||
sessionId: span.sessionId ?? null,
|
||||
http: span.http
|
||||
? {
|
||||
method: span.http.method ?? null,
|
||||
statusCode: span.http.statusCode ?? null,
|
||||
urlHost: span.http.urlHost ?? null,
|
||||
urlFull: span.http.urlFull ?? null,
|
||||
protocolVersion: span.http.protocolVersion ?? null,
|
||||
requestSize: span.http.requestSize ?? null,
|
||||
responseSize: span.http.responseSize ?? null,
|
||||
errorType: span.http.errorType ?? null,
|
||||
}
|
||||
: null,
|
||||
attributes: span.attributes.map(a => ({ key: a.key, value: a.value, type: a.type })),
|
||||
events: span.events.map(e => ({
|
||||
name: e.name,
|
||||
timestamp: e.timestamp,
|
||||
attributes: e.attributes,
|
||||
})),
|
||||
droppedAttributesCount: span.droppedAttributesCount,
|
||||
droppedEventsCount: span.droppedEventsCount,
|
||||
droppedLinksCount: span.droppedLinksCount,
|
||||
ingestedAt: span.ingestedAt ?? null,
|
||||
appVersion: span.appVersion,
|
||||
appBuildNumber: span.appBuildNumber,
|
||||
appUpdateId: span.appUpdateId ?? null,
|
||||
appEasBuildId: span.appEasBuildId ?? null,
|
||||
expoSdkVersion: span.expoSdkVersion ?? null,
|
||||
reactNativeVersion: span.reactNativeVersion ?? null,
|
||||
deviceModel: span.deviceModel,
|
||||
deviceOs: span.deviceOs,
|
||||
deviceOsVersion: span.deviceOsVersion,
|
||||
environment: span.environment ?? null,
|
||||
easClientId: span.easClientId,
|
||||
countryCode: span.countryCode ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuildSpanNamesTableOptions {
|
||||
daysBack?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
isTruncated?: boolean;
|
||||
}
|
||||
|
||||
export function buildObserveSpanNamesTable(
|
||||
names: AppObserveSpanName[],
|
||||
options?: BuildSpanNamesTableOptions
|
||||
): string {
|
||||
if (names.length === 0) {
|
||||
return chalk.yellow('No spans found.');
|
||||
}
|
||||
|
||||
const headers = ['Span Name', 'Kind', 'Count'];
|
||||
const rows: string[][] = names.map(n => [
|
||||
sanitizeTerminalText(n.name),
|
||||
n.kind,
|
||||
n.count.toLocaleString(),
|
||||
]);
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (options) {
|
||||
const timeDesc = buildTimeRangeDescription(options);
|
||||
lines.push(chalk.bold(`Span names ${timeDesc}`.trim()), '');
|
||||
}
|
||||
|
||||
lines.push(renderTextTable(headers, rows));
|
||||
|
||||
if (options?.isTruncated) {
|
||||
lines.push('', chalk.yellow('Result is truncated; not all span names are shown.'));
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'Pass --all-spans or a span filter such as --name, --host, or --status to list individual spans.'
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildObserveSpanNamesJson(
|
||||
names: AppObserveSpanName[],
|
||||
isTruncated: boolean
|
||||
): { names: Array<{ name: string; kind: string; count: number }>; isTruncated: boolean } {
|
||||
return {
|
||||
names: names.map(n => ({ name: n.name, kind: n.kind, count: n.count })),
|
||||
isTruncated,
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuildSpansTableOptions {
|
||||
daysBack?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export function buildObserveSpansTable(
|
||||
spans: AppObserveSpan[],
|
||||
pageInfo: PageInfo,
|
||||
options?: BuildSpansTableOptions
|
||||
): string {
|
||||
if (spans.length === 0) {
|
||||
return chalk.yellow('No spans found.');
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'Timestamp',
|
||||
'Name',
|
||||
'Duration',
|
||||
'Status',
|
||||
'HTTP',
|
||||
'Host',
|
||||
'App Version',
|
||||
'Platform',
|
||||
'Trace ID',
|
||||
];
|
||||
|
||||
const rows: string[][] = spans.map(span => [
|
||||
formatLogTimestamp(span.startTimestamp),
|
||||
sanitizeTerminalText(span.name),
|
||||
formatSpanDuration(span.durationMs),
|
||||
span.statusCode,
|
||||
formatSpanHttpStatus(span),
|
||||
sanitizeTerminalText(span.http?.urlHost ?? '-'),
|
||||
`${span.appVersion} (${span.appBuildNumber})`,
|
||||
`${span.deviceOs} ${span.deviceOsVersion}`,
|
||||
span.traceId,
|
||||
]);
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
if (options) {
|
||||
const subject = options.sessionId ? `Spans in session ${options.sessionId}` : 'Spans';
|
||||
const timeDesc = options.sessionId ? '' : buildTimeRangeDescription(options);
|
||||
lines.push(chalk.bold(`${subject} ${timeDesc}`.trim()), '');
|
||||
}
|
||||
|
||||
lines.push(renderTextTable(headers, rows));
|
||||
|
||||
lines.push('', 'Show a whole trace: eas observe:trace <Trace ID>');
|
||||
|
||||
if (pageInfo.hasNextPage && pageInfo.endCursor) {
|
||||
lines.push('', `Next page: --after ${pageInfo.endCursor}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildObserveSpansJson(
|
||||
spans: AppObserveSpan[],
|
||||
pageInfo: PageInfo
|
||||
): {
|
||||
spans: ObserveSpanJson[];
|
||||
pageInfo: { hasNextPage: boolean; endCursor: string | null };
|
||||
} {
|
||||
return {
|
||||
spans: spans.map(buildObserveSpanJson),
|
||||
pageInfo: {
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface TraceTreeRow {
|
||||
span: AppObserveSpan;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
function compareByStart(a: AppObserveSpan, b: AppObserveSpan): number {
|
||||
if (a.startTimestamp !== b.startTimestamp) {
|
||||
return a.startTimestamp < b.startTimestamp ? -1 : 1;
|
||||
}
|
||||
return a.spanId < b.spanId ? -1 : a.spanId > b.spanId ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders a trace's spans depth-first, children under their parent, siblings
|
||||
* oldest first. A span whose parent is not in the trace is shown as a root.
|
||||
*/
|
||||
export function orderTraceSpans(spans: AppObserveSpan[]): TraceTreeRow[] {
|
||||
const spanIds = new Set(spans.map(span => span.spanId));
|
||||
const childrenByParent = new Map<string | null, AppObserveSpan[]>();
|
||||
for (const span of spans) {
|
||||
const parentKey =
|
||||
span.parentSpanId && spanIds.has(span.parentSpanId) ? span.parentSpanId : null;
|
||||
const siblings = childrenByParent.get(parentKey) ?? [];
|
||||
siblings.push(span);
|
||||
childrenByParent.set(parentKey, siblings);
|
||||
}
|
||||
|
||||
const rows: TraceTreeRow[] = [];
|
||||
const visit = (parentKey: string | null, depth: number): void => {
|
||||
const children = childrenByParent.get(parentKey) ?? [];
|
||||
for (const span of [...children].sort(compareByStart)) {
|
||||
rows.push({ span, depth });
|
||||
visit(span.spanId, depth + 1);
|
||||
}
|
||||
};
|
||||
visit(null, 0);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function buildObserveTraceTable(trace: AppObserveTrace): string {
|
||||
const rows = orderTraceSpans(trace.spans);
|
||||
const lines: string[] = [chalk.bold(`Trace ${trace.id}`), ''];
|
||||
|
||||
if (rows.length === 0) {
|
||||
lines.push(chalk.yellow('No spans found for this trace.'));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const sessionId = trace.spans.find(span => span.sessionId)?.sessionId;
|
||||
const first = rows[0].span;
|
||||
lines.push(
|
||||
`Started: ${formatLogTimestamp(first.startTimestamp)}`,
|
||||
`Spans: ${rows.length}${trace.isTruncated ? ' (truncated)' : ''}`,
|
||||
`App version: ${first.appVersion} (${first.appBuildNumber})`,
|
||||
`Device: ${sanitizeTerminalText(first.deviceModel)} · ${first.deviceOs} ${first.deviceOsVersion}`,
|
||||
`Session: ${sessionId ?? '-'}`,
|
||||
''
|
||||
);
|
||||
|
||||
const traceStartMs = Math.min(
|
||||
...trace.spans.map(span => new Date(span.startTimestamp).getTime())
|
||||
);
|
||||
const headers = ['Offset', 'Duration', 'Name', 'Status', 'HTTP', 'URL'];
|
||||
const tableRows: string[][] = rows.map(({ span, depth }) => [
|
||||
formatSpanDuration(new Date(span.startTimestamp).getTime() - traceStartMs),
|
||||
formatSpanDuration(span.durationMs),
|
||||
`${' '.repeat(depth)}${sanitizeTerminalText(span.name)}`,
|
||||
span.statusCode,
|
||||
formatSpanHttpStatus(span),
|
||||
sanitizeTerminalText(span.http?.urlFull ?? '-'),
|
||||
]);
|
||||
lines.push(renderTextTable(headers, tableRows));
|
||||
|
||||
if (trace.isTruncated) {
|
||||
lines.push('', chalk.yellow('This trace has more spans than are shown.'));
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
lines.push('', `Show the session timeline: eas observe:session ${sessionId}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildObserveTraceJson(trace: AppObserveTrace): {
|
||||
id: string;
|
||||
isTruncated: boolean;
|
||||
spans: ObserveSpanJson[];
|
||||
} {
|
||||
return {
|
||||
id: trace.id,
|
||||
isTruncated: trace.isTruncated,
|
||||
spans: orderTraceSpans(trace.spans).map(({ span }) => buildObserveSpanJson(span)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { AppObserveSpanKind, AppObserveSpanStatusCode } from '../graphql/generated';
|
||||
|
||||
export const allowedSpanKindFlagValues = Object.values(AppObserveSpanKind).map(kind =>
|
||||
kind.toLowerCase()
|
||||
);
|
||||
|
||||
export const allowedSpanStatusFlagValues = Object.values(AppObserveSpanStatusCode).map(code =>
|
||||
code.toLowerCase()
|
||||
);
|
||||
|
||||
export function spanKindsFromFlag(values: string[] | undefined): AppObserveSpanKind[] | undefined {
|
||||
if (!values?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return values.map(value => {
|
||||
const kind = Object.values(AppObserveSpanKind).find(k => k.toLowerCase() === value);
|
||||
if (!kind) {
|
||||
throw new Error(`Unknown span kind flag value: "${value}"`);
|
||||
}
|
||||
return kind;
|
||||
});
|
||||
}
|
||||
|
||||
export function spanStatusCodesFromFlag(
|
||||
values: string[] | undefined
|
||||
): AppObserveSpanStatusCode[] | undefined {
|
||||
if (!values?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return values.map(value => {
|
||||
const code = Object.values(AppObserveSpanStatusCode).find(c => c.toLowerCase() === value);
|
||||
if (!code) {
|
||||
throw new Error(`Unknown span status flag value: "${value}"`);
|
||||
}
|
||||
return code;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-json
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "27.1.0",
|
||||
"version": "27.2.0",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/eas-build-job": "27.1.0",
|
||||
"@expo/eas-build-job": "27.2.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-cli-local-build-plugin
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "27.1.0",
|
||||
"version": "27.2.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,12 +26,12 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "27.1.0",
|
||||
"@expo/build-tools": "27.2.0",
|
||||
"@expo/bunyan": "^4.0.1",
|
||||
"@expo/eas-build-job": "27.1.0",
|
||||
"@expo/logger": "27.1.0",
|
||||
"@expo/eas-build-job": "27.2.0",
|
||||
"@expo/logger": "27.2.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "27.1.0",
|
||||
"@expo/turtle-spawn": "27.2.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/logger
|
||||
|
||||
## 27.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 27.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "27.1.0",
|
||||
"version": "27.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# @expo/simulator-launcher
|
||||
|
||||
## 27.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Accept a comma-separated list of warm Simulators in `EXPO_SIMULATOR_DEVICE`; the first is the default. One serve-sim serves every listed device on one port. After the default device is ready, warmup preinstalls the latest stable Expo Go from one download and tries to disable apsd on the other devices, until a job claims one. A claim can select any listed device and reports its preinstalled Expo Go. ([#4764](https://github.com/expo/eas-cli/pull/4764) by [@gwdp](https://github.com/gwdp))
|
||||
- Add `@expo/simulator-launcher`, which warms an iOS Simulator at VM boot and hands it to one EAS job (`start`, `claim`, `prepare-tools`, `stop`). ([#4722](https://github.com/expo/eas-cli/pull/4722) by [@gwdp](https://github.com/gwdp))
|
||||
@@ -0,0 +1,80 @@
|
||||
# @expo/simulator-launcher
|
||||
|
||||
Warm iOS Simulators before an EAS job arrives. Requires macOS.
|
||||
The image installs vm-tools and writes its startup script at
|
||||
`/usr/local/share/vm-tools/startup.sh`:
|
||||
|
||||
```sh
|
||||
export EXPO_SIMULATOR_STARTUP=1
|
||||
export EXPO_SIMULATOR_DEVICE="iPhone Duo,iPhone 18"
|
||||
exec bunx @expo/simulator-launcher@latest start
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- `start`: run one orphan serve-sim for the configured devices. Its default device becomes ready
|
||||
first; additional devices warm in the background. Argent and agent-device prestart on the default.
|
||||
- `claim ENGINE [DEVICE]`: reserve one job and return its selected device plus the existing server's
|
||||
identity, port and credentials. Engines: `serve-sim`, `agent-device`, `argent`.
|
||||
No DEVICE means the first configured device. Claim does not boot the selected device or wait for
|
||||
its tool. Build-tools privately adopts that device on the existing serve-sim port.
|
||||
- `prepare-tools`: continue the consumed claim after adoption. Keep global Argent, reuse the default
|
||||
agent-device runner or prepare a runner for the selected device.
|
||||
- `stop`: permanently end warmup for this boot and wait for all owned resources to be cleaned up.
|
||||
|
||||
```sh
|
||||
# Build-tools reads LAUNCHER_VERSION from the version recorded by start.
|
||||
bun x --no-install "@expo/simulator-launcher@$LAUNCHER_VERSION" claim serve-sim
|
||||
# After private serve-sim adoption confirms the selected device and native ACK:
|
||||
bun x --no-install "@expo/simulator-launcher@$LAUNCHER_VERSION" prepare-tools
|
||||
bun x --no-install "@expo/simulator-launcher@$LAUNCHER_VERSION" stop
|
||||
```
|
||||
|
||||
There is no copied launcher bundle. Build-tools uses the recorded package version for claim/stop;
|
||||
Warmup primes Bun’s exact-version cache before ready, with two attempts of up to 30 seconds. Its claim/stop calls use `--no-install`, retrying
|
||||
once with the registry only when Bun cannot find that cached binary. Priming failures warn.
|
||||
Before start has run,
|
||||
stop uses the normal package-manager path and still disables later warmup for this boot.
|
||||
|
||||
This flow requires the serve-sim release supporting port-based device adoption.
|
||||
|
||||
One durable state file, `/tmp/expo-simulator-startup.json`, holds the lifecycle, launcher version,
|
||||
selected device, server credentials, claim and native identity. It is independent of HOME/TMPDIR. A short boot-scoped lock
|
||||
serializes updates; an abandoned lock fails closed. This private format requires a fresh VM.
|
||||
|
||||
## Environment
|
||||
|
||||
- `EXPO_SIMULATOR_STARTUP=1` enables warmup.
|
||||
- `EXPO_SIMULATOR_PREINSTALL_EXPO_GO=0` turns off the Expo Go preinstall.
|
||||
- `EXPO_SIMULATOR_DEVICE` is a comma-separated list of exact device names or UDIDs. Entries are
|
||||
trimmed and must each identify one available device. The first is the default. Unset keeps
|
||||
the shortest available iPhone name, as does an empty or blank value. Empty list entries, missing,
|
||||
ambiguous or duplicate devices fail clearly.
|
||||
- `SERVE_SIM_VERSION`, `AGENT_DEVICE_VERSION`, `ARGENT_VERSION`: each defaults to `latest`.
|
||||
- `AGENT_DEVICE_STATE_DIR`, `ARGENT_EVENT_LOG`: tool state/output locations.
|
||||
- Agent-device idle timers default to off. Override with `AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS`
|
||||
and `AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS` (milliseconds).
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Ready means the default device booted and serve-sim answers. The apsd step and the Expo Go
|
||||
preinstall run in the background on every listed device. A claim does not wait for them; stop
|
||||
joins them.
|
||||
The second device may not have started yet. Its startup failure leaves the default available.
|
||||
Claim reserves a device; it does not report that device ready. Private adoption must confirm
|
||||
the same server PID/port, selected-device readiness and native video/capture before tool setup
|
||||
and app actions. Job-time version and authentication checks remain authoritative.
|
||||
- Agent-device prestarts on the default device only. A secondary claim prepares its runner after
|
||||
adoption and old-runner cleanup finish. One device-independent Argent server is reused for the claimed device.
|
||||
- Warmup downloads/extracts the latest stable Expo Go once and reuses it for the second device.
|
||||
Each initial device gets a preinstall attempt; ordinary failures warn. Claim prevents new
|
||||
preinstalls. `prepare-tools` waits for an install still running on the claimed device and
|
||||
reports its version, because the job installs its app next. Installs on other devices continue;
|
||||
adoption may shut such a device down, and its install then fails with a warning. Terminal stop
|
||||
drains them and the download cleanup. Job app installs remain
|
||||
authoritative. Replacements do not preinstall again. The VM image provides ffmpeg.
|
||||
- One start attempt and one accepted claim per boot. Failed cleanup never permits another session.
|
||||
Stop's caller timeout is not a global cleanup deadline or proof that teardown completed.
|
||||
A build-tools claim timeout uses normal startup only after confirmed terminal cleanup.
|
||||
|
||||
Claim and tool JSON include credentials; capture them rather than streaming them to job logs.
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
require('../dist/index').runCliAsync();
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
import baseConfig from '../../jest/jest.shared.config';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
...baseConfig,
|
||||
rootDir: 'src',
|
||||
testMatch: ['**/__tests__/*.test.ts'],
|
||||
setupFiles: ['<rootDir>/__tests__/fixturePids.ts'],
|
||||
clearMocks: true,
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@expo/simulator-launcher",
|
||||
"version": "27.2.0",
|
||||
"description": "Launch and claim Simulator sessions on EAS workers",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/simulator-launcher"
|
||||
},
|
||||
"bin": "bin/simulator-launcher",
|
||||
"files": [
|
||||
"bin",
|
||||
"dist/index.js"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "jest --config jest.config.ts",
|
||||
"build": "ncc build src/main.ts -o dist --no-cache --no-source-map-register",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepack": "yarn build",
|
||||
"rebuild": "yarn build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "22.20.5",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.4.12",
|
||||
"typescript": "^6.0.3",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import { createArgentTool } from '../utils/argent';
|
||||
import {
|
||||
commands,
|
||||
isProcessOwnedByServiceAsync,
|
||||
stopProcessAndDescendantsAsync,
|
||||
} from '../utils/processes';
|
||||
import { type WarmTool } from '../utils/tools';
|
||||
import { deferred, until } from './helpers';
|
||||
|
||||
jest.mock('node:fs/promises');
|
||||
jest.mock('../utils/processes');
|
||||
let child: ChildProcess;
|
||||
let result: ReturnType<typeof deferred<string>>;
|
||||
let controller: AbortController;
|
||||
let onFailure: jest.Mock;
|
||||
let tool: WarmTool;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
child = Object.assign(new EventEmitter(), {
|
||||
pid: 90001,
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
}) as ChildProcess;
|
||||
result = deferred<string>();
|
||||
void result.promise.catch(() => {});
|
||||
controller = new AbortController();
|
||||
onFailure = jest.fn();
|
||||
// The old factory ignores this callback; its start also resolves without readiness.
|
||||
const createTool: (onFailure: (error: unknown) => void) => WarmTool = createArgentTool;
|
||||
tool = createTool(onFailure);
|
||||
jest.mocked(commands.start).mockReturnValue({ child, resultPromise: result.promise });
|
||||
jest.mocked(isProcessOwnedByServiceAsync).mockResolvedValue(true);
|
||||
jest.mocked(fs.readdir).mockResolvedValue(['tool-server.json'] as never);
|
||||
jest
|
||||
.mocked(fs.readFile)
|
||||
.mockResolvedValue(JSON.stringify({ pid: 90001, port: 3201, token: 'fixture-token' }));
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify({ tools: [] })));
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockResolvedValue();
|
||||
});
|
||||
afterEach(() => {
|
||||
controller.abort();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not report ready until its owned server answers the authenticated tools request', async () => {
|
||||
const response = deferred<Response>();
|
||||
jest.mocked(fetch).mockReturnValue(response.promise);
|
||||
let ready = false;
|
||||
const started = tool.startAsync('PHONE', controller.signal).then(() => {
|
||||
ready = true;
|
||||
});
|
||||
await until(() => jest.mocked(fetch).mock.calls.length > 0 || ready);
|
||||
expect(ready).toBe(false);
|
||||
response.resolve(new Response(JSON.stringify({ tools: [] })));
|
||||
await started;
|
||||
expect(commands.start).toHaveBeenCalledWith('bunx', expect.arrayContaining(['server', 'start']), {
|
||||
detached: true,
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith('http://127.0.0.1:3201/tools', {
|
||||
headers: { Authorization: 'Bearer fixture-token' },
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an exit during the readiness request, even before the wrapper finishes closing', async () => {
|
||||
const response = deferred<Response>();
|
||||
jest.mocked(fetch).mockReturnValue(response.promise);
|
||||
const started = tool.startAsync('PHONE', controller.signal);
|
||||
await until(() => jest.mocked(fetch).mock.calls.length > 0);
|
||||
Object.assign(child, { exitCode: 1 });
|
||||
child.emit('exit', 1, null);
|
||||
response.resolve(new Response(JSON.stringify({ tools: [] })));
|
||||
await expect(started).rejects.toThrow('Argent exited before its server became ready');
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('observes an unexpected exit after readiness exactly once', async () => {
|
||||
await tool.startAsync('PHONE', controller.signal);
|
||||
child.emit('exit', 1, null);
|
||||
result.reject(new Error('wrapper failed'));
|
||||
await Promise.resolve();
|
||||
expect(onFailure).toHaveBeenCalledTimes(1);
|
||||
expect(onFailure.mock.calls[0][0].message).toContain('Argent server exited unexpectedly');
|
||||
});
|
||||
|
||||
it('ignores a foreign state file without sending its token to the local port', async () => {
|
||||
jest.mocked(isProcessOwnedByServiceAsync).mockResolvedValue(false);
|
||||
const started = tool.startAsync('PHONE', controller.signal).catch(error => error);
|
||||
await until(() => jest.mocked(isProcessOwnedByServiceAsync).mock.calls.length > 0);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
controller.abort(new Error('cancelled'));
|
||||
expect((await started).code).toBe('ABORT_ERR');
|
||||
});
|
||||
|
||||
it('retries an invalid tools response and does not notify for intentional stop', async () => {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new Response('{}'))
|
||||
.mockImplementation(async () => new Response(JSON.stringify({ tools: [] })));
|
||||
await tool.startAsync('PHONE', controller.signal);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async () => {
|
||||
child.emit('exit', 0, null);
|
||||
});
|
||||
await tool.stopAsync();
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not notify for cancelled tool teardown', async () => {
|
||||
await tool.startAsync('PHONE', controller.signal);
|
||||
controller.abort();
|
||||
child.emit('exit', 0, null);
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never publishes ready when cancellation arrives during the tools request', async () => {
|
||||
const response = deferred<Response>();
|
||||
jest.mocked(fetch).mockReturnValue(response.promise);
|
||||
const started = tool.startAsync('PHONE', controller.signal);
|
||||
await until(() => jest.mocked(fetch).mock.calls.length > 0);
|
||||
controller.abort(new Error('cancelled'));
|
||||
response.resolve(new Response(JSON.stringify({ tools: [] })));
|
||||
await expect(started).rejects.toThrow('cancelled');
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bounds readiness when the server never becomes available', async () => {
|
||||
const now = Date.now();
|
||||
jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValueOnce(now)
|
||||
.mockReturnValue(now + 120_000);
|
||||
await expect(tool.startAsync('PHONE', controller.signal)).rejects.toThrow(
|
||||
'Timed out waiting for the Argent server'
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries missing or incomplete state and transient HTTP failures before becoming ready', async () => {
|
||||
jest
|
||||
.mocked(fs.readdir)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('state directory not written yet'), { code: 'ENOENT' })
|
||||
)
|
||||
.mockResolvedValue(['events.jsonl', 'tool-server-old.json', 'tool-server.json'] as never);
|
||||
jest
|
||||
.mocked(fs.readFile)
|
||||
.mockRejectedValueOnce(new Error('state disappeared during startup'))
|
||||
.mockResolvedValue(JSON.stringify({ pid: 90001, port: 3201, token: 'fixture-token' }));
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(new Error('connection refused'))
|
||||
.mockResolvedValueOnce(new Response('unauthorized', { status: 401 }))
|
||||
.mockImplementation(async () => new Response(JSON.stringify({ tools: [] })));
|
||||
await tool.startAsync('PHONE', controller.signal);
|
||||
expect(fs.readdir).toHaveBeenCalledTimes(3);
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a successful wrapper exit while no state directory is available', async () => {
|
||||
jest.mocked(fs.readdir).mockImplementation(async () => {
|
||||
result.resolve('');
|
||||
throw new Error('state directory unavailable');
|
||||
});
|
||||
await expect(tool.startAsync('PHONE', controller.signal)).rejects.toThrow(
|
||||
'Argent exited before its server became ready'
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an exit during the final ownership check before publishing readiness', async () => {
|
||||
const owned = deferred<boolean>();
|
||||
jest
|
||||
.mocked(isProcessOwnedByServiceAsync)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockReturnValue(owned.promise);
|
||||
const started = tool.startAsync('PHONE', controller.signal);
|
||||
await until(() => jest.mocked(isProcessOwnedByServiceAsync).mock.calls.length === 2);
|
||||
child.emit('error', new Error('server failed during validation'));
|
||||
owned.resolve(true);
|
||||
await expect(started).rejects.toThrow('Argent exited before its server became ready');
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { commands } from '../utils/processes';
|
||||
import { startOwnedWrapperAsync } from './fixtures/ownedWrapper';
|
||||
|
||||
it('an early claim survives unused-tool wrapper exit while the selected server and Simulator stay owned', async () => {
|
||||
const fixture = await startOwnedWrapperAsync();
|
||||
const environment = { ...process.env };
|
||||
let session:
|
||||
| ReturnType<typeof import('../commands/start').createWarmSimulatorSession>
|
||||
| undefined;
|
||||
try {
|
||||
const { createWarmSimulatorSession } = await import('../commands/start');
|
||||
const simulator = await import('../utils/simulator');
|
||||
const serveSim = await import('../utils/serveSim');
|
||||
const processes = await import('../utils/processes');
|
||||
const agentDevice = await import('../utils/agentDevice');
|
||||
const argent = await import('../utils/argent');
|
||||
const expoGo = await import('../utils/expoGo');
|
||||
const controller = new AbortController();
|
||||
const running = commands.runAsync.bind(commands);
|
||||
process.env.EXPO_SIMULATOR_DEVICE = 'DUO';
|
||||
process.env.AGENT_DEVICE_STATE_DIR = '/owned-wrapper-fixture/agent';
|
||||
process.env.ARGENT_EVENT_LOG = '/owned-wrapper-fixture/argent-events';
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation((file, args, signal) =>
|
||||
file === 'bun' || (file === 'ps' && args[1] === String(process.pid))
|
||||
? Promise.resolve('controller-start')
|
||||
: running(file, args, signal)
|
||||
);
|
||||
jest
|
||||
.spyOn(simulator, 'listAvailableSimulatorsAsync')
|
||||
.mockResolvedValue([{ udid: 'DUO', name: 'iPhone Duo' }]);
|
||||
jest.spyOn(simulator, 'waitForSimulatorBootAsync').mockResolvedValue();
|
||||
jest.spyOn(simulator, 'disableApsdAsync').mockResolvedValue();
|
||||
const shutdown = jest.spyOn(simulator, 'shutdownSimulatorAsync').mockResolvedValue();
|
||||
const stopServer = jest.spyOn(processes, 'stopProcessAndDescendantsAsync').mockResolvedValue();
|
||||
jest.spyOn(agentDevice, 'createAgentDeviceTool').mockReturnValue({
|
||||
name: 'agent-device',
|
||||
startAsync: async () => {},
|
||||
stopAsync: fixture.stopAsync,
|
||||
});
|
||||
jest
|
||||
.spyOn(argent, 'createArgentTool')
|
||||
.mockReturnValue({ name: 'argent', startAsync: async () => {}, stopAsync: async () => {} });
|
||||
jest.spyOn(expoGo, 'startExpoGoPreinstall').mockReturnValue({
|
||||
preinstallOnDeviceAsync: async () => {},
|
||||
claimAsync: async () => undefined,
|
||||
stopAsync: async () => {},
|
||||
});
|
||||
jest.spyOn(serveSim, 'startServeSim').mockReturnValue({
|
||||
child: { pid: 90001 } as import('node:child_process').ChildProcess,
|
||||
resultPromise: new Promise(() => {}),
|
||||
});
|
||||
jest.spyOn(serveSim, 'waitForServeSimReadyAsync').mockResolvedValue({
|
||||
startupDevice: 'DUO',
|
||||
pid: 90001,
|
||||
nativeStartTime: 'fixture-start',
|
||||
port: 3200,
|
||||
url: 'http://127.0.0.1:3200',
|
||||
token: 'fixture-token',
|
||||
});
|
||||
const onFailure = jest.fn(error => controller.abort(error));
|
||||
session = createWarmSimulatorSession({
|
||||
signal: controller.signal,
|
||||
throwIfStoppingAsync: async () => controller.signal.throwIfAborted(),
|
||||
onPreparationFailure: onFailure,
|
||||
});
|
||||
await session.prepareAsync();
|
||||
expect(await session.claimAsync({ engine: 'serve-sim' })).toMatchObject({
|
||||
device: { udid: 'DUO' },
|
||||
});
|
||||
await session.prepareToolsAsync();
|
||||
await fixture.stopAsync();
|
||||
expect(fixture.job.child.exitCode).toBe(23);
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
expect(stopServer).not.toHaveBeenCalled();
|
||||
expect(shutdown).not.toHaveBeenCalled();
|
||||
await session.stopAsync();
|
||||
expect(shutdown).toHaveBeenCalledWith('DUO');
|
||||
} finally {
|
||||
await session?.stopAsync().catch(() => {});
|
||||
jest.restoreAllMocks();
|
||||
process.env = environment;
|
||||
await fixture.cleanupAsync();
|
||||
}
|
||||
}, 10_000);
|
||||
@@ -0,0 +1,636 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { startExpoGoPreinstall } from '../utils/expoGo';
|
||||
import { commands } from '../utils/processes';
|
||||
import { deferred, until } from './helpers';
|
||||
|
||||
const runCommandAsync = commands.runAsync.bind(commands);
|
||||
function createArchive(entries: { name: string; contents: Buffer }[]): Buffer {
|
||||
const blocks: Buffer[] = [];
|
||||
for (const { name, contents } of entries) {
|
||||
const header = Buffer.alloc(512);
|
||||
header.write(name);
|
||||
header.write('0000644\0', 100);
|
||||
header.write(contents.length.toString(8).padStart(11, '0') + '\0', 124);
|
||||
header.fill(32, 148, 156);
|
||||
header.write('0', 156);
|
||||
const checksum = header.reduce((sum, byte) => sum + byte, 0);
|
||||
header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148);
|
||||
blocks.push(header, contents, Buffer.alloc((512 - (contents.length % 512)) % 512));
|
||||
}
|
||||
return gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)]));
|
||||
}
|
||||
function installCalls() {
|
||||
return jest.mocked(commands.runAsync).mock.calls.filter(([file]) => file === 'xcrun');
|
||||
}
|
||||
|
||||
let directory: string;
|
||||
let archive: Buffer;
|
||||
let controller: AbortController;
|
||||
let warmup: ReturnType<typeof startOn> | undefined;
|
||||
const stableUrl = 'https://example.test/Expo-Go-stable.tar.gz';
|
||||
const sdkVersions = {
|
||||
'57.0.0': {
|
||||
releaseNoteUrl: 'https://example.test/57',
|
||||
iosClientUrl: 'https://example.test/old.tar.gz',
|
||||
},
|
||||
'58.0.0': { releaseNoteUrl: 'https://example.test/58', iosClientUrl: stableUrl },
|
||||
'59.0.0': {
|
||||
isBeta: true,
|
||||
releaseNoteUrl: 'https://example.test/beta',
|
||||
iosClientUrl: 'https://example.test/beta.tar.gz',
|
||||
},
|
||||
'60.0.0': { iosClientUrl: 'https://example.test/canary.tar.gz' },
|
||||
'61.1.0': {
|
||||
releaseNoteUrl: 'https://example.test/not-published',
|
||||
iosClientUrl: 'https://example.test/not-published.tar.gz',
|
||||
},
|
||||
};
|
||||
|
||||
/** Starts the shared download and preinstalls on one device, as warmup does for each device. */
|
||||
function startOn(udid: string, boot: Promise<unknown>) {
|
||||
const preinstall = startExpoGoPreinstall(controller.signal);
|
||||
return {
|
||||
...preinstall,
|
||||
resultPromise: preinstall.preinstallOnDeviceAsync(udid, boot, controller.signal),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'expo-go-unit-'));
|
||||
archive = createArchive([
|
||||
{ name: 'Info.plist', contents: Buffer.from('<plist>fixture</plist>') },
|
||||
]);
|
||||
jest.spyOn(os, 'tmpdir').mockReturnValue(directory);
|
||||
jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockImplementation(async url =>
|
||||
url === 'https://api.expo.dev/v2/versions'
|
||||
? Response.json({ iosUrl: 'https://example.test/legacy.tar.gz', sdkVersions })
|
||||
: new Response(Uint8Array.from(archive))
|
||||
);
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args, signal) =>
|
||||
file === '/usr/bin/tar' ? await runCommandAsync(file, args, signal) : ''
|
||||
);
|
||||
jest.spyOn(commands, 'throwIfCommandCloseTimedOut').mockImplementation(() => {});
|
||||
jest.spyOn(process.stderr, 'write').mockReturnValue(true);
|
||||
controller = new AbortController();
|
||||
warmup = undefined;
|
||||
});
|
||||
afterEach(async () => {
|
||||
controller.abort();
|
||||
await warmup?.stopAsync('UDID').catch(() => {});
|
||||
jest.restoreAllMocks();
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('uses the website stable release policy, extracts flat contents as .app and installs only after boot', async () => {
|
||||
const boot = deferred();
|
||||
warmup = startOn('UDID', boot.promise);
|
||||
try {
|
||||
await until(() => jest.mocked(fetch).mock.calls.length === 2);
|
||||
expect(fetch).toHaveBeenLastCalledWith(
|
||||
stableUrl,
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'expo-simulator-launcher',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
boot.resolve();
|
||||
await warmup.resultPromise;
|
||||
expect(installCalls()).toHaveLength(1);
|
||||
const [file, args, signal] = installCalls()[0];
|
||||
expect(file).toBe('xcrun');
|
||||
expect(args.slice(0, 3)).toEqual(['simctl', 'install', 'UDID']);
|
||||
expect(path.basename(args[3])).toBe('ExpoGo.app');
|
||||
expect(signal).toBeUndefined(); // Never cancel an in-flight installd transaction through simctl.
|
||||
// The shared download stays until a claim or stop releases it; stop waits for the removal.
|
||||
expect((await fs.stat(path.dirname(args[3]))).isDirectory()).toBe(true);
|
||||
await warmup.stopAsync('UDID');
|
||||
await expect(fs.stat(path.dirname(args[3]))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} finally {
|
||||
boot.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
it('claim aborts a pending download without waiting for it, and a late fetch cannot install', async () => {
|
||||
const pending = deferred<Response>();
|
||||
jest.mocked(fetch).mockReturnValueOnce(pending.promise);
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
try {
|
||||
await until(() => jest.mocked(fetch).mock.calls.length > 0);
|
||||
await warmup.claimAsync('UDID');
|
||||
expect(jest.mocked(fetch).mock.calls[0][1]?.signal?.aborted).toBe(true);
|
||||
pending.resolve(Response.json({ sdkVersions })); // Even an uncooperative late fetch cannot install.
|
||||
await warmup.stopAsync('UDID');
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
expect(
|
||||
(await fs.readdir(directory)).filter(name => name.startsWith('simulator-expo-go-'))
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
pending.resolve(Response.json({ sdkVersions }));
|
||||
}
|
||||
});
|
||||
|
||||
it('claim only waits for the native install; terminal stop also drains temporary-file cleanup', async () => {
|
||||
const installation = deferred<string>();
|
||||
const cleanup = deferred();
|
||||
const remove = fs.rm.bind(fs);
|
||||
let cleanupStarted = false;
|
||||
jest.spyOn(fs, 'rm').mockImplementation(async (file, options) => {
|
||||
cleanupStarted = true;
|
||||
await cleanup.promise;
|
||||
return await remove(file, options);
|
||||
});
|
||||
jest
|
||||
.mocked(commands.runAsync)
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === '/usr/bin/tar' ? await runCommandAsync(file, args) : await installation.promise
|
||||
);
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
try {
|
||||
await until(() => installCalls().length > 0);
|
||||
let claimed = false;
|
||||
const claiming = warmup.claimAsync('UDID').then(() => {
|
||||
claimed = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(claimed).toBe(false);
|
||||
installation.resolve('');
|
||||
await claiming;
|
||||
expect(claimed).toBe(true);
|
||||
await until(() => cleanupStarted);
|
||||
let stopped = false;
|
||||
const stopping = warmup.stopAsync('UDID').then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
cleanup.resolve();
|
||||
await stopping;
|
||||
} finally {
|
||||
installation.resolve('');
|
||||
cleanup.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
it('claim during extraction returns without waiting but cannot begin an install afterward', async () => {
|
||||
const extraction = deferred();
|
||||
let extracting = false;
|
||||
jest.mocked(commands.runAsync).mockImplementation(async (file, args) => {
|
||||
expect(file).toBe('/usr/bin/tar');
|
||||
extracting = true;
|
||||
await extraction.promise;
|
||||
return await runCommandAsync(file, args);
|
||||
});
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
try {
|
||||
await until(() => extracting);
|
||||
await warmup.claimAsync('UDID');
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
let stopped = false;
|
||||
const stopping = warmup.stopAsync('UDID').then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
extraction.resolve();
|
||||
await stopping;
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
} finally {
|
||||
extraction.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['metadata', 'archive', 'install'])(
|
||||
'warns on an ordinary %s failure without failing warmup',
|
||||
async phase => {
|
||||
if (phase === 'metadata') {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 403 }));
|
||||
}
|
||||
if (phase === 'archive') {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(Response.json({ sdkVersions }))
|
||||
.mockResolvedValueOnce(new Response('', { status: 503 }));
|
||||
}
|
||||
if (phase === 'install') {
|
||||
jest.mocked(commands.runAsync).mockImplementation(async (file, args) => {
|
||||
if (file === '/usr/bin/tar') {
|
||||
return await runCommandAsync(file, args);
|
||||
}
|
||||
throw Object.assign(new Error('install failed'), { code: 1 });
|
||||
});
|
||||
}
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
await warmup.claimAsync('UDID');
|
||||
await warmup.stopAsync('UDID');
|
||||
expect(process.stderr.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not preinstall Expo Go')
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['signal', 'close timeout', 'temp removal'])(
|
||||
'fails closed on %s instead of treating it as optional installation failure',
|
||||
async kind => {
|
||||
const error = Object.assign(new Error(`uncertain ${kind}`), {
|
||||
code: kind === 'signal' ? null : 1,
|
||||
});
|
||||
if (kind === 'temp removal') {
|
||||
jest.spyOn(fs, 'rm').mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(commands.runAsync).mockImplementation(async (file, args) => {
|
||||
if (file === '/usr/bin/tar') {
|
||||
return await runCommandAsync(file, args);
|
||||
}
|
||||
if (kind === 'close timeout') {
|
||||
jest.mocked(commands.throwIfCommandCloseTimedOut).mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise.catch(() => {});
|
||||
// The claim does not wait for the removal; stop does, and never confirms a failed one.
|
||||
await warmup.claimAsync('UDID').catch(() => {});
|
||||
await expect(warmup.stopAsync('UDID')).rejects.toBe(error);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['valid', 'traversal', 'malformed'] as const)(
|
||||
'drains the real owned extractor for a %s archive before removing temp files or settling stop',
|
||||
async kind => {
|
||||
const contents = Buffer.alloc(4 * 1024 * 1024, 97);
|
||||
archive =
|
||||
kind === 'malformed'
|
||||
? Buffer.from('not a gzip or tar archive')
|
||||
: createArchive([
|
||||
...(kind === 'traversal'
|
||||
? [{ name: '../escaped', contents: Buffer.from('unsafe') }]
|
||||
: []),
|
||||
{ name: 'Info.plist', contents: Buffer.from('<plist>fixture</plist>') },
|
||||
{ name: 'ordinary-file', contents },
|
||||
]);
|
||||
const start = commands.start.bind(commands);
|
||||
let extractor: ReturnType<typeof commands.start> | undefined;
|
||||
let extractorClosed = false;
|
||||
const remove = fs.rm.bind(fs);
|
||||
const removing = jest.spyOn(fs, 'rm').mockImplementation(async (file, options) => {
|
||||
expect(extractorClosed).toBe(true);
|
||||
if (kind === 'traversal') {
|
||||
await expect(fs.access(path.join(String(file), 'escaped'))).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
}
|
||||
if (kind !== 'malformed') {
|
||||
expect(
|
||||
(await fs.readFile(path.join(String(file), 'ExpoGo.app', 'ordinary-file'))).equals(
|
||||
contents
|
||||
)
|
||||
).toBe(true);
|
||||
}
|
||||
return await remove(file, options);
|
||||
});
|
||||
jest.spyOn(commands, 'start').mockImplementation((file, args, options) => {
|
||||
expect(file).toBe('/usr/bin/tar');
|
||||
expect(args).toEqual([
|
||||
'-xzf',
|
||||
expect.stringMatching(/\/expo-go.tar.gz$/),
|
||||
'-C',
|
||||
expect.stringMatching(/\/ExpoGo.app$/),
|
||||
]);
|
||||
expect(options?.signal).toBeUndefined();
|
||||
extractor = start(file, args, options);
|
||||
extractor.child.once('close', () => {
|
||||
extractorClosed = true;
|
||||
});
|
||||
expect(extractor.child.kill('SIGSTOP')).toBe(true);
|
||||
return extractor;
|
||||
});
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
try {
|
||||
await until(() => extractor !== undefined);
|
||||
await warmup.claimAsync('UDID'); // Only forbids new install; extraction remains owned and pending.
|
||||
expect(extractorClosed).toBe(false);
|
||||
let stopped = false;
|
||||
const stopping = warmup.stopAsync('UDID').then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
expect(removing).not.toHaveBeenCalled();
|
||||
extractor!.child.kill('SIGCONT');
|
||||
await stopping;
|
||||
expect(extractorClosed).toBe(true);
|
||||
expect(removing).toHaveBeenCalledTimes(1);
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
if (kind !== 'valid') {
|
||||
const extractionError = await extractor!.resultPromise.catch(error => error);
|
||||
expect(extractionError.code).toBeGreaterThan(0);
|
||||
}
|
||||
} finally {
|
||||
// Reap only this test's owned child, including an assertion failure while it is stopped.
|
||||
if (extractor?.child.exitCode == null && extractor?.child.signalCode == null) {
|
||||
extractor?.child.kill('SIGCONT');
|
||||
extractor?.child.kill('SIGKILL');
|
||||
}
|
||||
await extractor?.resultPromise.catch(() => {});
|
||||
removing.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps owned temp files and rejects terminal stop when extractor close is uncertain', async () => {
|
||||
const failure = new Error('extractor close not proved');
|
||||
const remove = jest.spyOn(fs, 'rm');
|
||||
jest.mocked(commands.runAsync).mockImplementationOnce(async file => {
|
||||
expect(file).toBe('/usr/bin/tar');
|
||||
jest.mocked(commands.throwIfCommandCloseTimedOut).mockImplementation(() => {
|
||||
throw failure;
|
||||
});
|
||||
throw failure;
|
||||
});
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await expect(warmup.resultPromise).rejects.toBe(failure);
|
||||
await expect(warmup.stopAsync('UDID')).rejects.toBe(failure);
|
||||
await expect(warmup.claimAsync('UDID')).rejects.toBe(failure);
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
expect((await fs.readdir(directory)).some(name => name.startsWith('simulator-expo-go-'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(['traversal', 'malformed'] as const)(
|
||||
'never installs after a real %s archive failure without a claim',
|
||||
async kind => {
|
||||
archive =
|
||||
kind === 'malformed'
|
||||
? Buffer.from('not an archive')
|
||||
: createArchive([
|
||||
{ name: '../escaped', contents: Buffer.from('unsafe') },
|
||||
{ name: 'Info.plist', contents: Buffer.from('<plist>fixture</plist>') },
|
||||
]);
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(installCalls()).toHaveLength(0);
|
||||
expect(process.stderr.write).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not preinstall Expo Go')
|
||||
);
|
||||
await warmup.stopAsync('UDID');
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
}
|
||||
);
|
||||
|
||||
it('claim returns the identity read from the Expo Go it installed', async () => {
|
||||
const plist: Record<string, string> = {
|
||||
CFBundleIdentifier: 'host.exp.Exponent',
|
||||
CFBundleShortVersionString: '58.0.7',
|
||||
CFBundleVersion: '58.0.7',
|
||||
};
|
||||
jest
|
||||
.mocked(commands.runAsync)
|
||||
.mockImplementation(async (file, args, signal) =>
|
||||
file === '/usr/bin/tar'
|
||||
? await runCommandAsync(file, args, signal)
|
||||
: file === 'plutil'
|
||||
? `${plist[args[1]]}\n`
|
||||
: ''
|
||||
);
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(await warmup.claimAsync('UDID')).toEqual({
|
||||
udid: 'UDID',
|
||||
bundleIdentifier: 'host.exp.Exponent',
|
||||
shortVersion: '58.0.7',
|
||||
version: '58.0.7',
|
||||
});
|
||||
const plistPath = jest
|
||||
.mocked(commands.runAsync)
|
||||
.mock.calls.find(([file]) => file === 'plutil')![1];
|
||||
expect(path.basename(path.dirname(plistPath[5]))).toBe('ExpoGo.app');
|
||||
});
|
||||
|
||||
it('claim returns no identity when the preinstall fails', async () => {
|
||||
jest
|
||||
.mocked(commands.runAsync)
|
||||
.mockImplementation(async (file, args, signal) =>
|
||||
file === '/usr/bin/tar'
|
||||
? await runCommandAsync(file, args, signal)
|
||||
: file === 'xcrun'
|
||||
? Promise.reject(Object.assign(new Error('install failed'), { code: 1 }))
|
||||
: ''
|
||||
);
|
||||
warmup = startOn('UDID', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
expect(await warmup.claimAsync('UDID')).toBeUndefined();
|
||||
expect(jest.mocked(commands.runAsync).mock.calls.some(([file]) => file === 'plutil')).toBe(false);
|
||||
});
|
||||
|
||||
it('downloads and extracts once for two devices, then removes the shared app on stop', async () => {
|
||||
warmup = startOn('FIRST', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
const app = installCalls()[0][1][3];
|
||||
expect((await fs.stat(app)).isDirectory()).toBe(true);
|
||||
await warmup.preinstallOnDeviceAsync('SECOND', Promise.resolve(), controller.signal);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
jest.mocked(commands.runAsync).mock.calls.filter(([file]) => file === '/usr/bin/tar')
|
||||
).toHaveLength(1);
|
||||
expect(installCalls().map(([, args]) => args.slice(2))).toEqual([
|
||||
['FIRST', app],
|
||||
['SECOND', app],
|
||||
]);
|
||||
const claimed = await warmup.claimAsync('SECOND');
|
||||
await warmup.stopAsync('SECOND');
|
||||
await expect(fs.stat(app)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(claimed?.udid).toBe('SECOND');
|
||||
});
|
||||
it('a claim prevents secondary preinstall from starting and releases the retained archive', async () => {
|
||||
warmup = startOn('FIRST', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
await warmup.claimAsync('FIRST');
|
||||
await warmup.preinstallOnDeviceAsync('SECOND', Promise.resolve(), controller.signal);
|
||||
await warmup.stopAsync('FIRST');
|
||||
expect(installCalls()).toHaveLength(1);
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
});
|
||||
it('claim and stop drain a secondary native install without cancelling its transaction', async () => {
|
||||
const install = deferred<string>();
|
||||
const original = jest.mocked(commands.runAsync).getMockImplementation()!;
|
||||
warmup = startOn('FIRST', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
jest
|
||||
.mocked(commands.runAsync)
|
||||
.mockImplementation((file, args, signal) =>
|
||||
file === 'xcrun' && args.includes('SECOND') ? install.promise : original(file, args, signal)
|
||||
);
|
||||
const secondary = warmup.preinstallOnDeviceAsync('SECOND', Promise.resolve(), controller.signal);
|
||||
let claimed = false;
|
||||
try {
|
||||
await until(() => installCalls().length === 2);
|
||||
const claiming = warmup.claimAsync('SECOND').then(() => {
|
||||
claimed = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(claimed).toBe(false);
|
||||
expect(installCalls()[1][2]).toBeUndefined();
|
||||
install.resolve('');
|
||||
await claiming;
|
||||
await secondary;
|
||||
await warmup.stopAsync('FIRST');
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
} finally {
|
||||
install.resolve('');
|
||||
await secondary;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['signal', 'close timeout'])(
|
||||
'isolates a secondary %s failure from the already-installed default',
|
||||
async kind => {
|
||||
warmup = startOn('FIRST', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
const failure = Object.assign(new Error('secondary install failed'), { code: null });
|
||||
const original = jest.mocked(commands.runAsync).getMockImplementation()!;
|
||||
jest.mocked(commands.runAsync).mockImplementation((file, args, signal) => {
|
||||
if (file === 'xcrun' && args.includes('SECOND')) {
|
||||
if (kind === 'close timeout') {
|
||||
jest.mocked(commands.throwIfCommandCloseTimedOut).mockImplementation(() => {
|
||||
throw failure;
|
||||
});
|
||||
}
|
||||
return Promise.reject(failure);
|
||||
}
|
||||
return original(file, args, signal);
|
||||
});
|
||||
await expect(
|
||||
warmup.preinstallOnDeviceAsync('SECOND', Promise.resolve(), controller.signal)
|
||||
).rejects.toBe(failure);
|
||||
expect((await warmup.claimAsync('FIRST'))?.udid).toBe('FIRST');
|
||||
await expect(warmup.claimAsync('SECOND')).rejects.toBe(failure);
|
||||
if (kind === 'close timeout') {
|
||||
await expect(warmup.stopAsync('FIRST')).rejects.toBe(failure);
|
||||
} else {
|
||||
await expect(warmup.stopAsync('FIRST')).resolves.toBeUndefined();
|
||||
}
|
||||
await expect(warmup.stopAsync('SECOND')).rejects.toBe(failure);
|
||||
}
|
||||
);
|
||||
it('a concurrent stop cannot miss a secondary install failure while shared cleanup drains', async () => {
|
||||
warmup = startOn('FIRST', Promise.resolve());
|
||||
await warmup.resultPromise;
|
||||
const installation = deferred<string>();
|
||||
const original = jest.mocked(commands.runAsync).getMockImplementation()!;
|
||||
jest
|
||||
.mocked(commands.runAsync)
|
||||
.mockImplementation((file, args, signal) =>
|
||||
file === 'xcrun' && args.includes('SECOND')
|
||||
? installation.promise
|
||||
: original(file, args, signal)
|
||||
);
|
||||
const secondary = warmup.preinstallOnDeviceAsync('SECOND', Promise.resolve(), controller.signal);
|
||||
void secondary.catch(() => {});
|
||||
try {
|
||||
await until(() => installCalls().length === 2);
|
||||
const stopping = warmup.stopAsync('SECOND');
|
||||
void stopping.catch(() => {});
|
||||
const failure = Object.assign(new Error('secondary killed'), { code: null });
|
||||
installation.reject(failure);
|
||||
await expect(stopping).rejects.toBe(failure);
|
||||
await expect(secondary).rejects.toBe(failure);
|
||||
} finally {
|
||||
installation.resolve('');
|
||||
await secondary.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('preinstalls all three configured devices from one shared archive through the session owner', async () => {
|
||||
const { createWarmSimulatorSession } = await import('../commands/start');
|
||||
const simulator = await import('../utils/simulator');
|
||||
const serveSim = await import('../utils/serveSim');
|
||||
const processes = await import('../utils/processes');
|
||||
const agentDevice = await import('../utils/agentDevice');
|
||||
const argent = await import('../utils/argent');
|
||||
const devices = ['FIRST', 'SECOND', 'THIRD'].map(udid => ({ udid, name: `iPhone ${udid}` }));
|
||||
const environment = { ...process.env };
|
||||
process.env.EXPO_SIMULATOR_DEVICE = devices.map(device => device.udid).join(',');
|
||||
process.env.AGENT_DEVICE_STATE_DIR = path.join(directory, 'agent');
|
||||
process.env.ARGENT_EVENT_LOG = path.join(directory, 'argent-events');
|
||||
jest.spyOn(simulator, 'listAvailableSimulatorsAsync').mockResolvedValue(devices);
|
||||
jest.spyOn(simulator, 'waitForSimulatorBootAsync').mockResolvedValue();
|
||||
jest.spyOn(simulator, 'disableApsdAsync').mockResolvedValue();
|
||||
jest.spyOn(simulator, 'shutdownSimulatorAsync').mockResolvedValue();
|
||||
jest.spyOn(processes, 'stopProcessAndDescendantsAsync').mockResolvedValue();
|
||||
jest.spyOn(agentDevice, 'createAgentDeviceTool').mockReturnValue({
|
||||
name: 'agent-device',
|
||||
startAsync: async () => {},
|
||||
stopAsync: async () => {},
|
||||
});
|
||||
jest
|
||||
.spyOn(argent, 'createArgentTool')
|
||||
.mockReturnValue({ name: 'argent', startAsync: async () => {}, stopAsync: async () => {} });
|
||||
jest.spyOn(serveSim, 'startServeSim').mockReturnValue({
|
||||
child: { pid: 90001 } as import('node:child_process').ChildProcess,
|
||||
resultPromise: new Promise(() => {}),
|
||||
});
|
||||
jest.spyOn(serveSim, 'waitForServeSimReadyAsync').mockResolvedValue({
|
||||
startupDevice: 'FIRST',
|
||||
pid: 90001,
|
||||
port: 3200,
|
||||
nativeStartTime: 'start-time',
|
||||
url: 'http://127.0.0.1:3200',
|
||||
token: 'fixture-token',
|
||||
});
|
||||
const original = jest.mocked(commands.runAsync).getMockImplementation()!;
|
||||
jest.mocked(commands.runAsync).mockImplementation(async (file, args, signal) => {
|
||||
if (file === 'ps') {
|
||||
return 'controller-start';
|
||||
}
|
||||
if (file === 'plutil') {
|
||||
return args.includes('CFBundleIdentifier') ? 'host.exp.Exponent' : '58.0.7';
|
||||
}
|
||||
return original(file, args, signal);
|
||||
});
|
||||
const onFailure = jest.fn();
|
||||
const session = createWarmSimulatorSession({
|
||||
signal: controller.signal,
|
||||
throwIfStoppingAsync: async () => controller.signal.throwIfAborted(),
|
||||
onPreparationFailure: onFailure,
|
||||
});
|
||||
try {
|
||||
await session.prepareAsync();
|
||||
await until(() => installCalls().length === 3);
|
||||
await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'THIRD' });
|
||||
const prepared = await session.prepareToolsAsync();
|
||||
expect(prepared.preinstalledExpoGo).toEqual({
|
||||
udid: 'THIRD',
|
||||
bundleIdentifier: 'host.exp.Exponent',
|
||||
shortVersion: '58.0.7',
|
||||
version: '58.0.7',
|
||||
});
|
||||
expect(installCalls().map(([, args]) => args[2])).toEqual(['FIRST', 'SECOND', 'THIRD']);
|
||||
expect(new Set(installCalls().map(([, args]) => args[3])).size).toBe(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
jest.mocked(commands.runAsync).mock.calls.filter(([file]) => file === '/usr/bin/tar')
|
||||
).toHaveLength(1);
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await session.stopAsync();
|
||||
process.env = environment;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
// Test fixtures use these PIDs for fake child processes. Fail fast instead of letting a test
|
||||
// reach the real process.kill with one of them, where it could signal an unrelated process.
|
||||
const FIXTURE_PIDS = new Set([10, 11, 12, 20, 30, 40, 41]);
|
||||
const isFixturePid = (pid: number): boolean =>
|
||||
FIXTURE_PIDS.has(Math.abs(pid)) || (Math.abs(pid) >= 90000 && Math.abs(pid) < 90100);
|
||||
|
||||
const realKill = process.kill.bind(process);
|
||||
process.kill = ((pid: number, signal?: string | number) => {
|
||||
if (isFixturePid(pid)) {
|
||||
throw new Error(`A test tried to signal fixture PID ${pid} through the real process.kill.`);
|
||||
}
|
||||
return realKill(pid, signal);
|
||||
}) as typeof process.kill;
|
||||
@@ -0,0 +1,56 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as sleepAsync } from 'node:timers/promises';
|
||||
|
||||
import { commands, stopProcessAndDescendantsAsync } from '../../utils/processes';
|
||||
|
||||
export async function startOwnedWrapperAsync() {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'launcher-owned-wrapper-'));
|
||||
const readyPath = path.join(directory, 'ready.json');
|
||||
const job = commands.start(
|
||||
process.execPath,
|
||||
[path.join(__dirname, 'stop-wrapper.cjs'), path.join(__dirname, 'stop-leaf.cjs'), readyPath],
|
||||
{ captureStdout: true, detached: true }
|
||||
);
|
||||
const realKill = process.kill.bind(process);
|
||||
const stop = stopProcessAndDescendantsAsync;
|
||||
let confirmed = false;
|
||||
let stopping: Promise<void> | undefined;
|
||||
const cleanupAsync = async () => {
|
||||
if (!confirmed && job.child.pid) {
|
||||
try {
|
||||
realKill(-job.child.pid, 'SIGKILL');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
|
||||
}
|
||||
}
|
||||
await job.resultPromise.catch(() => {});
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
};
|
||||
try {
|
||||
let leaves: number[] = [];
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (!leaves.length) {
|
||||
const ready = await fs.readFile(readyPath, 'utf8').catch(() => undefined);
|
||||
if (ready) leaves = JSON.parse(ready);
|
||||
else {
|
||||
if (Date.now() >= deadline) throw new Error('The disposable wrapper did not become ready.');
|
||||
await sleepAsync(10);
|
||||
}
|
||||
}
|
||||
return {
|
||||
job,
|
||||
leaves,
|
||||
realKill,
|
||||
cleanupAsync,
|
||||
stopAsync: () =>
|
||||
(stopping ??= stop(job).then(() => {
|
||||
confirmed = true;
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanupAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
process.on('SIGTERM', () => process.exit(23));
|
||||
setInterval(() => {}, 1000);
|
||||
process.send('ready');
|
||||
@@ -0,0 +1,20 @@
|
||||
const { spawn } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const children = [0, 1].map(() =>
|
||||
spawn(process.execPath, [process.argv[2]], {
|
||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||
})
|
||||
);
|
||||
// Like a supervisor, forward parent termination and report the child's final status.
|
||||
process.on('SIGTERM', () => {
|
||||
for (const child of children) child.kill('SIGTERM');
|
||||
});
|
||||
let ready = 0;
|
||||
for (const child of children) {
|
||||
child.on('message', () => {
|
||||
if (++ready === children.length) {
|
||||
fs.writeFileSync(process.argv[3], JSON.stringify(children.map(child => child.pid)));
|
||||
}
|
||||
});
|
||||
child.on('exit', code => process.exit(code ?? 1));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
/** Polls `predicate` for up to 2 seconds, then asserts it. */
|
||||
export async function until(predicate: () => boolean): Promise<void> {
|
||||
for (let i = 0; i < 400 && !predicate(); i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
expect(predicate()).toBe(true);
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { claimAsync, prepareToolsAsync } from '../commands/claim';
|
||||
import { runWarmSimulatorSessionAsync, startAsync } from '../commands/start';
|
||||
import { stopAsync } from '../commands/stop';
|
||||
import { mainAsync } from '../main';
|
||||
import { createArgentTool } from '../utils/argent';
|
||||
import { startExpoGoPreinstall } from '../utils/expoGo';
|
||||
import { type ChildProcessHandle, commands } from '../utils/processes';
|
||||
import * as processes from '../utils/processes';
|
||||
import * as serveSim from '../utils/serveSim';
|
||||
import { shutdownSimulatorAsync } from '../utils/simulator';
|
||||
import {
|
||||
LAUNCHER_STATE_FILE,
|
||||
readLauncherStateAsync,
|
||||
updateLauncherStateAsync,
|
||||
waitForLauncherStateAsync,
|
||||
} from '../utils/state';
|
||||
import { deferred, until } from './helpers';
|
||||
|
||||
jest.mock('../utils/expoGo');
|
||||
jest.mock('../utils/argent');
|
||||
const boot = 'boot-one';
|
||||
let root: string;
|
||||
let environment: NodeJS.ProcessEnv;
|
||||
let active: Promise<unknown>[];
|
||||
let jobs: ChildProcessHandle[];
|
||||
const platform = process.platform;
|
||||
const deviceList = {
|
||||
devices: {
|
||||
ios: [
|
||||
{ name: 'iPhone 17', udid: 'PHONE' },
|
||||
{ name: 'iPhone 17 Pro', udid: 'PRO' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const nativeFs = {
|
||||
readFile: fs.readFile.bind(fs),
|
||||
writeFile: fs.writeFile.bind(fs),
|
||||
rename: fs.rename.bind(fs),
|
||||
rm: fs.rm.bind(fs),
|
||||
mkdir: fs.mkdir.bind(fs),
|
||||
rmdir: fs.rmdir.bind(fs),
|
||||
stat: fs.stat.bind(fs),
|
||||
};
|
||||
const remap = (file: unknown): unknown =>
|
||||
typeof file === 'string' && file.startsWith(LAUNCHER_STATE_FILE)
|
||||
? path.join(root, 'control', path.basename(file))
|
||||
: file;
|
||||
|
||||
async function status(value: string) {
|
||||
return await waitForLauncherStateAsync(boot, state => state?.status === value, 2_000);
|
||||
}
|
||||
async function beginSession(): Promise<Promise<unknown>[]> {
|
||||
await updateLauncherStateAsync(boot, () => ({
|
||||
bootUuid: boot,
|
||||
status: 'starting',
|
||||
launcherVersion: require('../../package.json').version,
|
||||
controllerPid: process.pid,
|
||||
}));
|
||||
const completion = runWarmSimulatorSessionAsync(boot).catch(error => error);
|
||||
active.push(completion);
|
||||
return [completion];
|
||||
}
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'launcher-session-'));
|
||||
jest.spyOn(os, 'homedir').mockReturnValue(root);
|
||||
await fs.mkdir(path.join(root, 'control'));
|
||||
await fs.mkdir(path.join(root, '.argent'));
|
||||
await fs.writeFile(
|
||||
path.join(root, '.argent/tool-server.json'),
|
||||
JSON.stringify({ pid: 90002, port: 3201, token: 'fixture-token' })
|
||||
);
|
||||
jest.spyOn(processes, 'isProcessOwnedByServiceAsync').mockResolvedValue(true);
|
||||
jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockImplementation(async () => new Response(JSON.stringify({ tools: [] })));
|
||||
for (const name of ['readFile', 'writeFile', 'rename', 'rm', 'mkdir', 'rmdir', 'stat'] as const) {
|
||||
jest.spyOn(fs, name).mockImplementation(((...args: unknown[]) => {
|
||||
args[0] = remap(args[0]);
|
||||
if (name === 'rename') {
|
||||
args[1] = remap(args[1]);
|
||||
}
|
||||
return (nativeFs[name] as (...args: unknown[]) => unknown)(...args);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
environment = { ...process.env };
|
||||
process.env.EXPO_SIMULATOR_STARTUP = '1';
|
||||
process.env.ARGENT_EVENT_LOG = path.join(root, 'argent-events');
|
||||
active = [];
|
||||
jobs = [];
|
||||
jest
|
||||
.mocked(startExpoGoPreinstall)
|
||||
.mockReset()
|
||||
.mockReturnValue({
|
||||
preinstallOnDeviceAsync: jest.fn(async () => {}),
|
||||
claimAsync: jest.fn(async () => undefined),
|
||||
stopAsync: jest.fn(async () => {}),
|
||||
});
|
||||
jest.spyOn(commands, 'runAsync').mockImplementation(async (file, args) => {
|
||||
if (file === '/usr/sbin/sysctl') {
|
||||
return boot;
|
||||
}
|
||||
if (args.includes('lstart=')) {
|
||||
return 'Thu Oct 22 10:00:00 2026';
|
||||
}
|
||||
if (args.includes('devices')) {
|
||||
return JSON.stringify(deviceList);
|
||||
}
|
||||
if (args.includes('launchctl') && args.includes('list')) {
|
||||
throw Object.assign(new Error('apsd is not loaded'), { code: 113 });
|
||||
}
|
||||
return '';
|
||||
});
|
||||
jest.spyOn(commands, 'start').mockImplementation((_file, args) => {
|
||||
const job = {
|
||||
child: Object.assign(new EventEmitter(), {
|
||||
pid: 90000 + jobs.length,
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
kill: jest.fn(),
|
||||
}) as unknown as ChildProcess,
|
||||
resultPromise: args.includes('server') ? new Promise<string>(() => {}) : Promise.resolve(''),
|
||||
};
|
||||
jobs.push(job);
|
||||
return job;
|
||||
});
|
||||
// Fixture PIDs are fake; never signal them.
|
||||
jest.spyOn(processes, 'stopProcessAndDescendantsAsync').mockResolvedValue();
|
||||
jest.spyOn(serveSim, 'waitForServeSimReadyAsync').mockResolvedValue({
|
||||
startupDevice: 'PHONE',
|
||||
pid: 90000,
|
||||
nativeStartTime: 'native-start',
|
||||
port: 3200,
|
||||
url: 'http://127.0.0.1:3200',
|
||||
token: 'fixture-token',
|
||||
});
|
||||
jest.mocked(createArgentTool).mockImplementation(() => ({
|
||||
name: 'argent',
|
||||
startAsync: async () => {},
|
||||
stopAsync: async () => {},
|
||||
}));
|
||||
});
|
||||
afterEach(async () => {
|
||||
if (active.length) {
|
||||
process.emit('SIGTERM', 'SIGTERM');
|
||||
}
|
||||
await Promise.allSettled(active);
|
||||
jest.restoreAllMocks();
|
||||
process.env = environment;
|
||||
Object.defineProperty(process, 'platform', { value: platform });
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('concurrent starts acquire one session and record the exact package version', async () => {
|
||||
const results: unknown[] = [];
|
||||
const starts = [0, 1].map(() =>
|
||||
startAsync(boot).then(
|
||||
value => results.push(value),
|
||||
error => results.push(error)
|
||||
)
|
||||
);
|
||||
active.push(...starts);
|
||||
await status('ready');
|
||||
await until(() => results.length === 1);
|
||||
expect(results[0]).toBeInstanceOf(Error);
|
||||
expect((await readLauncherStateAsync(boot))?.launcherVersion).toBe(
|
||||
require('../../package.json').version
|
||||
);
|
||||
expect(jest.mocked(serveSim.waitForServeSimReadyAsync).mock.calls).toHaveLength(1);
|
||||
await stopAsync(boot);
|
||||
await Promise.all(starts);
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe('disabled');
|
||||
});
|
||||
|
||||
test('stop before start makes late startup a resource-free disabled no-op', async () => {
|
||||
expect(await stopAsync(boot)).toBe(0);
|
||||
expect(await startAsync(boot)).toBe(0);
|
||||
expect(commands.runAsync).not.toHaveBeenCalled();
|
||||
expect(commands.start).not.toHaveBeenCalled();
|
||||
expect(await readLauncherStateAsync(boot)).toEqual({ bootUuid: boot, status: 'disabled' });
|
||||
});
|
||||
|
||||
test('stop under different HOME and TMPDIR waits for the same active session cleanup', async () => {
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
const drain = deferred();
|
||||
jest.mocked(processes.stopProcessAndDescendantsAsync).mockImplementation(async () => {
|
||||
await drain.promise;
|
||||
});
|
||||
process.env.HOME = path.join(root, 'other-home');
|
||||
process.env.TMPDIR = path.join(root, 'other-tmp');
|
||||
jest.mocked(os.homedir).mockReturnValue(process.env.HOME);
|
||||
let done = false;
|
||||
const stopping = mainAsync(['stop']).then(value => {
|
||||
done = true;
|
||||
return value;
|
||||
});
|
||||
try {
|
||||
await status('stopping');
|
||||
expect(done).toBe(false);
|
||||
drain.resolve();
|
||||
expect(await stopping).toBe(0);
|
||||
expect(await completion).toBeUndefined();
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe('disabled');
|
||||
} finally {
|
||||
drain.resolve();
|
||||
await stopping;
|
||||
}
|
||||
});
|
||||
|
||||
test('ready contains one server identity and effective paths in the single state file', async () => {
|
||||
const [completion] = await beginSession();
|
||||
const ready = await status('ready');
|
||||
expect(ready).toMatchObject({
|
||||
controllerPid: process.pid,
|
||||
device: { udid: 'PHONE' },
|
||||
server: { startupDevice: 'PHONE', pid: 90000, nativeStartTime: 'native-start', port: 3200 },
|
||||
});
|
||||
expect(ready?.agentDeviceStateDir).toContain(root);
|
||||
expect(await fs.readdir(path.join(root, 'control'))).toEqual([
|
||||
path.basename(LAUNCHER_STATE_FILE),
|
||||
]);
|
||||
await stopAsync(boot);
|
||||
expect(await completion).toBeUndefined();
|
||||
});
|
||||
|
||||
test.each([undefined, 'phone', 'iPhone 17 Pro'])(
|
||||
'claim reserves %s without changing the server or booting devices',
|
||||
async selector => {
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
await claimAsync(boot, 'serve-sim', selector);
|
||||
const result = JSON.parse(String(output.mock.calls.at(-1)![0]));
|
||||
expect(result).toMatchObject({
|
||||
status: 'claimed',
|
||||
engine: 'serve-sim',
|
||||
device: { udid: selector === 'iPhone 17 Pro' ? 'PRO' : 'PHONE' },
|
||||
server: { startupDevice: 'PHONE', port: 3200, pid: 90000 },
|
||||
});
|
||||
expect(result.device).not.toHaveProperty('port');
|
||||
expect(
|
||||
jest.mocked(commands.runAsync).mock.calls.filter(([, args]) => args.includes('bootstatus'))
|
||||
).toHaveLength(0);
|
||||
await expect(claimAsync(boot, 'argent')).rejects.toThrow('available');
|
||||
await stopAsync(boot);
|
||||
expect(await completion).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
test.each([
|
||||
[undefined, undefined],
|
||||
['invalid', undefined],
|
||||
['serve-sim', ' '],
|
||||
])('invalid claim %s/%s does not acquire or mutate state', async (engine, device) => {
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
const before = await readLauncherStateAsync(boot);
|
||||
await expect(claimAsync(boot, engine, device)).rejects.toThrow(
|
||||
'Invalid Simulator session request'
|
||||
);
|
||||
expect(await readLauncherStateAsync(boot)).toEqual(before);
|
||||
await stopAsync(boot);
|
||||
await completion;
|
||||
});
|
||||
|
||||
test('the post-adopt preparation command uses the consumed claim and returns toolsReady', async () => {
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
await expect(prepareToolsAsync(boot)).rejects.toThrow('Claim and adopt');
|
||||
await claimAsync(boot, 'agent-device');
|
||||
expect((await readLauncherStateAsync(boot))?.toolsReady).toBeUndefined();
|
||||
await prepareToolsAsync(boot);
|
||||
expect(JSON.parse(String(output.mock.calls.at(-1)![0]))).toMatchObject({
|
||||
status: 'claimed',
|
||||
engine: 'agent-device',
|
||||
toolsReady: true,
|
||||
prepareToolsRequested: true,
|
||||
});
|
||||
await stopAsync(boot);
|
||||
expect(await completion).toBeUndefined();
|
||||
});
|
||||
|
||||
test('prepare-tools returns the validated Argent identity past an earlier stale same-port record', async () => {
|
||||
jest
|
||||
.mocked(createArgentTool)
|
||||
.mockImplementation(jest.requireActual('../utils/argent').createArgentTool);
|
||||
await fs.writeFile(
|
||||
path.join(root, '.argent/tool-server-old.json'),
|
||||
JSON.stringify({ pid: 90099, port: 3201, token: 'stale-token' })
|
||||
);
|
||||
jest
|
||||
.spyOn(fs, 'readdir')
|
||||
.mockResolvedValue(['tool-server-old.json', 'tool-server.json'] as never);
|
||||
jest
|
||||
.mocked(processes.isProcessOwnedByServiceAsync)
|
||||
.mockImplementation(async pid => pid !== 90099);
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
await claimAsync(boot, 'argent');
|
||||
await prepareToolsAsync(boot);
|
||||
expect(JSON.parse(String(output.mock.calls.at(-1)?.[0]))).toMatchObject({
|
||||
toolsReady: true,
|
||||
tools: { pid: 90002, port: 3201, token: 'fixture-token' },
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ headers: { Authorization: 'Bearer stale-token' } })
|
||||
);
|
||||
await stopAsync(boot);
|
||||
expect(await completion).toBeUndefined();
|
||||
});
|
||||
|
||||
test('stop during post-adopt preparation prevents toolsReady and waits for owned drain', async () => {
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
await claimAsync(boot, 'serve-sim');
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
let preparationSignal: AbortSignal;
|
||||
jest
|
||||
.mocked(serveSim.waitForServeSimReadyAsync)
|
||||
.mockImplementationOnce(async (_device, _server, signal) => {
|
||||
preparationSignal = signal;
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
signal.throwIfAborted();
|
||||
throw new Error('unreachable');
|
||||
});
|
||||
const preparing = prepareToolsAsync(boot).catch(error => error);
|
||||
try {
|
||||
await entered.promise;
|
||||
const stopping = stopAsync(boot);
|
||||
await status('stopping');
|
||||
await until(() => preparationSignal.aborted);
|
||||
release.resolve();
|
||||
await stopping;
|
||||
expect(await preparing).toBeInstanceOf(Error);
|
||||
expect((await readLauncherStateAsync(boot))?.toolsReady).not.toBe(true);
|
||||
expect(await completion).toBeUndefined();
|
||||
} finally {
|
||||
release.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
test('claim does not wait for the Expo Go install; prepare-tools does, and stop drains cleanup', async () => {
|
||||
const info = {
|
||||
udid: 'PHONE',
|
||||
bundleIdentifier: 'host.exp.Exponent',
|
||||
shortVersion: '58.0.7',
|
||||
version: '58.0.7',
|
||||
};
|
||||
const install = deferred<typeof info>();
|
||||
const cleanup = deferred();
|
||||
jest.mocked(startExpoGoPreinstall).mockReturnValue({
|
||||
// The default device's preinstall stays open until its cleanup finishes.
|
||||
preinstallOnDeviceAsync: jest.fn(() => cleanup.promise),
|
||||
claimAsync: jest.fn(() => install.promise),
|
||||
stopAsync: jest.fn(() => cleanup.promise),
|
||||
});
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
try {
|
||||
await claimAsync(boot, 'serve-sim');
|
||||
expect(await readLauncherStateAsync(boot)).not.toHaveProperty('preinstalledExpoGo');
|
||||
let prepared = false;
|
||||
const preparing = prepareToolsAsync(boot).then(() => {
|
||||
prepared = true;
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
expect(prepared).toBe(false);
|
||||
install.resolve(info);
|
||||
await preparing;
|
||||
expect(await readLauncherStateAsync(boot)).toMatchObject({
|
||||
toolsReady: true,
|
||||
preinstalledExpoGo: info,
|
||||
});
|
||||
const stopping = stopAsync(boot);
|
||||
await status('stopping');
|
||||
expect((await readLauncherStateAsync(boot))?.status).not.toBe('disabled');
|
||||
cleanup.resolve();
|
||||
await stopping;
|
||||
await completion;
|
||||
} finally {
|
||||
install.resolve(info);
|
||||
cleanup.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
test('preparation failure confirms cleanup before publishing disabled with its cause', async () => {
|
||||
jest
|
||||
.mocked(serveSim.waitForServeSimReadyAsync)
|
||||
.mockRejectedValueOnce(new Error('native readiness failed'));
|
||||
const [completion] = await beginSession();
|
||||
expect(((await completion) as Error).message).toBe('native readiness failed');
|
||||
expect(processes.stopProcessAndDescendantsAsync).toHaveBeenCalled();
|
||||
expect(await readLauncherStateAsync(boot)).toMatchObject({
|
||||
status: 'disabled',
|
||||
error: 'native readiness failed',
|
||||
});
|
||||
});
|
||||
|
||||
test('cleanup failure leaves stopping with a cause and never publishes disabled', async () => {
|
||||
const [completion] = await beginSession();
|
||||
await status('ready');
|
||||
jest
|
||||
.mocked(processes.stopProcessAndDescendantsAsync)
|
||||
.mockRejectedValue(new Error('child not drained'));
|
||||
process.emit('SIGTERM', 'SIGTERM');
|
||||
expect(await completion).toBeInstanceOf(AggregateError);
|
||||
expect(await readLauncherStateAsync(boot)).toMatchObject({
|
||||
status: 'stopping',
|
||||
error: expect.stringContaining('cleanup could not be confirmed'),
|
||||
});
|
||||
});
|
||||
|
||||
test('stop waits on a versionless active state', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({
|
||||
bootUuid: boot,
|
||||
status: 'ready',
|
||||
controllerPid: 9000,
|
||||
}));
|
||||
const stopping = stopAsync(boot);
|
||||
await status('stopping');
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'disabled' }));
|
||||
expect(await stopping).toBe(0);
|
||||
});
|
||||
|
||||
test('--version prints the exact package version without touching boot or state', async () => {
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
expect(await mainAsync(['--version'])).toBe(0);
|
||||
expect(output).toHaveBeenCalledWith(`${require('../../package.json').version}\n`);
|
||||
expect(commands.runAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(['install', 'adopt', 'run'])('rejects removed command %s', async command => {
|
||||
await expect(mainAsync([command])).rejects.toThrow('Usage:');
|
||||
expect(commands.runAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
test.each(['linux', 'win32'])('rejects %s before state or native work', async target => {
|
||||
Object.defineProperty(process, 'platform', { value: target });
|
||||
await expect(mainAsync(['start'])).rejects.toThrow('requires macOS');
|
||||
expect(await fs.readdir(path.join(root, 'control'))).toEqual([]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['Shutdown', true],
|
||||
['Booted', false],
|
||||
])('native shutdown error for %s is confirmed done: %s', async (state, done) => {
|
||||
const failure = new Error('shutdown failed');
|
||||
jest.mocked(commands.runAsync).mockImplementation(async (_file, args) => {
|
||||
if (args.includes('shutdown')) {
|
||||
throw failure;
|
||||
}
|
||||
return JSON.stringify({ devices: { ios: [{ udid: 'OLD', state }] } });
|
||||
});
|
||||
const result = shutdownSimulatorAsync('OLD');
|
||||
if (done) {
|
||||
await expect(result).resolves.toBeUndefined();
|
||||
} else {
|
||||
await expect(result).rejects.toBe(failure);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { startOwnedWrapperAsync } from './fixtures/ownedWrapper';
|
||||
|
||||
it('confirms cleanup when a real owned wrapper propagates intentionally stopped children as exit 23', async () => {
|
||||
const fixture = await startOwnedWrapperAsync();
|
||||
const { job, leaves, realKill } = fixture;
|
||||
try {
|
||||
const kill = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid === job.child.pid && (job.child.exitCode !== null || job.child.signalCode !== null)) {
|
||||
throw new Error('An exited wrapper PID must never be probed or signalled.');
|
||||
}
|
||||
return realKill(pid, signal);
|
||||
});
|
||||
await expect(fixture.stopAsync()).resolves.toBeUndefined();
|
||||
expect(job.child.exitCode).toBe(23);
|
||||
await expect(job.resultPromise).rejects.toMatchObject({ code: 23 });
|
||||
expect(
|
||||
kill.mock.calls.filter(([, signal]) => signal === 'SIGTERM').map(([pid]) => pid)
|
||||
).toEqual(expect.arrayContaining(leaves));
|
||||
for (const pid of leaves) {
|
||||
expect(() => realKill(pid, 0)).toThrow();
|
||||
}
|
||||
} finally {
|
||||
jest.restoreAllMocks();
|
||||
await fixture.cleanupAsync();
|
||||
}
|
||||
}, 10_000);
|
||||
@@ -0,0 +1,640 @@
|
||||
import { type ChildProcess, spawn } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
jest.mock('node:child_process', () => ({ spawn: jest.fn() }));
|
||||
let commands: typeof import('../utils/processes').commands;
|
||||
let stopProcessAndDescendantsAsync: typeof import('../utils/processes').stopProcessAndDescendantsAsync;
|
||||
let terminateProcessGroupAsync: typeof import('../utils/processes').terminateProcessGroupAsync;
|
||||
let child: ChildProcess;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.isolateModules(() => {
|
||||
({
|
||||
commands,
|
||||
stopProcessAndDescendantsAsync,
|
||||
terminateProcessGroupAsync,
|
||||
} = require('../utils/processes'));
|
||||
});
|
||||
child = Object.assign(new EventEmitter(), {
|
||||
pid: 90001,
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
stdout: new PassThrough(),
|
||||
kill: jest.fn().mockReturnValue(true),
|
||||
unref: jest.fn(),
|
||||
}) as unknown as ChildProcess;
|
||||
jest.mocked(spawn).mockReturnValue(child);
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('captures output and waits for close rather than wrapper exit', async () => {
|
||||
const job = commands.start('command', [], { captureStdout: true });
|
||||
let finished = false;
|
||||
const result = job.resultPromise.then(output => {
|
||||
finished = true;
|
||||
return output;
|
||||
});
|
||||
expect(child.listenerCount('close')).toBe(1);
|
||||
child.stdout!.emit('data', Buffer.from('output'));
|
||||
child.emit('exit', 0, null);
|
||||
await Promise.resolve();
|
||||
expect(finished).toBe(false);
|
||||
child.emit('close', 0, null);
|
||||
await expect(result).resolves.toBe('output');
|
||||
expect(() => commands.throwIfCommandCloseTimedOut()).not.toThrow();
|
||||
});
|
||||
|
||||
for (const code of ['ABORT_ERR', 'ENOENT']) {
|
||||
test(`preserves original ${code} error and observes close before awaiting`, async () => {
|
||||
const error = Object.assign(new Error('original failure'), { code });
|
||||
const job = commands.start('command', []);
|
||||
let finished = false;
|
||||
const result = job.resultPromise.catch(error => {
|
||||
finished = true;
|
||||
return error;
|
||||
});
|
||||
expect(child.listenerCount('close')).toBe(1);
|
||||
child.emit('error', error);
|
||||
await Promise.resolve();
|
||||
expect(finished).toBe(false);
|
||||
child.emit('close', null, null);
|
||||
await expect(result).resolves.toBe(error);
|
||||
});
|
||||
}
|
||||
|
||||
test('nonzero exits reject only after close and retain exit code', async () => {
|
||||
const job = commands.start('command', []);
|
||||
const result = job.resultPromise.catch(error => error);
|
||||
child.emit('exit', 7, null);
|
||||
child.emit('close', 7, null);
|
||||
expect((await result).code).toBe(7);
|
||||
});
|
||||
|
||||
for (const event of ['error', 'exit']) {
|
||||
test(`bounded close drain fails teardown even after late close: ${event}`, async () => {
|
||||
const original = new Error('original abort');
|
||||
const job = commands.start('command', []);
|
||||
const result = job.resultPromise.catch(error => error);
|
||||
if (event === 'error') {
|
||||
child.emit('error', original);
|
||||
} else {
|
||||
child.emit('exit', 0, null);
|
||||
}
|
||||
await jest.advanceTimersByTimeAsync(130_000);
|
||||
const failure = await result;
|
||||
if (event === 'error') {
|
||||
expect(failure).toBe(original);
|
||||
} else {
|
||||
expect(failure.message).toContain('did not finish closing');
|
||||
}
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
expect(child.unref).toHaveBeenCalled();
|
||||
expect(() => commands.throwIfCommandCloseTimedOut()).toThrow('did not finish closing');
|
||||
child.emit('close', 0, null);
|
||||
expect(() => commands.throwIfCommandCloseTimedOut()).toThrow('did not finish closing');
|
||||
});
|
||||
}
|
||||
|
||||
test('descendants drain before their parent, then the owned child close is awaited', async () => {
|
||||
const alive = new Set([90001, 90002]);
|
||||
const signals: number[] = [];
|
||||
jest
|
||||
.mocked(child.kill)
|
||||
.mockImplementation(signal => process.kill(child.pid!, signal ?? 'SIGTERM'));
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (!alive.has(pid)) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
signals.push(pid);
|
||||
alive.delete(pid);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002\n' : ''
|
||||
);
|
||||
let closed!: () => void;
|
||||
const resultPromise = new Promise<string>(resolve => {
|
||||
closed = () => resolve('');
|
||||
});
|
||||
let finished = false;
|
||||
const stopping = stopProcessAndDescendantsAsync({ child, resultPromise }).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(signals).toEqual([90002, 90001]);
|
||||
expect(finished).toBe(false);
|
||||
closed();
|
||||
await stopping;
|
||||
expect(finished).toBe(true);
|
||||
});
|
||||
|
||||
test('an exited foreground wrapper is failed teardown; never inspect its possibly reused PID', async () => {
|
||||
Object.defineProperty(child, 'exitCode', { value: 0 });
|
||||
const kill = jest.spyOn(process, 'kill');
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') })
|
||||
).rejects.toThrow('child processes');
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') }, true)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('a descendant that ignores TERM prevents stop success at the deadline', async () => {
|
||||
jest.spyOn(process, 'kill').mockImplementation((_pid, signal) => {
|
||||
if (signal === 'SIGTERM') {
|
||||
Object.defineProperty(child, 'exitCode', { value: 0 });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' ? (args[1] === '90001' ? '90002' : '') : 'S'
|
||||
);
|
||||
const result = stopProcessAndDescendantsAsync({
|
||||
child,
|
||||
resultPromise: Promise.resolve(''),
|
||||
}).catch(error => error);
|
||||
await jest.advanceTimersByTimeAsync(130_100);
|
||||
expect((await result).message).toContain('90002 is still running');
|
||||
expect(jest.mocked(process.kill).mock.calls.filter(([, signal]) => signal === 'SIGTERM')).toEqual(
|
||||
[[90002, 'SIGTERM']]
|
||||
);
|
||||
});
|
||||
|
||||
test('ps failure cannot fabricate descendant teardown', async () => {
|
||||
jest.spyOn(process, 'kill').mockReturnValue(true);
|
||||
jest.spyOn(commands, 'runAsync').mockImplementation(async file => {
|
||||
if (file === 'pgrep') {
|
||||
return '';
|
||||
}
|
||||
throw new Error('ps failed');
|
||||
});
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') })
|
||||
).rejects.toThrow('ps failed');
|
||||
});
|
||||
|
||||
test('normal wrapper exit during drain still drains every known child and awaits owned close without probing its reused PID', async () => {
|
||||
const job = commands.start('wrapper', []);
|
||||
const signals: number[] = [];
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid === child.pid) {
|
||||
throw new Error('Possibly reused wrapper PID must not be probed or signalled.');
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
signals.push(pid);
|
||||
Object.defineProperty(child, 'exitCode', { value: 0 });
|
||||
child.emit('exit', 0, null);
|
||||
return true;
|
||||
}
|
||||
if (signals.includes(pid)) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002\n90003' : ''
|
||||
);
|
||||
let finished = false;
|
||||
const stopping = stopProcessAndDescendantsAsync(job).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(signals).toEqual([90002, 90003]);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
expect(finished).toBe(false);
|
||||
child.emit('close', 0, null);
|
||||
await stopping;
|
||||
expect(finished).toBe(true);
|
||||
});
|
||||
|
||||
test('wrapper exit before completed descendant discovery fails without inspecting discovered PIDs', async () => {
|
||||
const kill = jest.spyOn(process, 'kill');
|
||||
jest.spyOn(commands, 'runAsync').mockImplementation(async () => {
|
||||
Object.defineProperty(child, 'exitCode', { value: 0 });
|
||||
return '90002';
|
||||
});
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') })
|
||||
).rejects.toThrow('before its child processes could be identified');
|
||||
expect(commands.runAsync).toHaveBeenCalledTimes(1);
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ exitCode: 1, signalCode: null },
|
||||
{ exitCode: null, signalCode: 'SIGTERM' },
|
||||
])('unexpected wrapper exit during drain fails closed: %j', async exit => {
|
||||
const signals: number[] = [];
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (signal === 'SIGTERM') {
|
||||
signals.push(pid);
|
||||
Object.assign(child, exit);
|
||||
}
|
||||
if (!signal && signals.includes(pid)) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002' : ''
|
||||
);
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') })
|
||||
).rejects.toThrow('exited unexpectedly while its child processes were stopping');
|
||||
expect(signals).toEqual([90002]);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('normal wrapper exit after draining does not bypass a missing close timeout', async () => {
|
||||
const job = commands.start('wrapper', []);
|
||||
let descendantAlive = true;
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid !== 90002) {
|
||||
throw new Error('Possibly reused wrapper PID must not be probed or signalled.');
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
descendantAlive = false;
|
||||
Object.defineProperty(child, 'exitCode', { value: 0 });
|
||||
child.emit('exit', 0, null);
|
||||
} else if (!descendantAlive) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002' : ''
|
||||
);
|
||||
const stopping = stopProcessAndDescendantsAsync(job).catch(error => error);
|
||||
await jest.advanceTimersByTimeAsync(130_001);
|
||||
expect((await stopping).message).toContain('did not finish closing');
|
||||
child.emit('close', 0, null);
|
||||
expect(() => commands.throwIfCommandCloseTimedOut()).toThrow('did not finish closing');
|
||||
});
|
||||
|
||||
describe('terminateProcessGroupAsync', () => {
|
||||
/** Models the group led by 90001; `members` are its live processes. */
|
||||
function group(members: Set<number>, { ignoresTerm = false, survivesKill = false } = {}) {
|
||||
const signals: [number, string | number | undefined][] = [];
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid !== -90001) {
|
||||
throw new Error(`Only the process group may be signalled, not ${pid}.`);
|
||||
}
|
||||
if (members.size === 0) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
if (signal) {
|
||||
signals.push([pid, signal]);
|
||||
}
|
||||
if ((signal === 'SIGTERM' && !ignoresTerm) || (signal === 'SIGKILL' && !survivesKill)) {
|
||||
members.clear();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return signals;
|
||||
}
|
||||
|
||||
it('signals the whole group once and waits for it to empty', async () => {
|
||||
const signals = group(new Set([90001, 90002, 90003]));
|
||||
await terminateProcessGroupAsync({ child, resultPromise: Promise.resolve('') });
|
||||
expect(signals).toEqual([[-90001, 'SIGTERM']]);
|
||||
});
|
||||
|
||||
it('kills a group that ignores TERM after the grace period', async () => {
|
||||
jest.useRealTimers();
|
||||
const signals = group(new Set([90001, 90004]), { ignoresTerm: true });
|
||||
await terminateProcessGroupAsync({ child, resultPromise: Promise.resolve('') }, 50);
|
||||
expect(signals).toEqual([
|
||||
[-90001, 'SIGTERM'],
|
||||
[-90001, 'SIGKILL'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails when the group survives SIGKILL', async () => {
|
||||
jest.useRealTimers();
|
||||
group(new Set([90001, 90004]), { ignoresTerm: true, survivesKill: true });
|
||||
const stopping = terminateProcessGroupAsync(
|
||||
{ child, resultPromise: Promise.resolve('') },
|
||||
50
|
||||
).catch(error => error);
|
||||
expect((await stopping).message).toContain('process group 90001 is still running');
|
||||
});
|
||||
|
||||
it('never signals a reused PID after its group has exited', async () => {
|
||||
jest.useRealTimers();
|
||||
const members = new Set([90001, 90002]);
|
||||
const signals = group(members);
|
||||
const groupKill = jest.mocked(process.kill).getMockImplementation()!;
|
||||
// After TERM empties the group, 90001's number belongs to an unrelated live process.
|
||||
jest
|
||||
.mocked(process.kill)
|
||||
.mockImplementation((pid, signal) => (pid === 90001 ? true : groupKill(pid, signal)));
|
||||
await terminateProcessGroupAsync({ child, resultPromise: Promise.resolve('') }, 50);
|
||||
expect(signals).toEqual([[-90001, 'SIGTERM']]);
|
||||
expect(jest.mocked(process.kill).mock.calls.some(([pid]) => pid > 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EPERM from exited processes awaiting reap', () => {
|
||||
const eperm = (): never => {
|
||||
throw Object.assign(new Error('kill EPERM'), { code: 'EPERM' });
|
||||
};
|
||||
|
||||
it('treats a group with only zombie members as stopped', async () => {
|
||||
jest.useRealTimers();
|
||||
let terminated = false;
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
expect(pid).toBe(-90001);
|
||||
if (signal === 'SIGTERM') {
|
||||
terminated = true;
|
||||
return true;
|
||||
}
|
||||
// After TERM, the only member left is the leader awaiting reap.
|
||||
return terminated ? eperm() : true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async file => (file === 'ps' ? ' 90001 Z\n 412 Ss\n' : ''));
|
||||
await terminateProcessGroupAsync({ child, resultPromise: Promise.resolve('') }, 50);
|
||||
expect(
|
||||
jest.mocked(process.kill).mock.calls.filter(([, signal]) => signal === 'SIGKILL')
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('still fails closed when a live member cannot be signalled', async () => {
|
||||
jest.useRealTimers();
|
||||
jest.spyOn(process, 'kill').mockImplementation(() => eperm());
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async file => (file === 'ps' ? ' 90001 S\n' : ''));
|
||||
const stopping = terminateProcessGroupAsync(
|
||||
{ child, resultPromise: Promise.resolve('') },
|
||||
50
|
||||
).catch(error => error);
|
||||
expect((await stopping).message).toContain('process group 90001 is still running');
|
||||
});
|
||||
|
||||
it('lets the tree stop finish a descendant that exited before its signal', async () => {
|
||||
jest
|
||||
.spyOn(process, 'kill')
|
||||
.mockImplementation((pid, signal) =>
|
||||
pid === 90002 && signal === 'SIGTERM' ? eperm() : pid === 90002 ? eperm() : true
|
||||
);
|
||||
jest.mocked(child.kill).mockImplementation(() => {
|
||||
Object.defineProperty(child, 'exitCode', { value: 0 });
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' ? (args[1] === '90001' ? '90002\n' : '') : file === 'ps' ? 'Z\n' : ''
|
||||
);
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('serve-sim leads its own process group', () => {
|
||||
const { startServeSim } = require('../utils/serveSim');
|
||||
startServeSim(['PHONE'], new AbortController().signal);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bunx',
|
||||
expect.arrayContaining(['PHONE', '--port', '3200']),
|
||||
expect.objectContaining({ detached: true })
|
||||
);
|
||||
});
|
||||
|
||||
test('the fixture PID guard keeps tests away from the real process.kill', () => {
|
||||
expect(() => process.kill(90002, 0)).toThrow('fixture PID 90002');
|
||||
expect(() => process.kill(-90001, 'SIGKILL')).toThrow('fixture PID -90001');
|
||||
expect(() => process.kill(11, 0)).toThrow('fixture PID 11');
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ exitCode: 23, signalCode: null },
|
||||
{ exitCode: 143, signalCode: null },
|
||||
{ exitCode: null, signalCode: 'SIGTERM' },
|
||||
])(
|
||||
'owned-group wrapper exit after intentional child stop still drains siblings and owned close: %j',
|
||||
async exit => {
|
||||
const job = commands.start('wrapper', [], { captureStdout: true, detached: true });
|
||||
const signalled = new Set<number>();
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid === -90001 || signalled.has(pid)) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
if (pid === child.pid) {
|
||||
throw new Error('An exited wrapper PID must not be probed.');
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
signalled.add(pid);
|
||||
Object.assign(child, exit);
|
||||
child.emit('exit', exit.exitCode, exit.signalCode);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002 90003' : ''
|
||||
);
|
||||
let finished = false;
|
||||
const stopping = stopProcessAndDescendantsAsync(job).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect([...signalled]).toEqual([90002, 90003]);
|
||||
expect(finished).toBe(false);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
child.emit('close', exit.exitCode, exit.signalCode);
|
||||
await stopping;
|
||||
expect(finished).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
test('an owned group cannot excuse wrapper exit before descendant discovery or intentional signals', async () => {
|
||||
const job = commands.start('wrapper', [], { captureStdout: true, detached: true });
|
||||
const kill = jest.spyOn(process, 'kill');
|
||||
jest.spyOn(commands, 'runAsync').mockImplementation(async () => {
|
||||
Object.assign(child, { exitCode: 143 });
|
||||
child.emit('exit', 143, null);
|
||||
return '90002';
|
||||
});
|
||||
await expect(stopProcessAndDescendantsAsync(job)).rejects.toThrow(
|
||||
'before its child processes could be identified'
|
||||
);
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
child.emit('close', 143, null);
|
||||
});
|
||||
|
||||
test.each([0, 143])(
|
||||
'wrapper exit %s before the first intentional signal remains a failure even for an owned group',
|
||||
async exitCode => {
|
||||
const job = commands.start('wrapper', [], { captureStdout: true, detached: true });
|
||||
let signalled = false;
|
||||
const kill = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid === 90001 || pid === -90001) {
|
||||
throw new Error('Exited wrapper must not be probed or signalled.');
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
signalled = true;
|
||||
return true;
|
||||
}
|
||||
if (signalled) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
Object.assign(child, { exitCode });
|
||||
child.emit('exit', exitCode, null);
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002' : ''
|
||||
);
|
||||
await expect(stopProcessAndDescendantsAsync(job)).rejects.toThrow('exited unexpectedly');
|
||||
expect(kill.mock.calls.filter(([, signal]) => signal === 'SIGTERM')).toEqual([
|
||||
[90002, 'SIGTERM'],
|
||||
]);
|
||||
child.emit('close', exitCode, null);
|
||||
}
|
||||
);
|
||||
|
||||
test('owned group retirement drains a child respawned outside the initial snapshot before close', async () => {
|
||||
const job = commands.start('wrapper', [], { captureStdout: true, detached: true });
|
||||
let oldChild = true;
|
||||
let respawn = false;
|
||||
const kill = jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid === 90001 || pid === 90003) {
|
||||
throw new Error('Do not probe an exited wrapper or guess a respawned PID.');
|
||||
}
|
||||
if (pid === -90001) {
|
||||
if (!respawn) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
respawn = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (!oldChild) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
oldChild = false;
|
||||
respawn = true;
|
||||
Object.assign(child, { exitCode: 143 });
|
||||
child.emit('exit', 143, null);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002' : ''
|
||||
);
|
||||
let finished = false;
|
||||
const stopping = stopProcessAndDescendantsAsync(job).then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(respawn).toBe(false);
|
||||
expect(finished).toBe(false);
|
||||
expect(kill.mock.calls.filter(([, signal]) => signal === 'SIGTERM')).toEqual([
|
||||
[90002, 'SIGTERM'],
|
||||
[-90001, 'SIGTERM'],
|
||||
]);
|
||||
child.emit('close', 143, null);
|
||||
await stopping;
|
||||
});
|
||||
|
||||
test('expected owned wrapper termination cannot bypass the sticky close timeout', async () => {
|
||||
const job = commands.start('wrapper', [], { captureStdout: true, detached: true });
|
||||
let stopped = false;
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (pid === -90001 || stopped) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
if (pid !== 90002) {
|
||||
throw new Error('The exited wrapper must not be probed.');
|
||||
}
|
||||
if (signal === 'SIGTERM') {
|
||||
stopped = true;
|
||||
Object.assign(child, { exitCode: 143 });
|
||||
child.emit('exit', 143, null);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (file, args) =>
|
||||
file === 'pgrep' && args[1] === '90001' ? '90002' : ''
|
||||
);
|
||||
const stopping = stopProcessAndDescendantsAsync(job).catch(error => error);
|
||||
await jest.advanceTimersByTimeAsync(130_001);
|
||||
expect((await stopping).message).toContain('did not finish closing');
|
||||
child.emit('close', 143, null);
|
||||
expect(() => commands.throwIfCommandCloseTimedOut()).toThrow('did not finish closing');
|
||||
});
|
||||
|
||||
test('tries every known sibling before reporting a descendant drain failure', async () => {
|
||||
const failure = new Error('first child could not be verified');
|
||||
const signalled = new Set<number>();
|
||||
jest.spyOn(process, 'kill').mockImplementation((pid, signal) => {
|
||||
if (signal === 'SIGTERM') {
|
||||
signalled.add(pid);
|
||||
return true;
|
||||
}
|
||||
if (pid === 90003 && signalled.has(pid)) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ESRCH' });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
jest.spyOn(commands, 'runAsync').mockImplementation(async (file, args) => {
|
||||
if (file === 'pgrep') {
|
||||
return args[1] === '90001' ? '90002 90003' : '';
|
||||
}
|
||||
if (args[1] === '90002') {
|
||||
throw failure;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({ child, resultPromise: Promise.resolve('') })
|
||||
).rejects.toBe(failure);
|
||||
expect([...signalled]).toEqual([90002, 90003]);
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects a process-group ID that does not belong to its owned child before any native probe', async () => {
|
||||
const kill = jest.spyOn(process, 'kill');
|
||||
const run = jest.spyOn(commands, 'runAsync');
|
||||
await expect(
|
||||
stopProcessAndDescendantsAsync({
|
||||
child,
|
||||
resultPromise: Promise.resolve(''),
|
||||
processGroupId: 90002,
|
||||
})
|
||||
).rejects.toThrow('does not belong');
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import {
|
||||
type ChildProcessHandle,
|
||||
commands,
|
||||
isProcessOwnedByServiceAsync,
|
||||
} from '../utils/processes';
|
||||
import { waitForServeSimReadyAsync } from '../utils/serveSim';
|
||||
import { deferred, until } from './helpers';
|
||||
|
||||
jest.mock('node:fs/promises');
|
||||
jest.mock('../utils/processes');
|
||||
let child: ChildProcess;
|
||||
let server: ChildProcessHandle;
|
||||
let controller: AbortController;
|
||||
const state = {
|
||||
pid: 90002,
|
||||
port: 3202,
|
||||
device: 'PHONE',
|
||||
url: 'http://127.0.0.1:3202',
|
||||
token: 'fixture-token',
|
||||
};
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(commands.runAsync).mockResolvedValue('native-start');
|
||||
child = { pid: 90001, exitCode: null, signalCode: null } as ChildProcess;
|
||||
server = { child, resultPromise: new Promise<string>(() => {}) };
|
||||
controller = new AbortController();
|
||||
jest.mocked(fs.readFile).mockResolvedValue(JSON.stringify(state));
|
||||
jest.mocked(isProcessOwnedByServiceAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockImplementation(
|
||||
async () => new Response(JSON.stringify({ status: 'ready', device: 'PHONE' }))
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
controller.abort();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('ignores a matching old server until the new launch owns the registered process', async () => {
|
||||
jest.mocked(isProcessOwnedByServiceAsync).mockResolvedValue(false);
|
||||
let ready = false;
|
||||
const waiting = waitForServeSimReadyAsync('PHONE', server, controller.signal, 3202).then(() => {
|
||||
ready = true;
|
||||
});
|
||||
await until(() => jest.mocked(fs.readFile).mock.calls.length > 0);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(ready).toBe(false);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
jest.mocked(isProcessOwnedByServiceAsync).mockResolvedValue(true);
|
||||
await waiting;
|
||||
expect(fetch).toHaveBeenCalledWith('http://127.0.0.1:3202/readyz', {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects the launch exiting during a successful readiness response without waiting for close', async () => {
|
||||
const response = deferred<Response>();
|
||||
jest.mocked(fetch).mockReturnValue(response.promise);
|
||||
const waiting = waitForServeSimReadyAsync('PHONE', server, controller.signal, 3202);
|
||||
await until(() => jest.mocked(fetch).mock.calls.length > 0);
|
||||
Object.assign(child, { exitCode: 1 });
|
||||
response.resolve(new Response(JSON.stringify({ status: 'ready', device: 'PHONE' })));
|
||||
await expect(waiting).rejects.toThrow('serve-sim exited before PHONE became ready');
|
||||
});
|
||||
|
||||
it('does not probe a state file without a process identity', async () => {
|
||||
jest.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ ...state, pid: undefined }));
|
||||
const waiting = waitForServeSimReadyAsync('PHONE', server, controller.signal, 3202).catch(
|
||||
error => error
|
||||
);
|
||||
await until(() => jest.mocked(fs.readFile).mock.calls.length > 0);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(isProcessOwnedByServiceAsync).not.toHaveBeenCalled();
|
||||
controller.abort();
|
||||
expect((await waiting).code).toBe('ABORT_ERR');
|
||||
});
|
||||
|
||||
it('cannot report ready after cancellation during the response', async () => {
|
||||
const response = deferred<Response>();
|
||||
jest.mocked(fetch).mockReturnValue(response.promise);
|
||||
const waiting = waitForServeSimReadyAsync('PHONE', server, controller.signal, 3202);
|
||||
await until(() => jest.mocked(fetch).mock.calls.length > 0);
|
||||
controller.abort(new Error('cancelled'));
|
||||
response.resolve(new Response(JSON.stringify({ status: 'ready', device: 'PHONE' })));
|
||||
await expect(waiting).rejects.toThrow('cancelled');
|
||||
});
|
||||
|
||||
it('times out rather than accepting a server that never becomes ready', async () => {
|
||||
const now = Date.now();
|
||||
jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValueOnce(now)
|
||||
.mockReturnValue(now + 120_000);
|
||||
await expect(waitForServeSimReadyAsync('PHONE', server, controller.signal, 3202)).rejects.toThrow(
|
||||
'Timed out waiting for serve-sim on PHONE'
|
||||
);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries HTTP errors, a refused connection and readiness for another device', async () => {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new Response('unavailable', { status: 503 }))
|
||||
.mockRejectedValueOnce(new Error('connection refused'))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ status: 'ready', device: 'OTHER' })))
|
||||
.mockImplementation(
|
||||
async () => new Response(JSON.stringify({ status: 'ready', device: 'PHONE' }))
|
||||
);
|
||||
await waitForServeSimReadyAsync('PHONE', server, controller.signal, 3202);
|
||||
expect(fetch).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
|
||||
import { commands, isProcessOwnedByServiceAsync } from '../utils/processes';
|
||||
import { deferred } from './helpers';
|
||||
|
||||
let child: ChildProcess;
|
||||
let controller: AbortController;
|
||||
beforeEach(() => {
|
||||
child = { pid: 90001, exitCode: null, signalCode: null } as ChildProcess;
|
||||
controller = new AbortController();
|
||||
jest.spyOn(commands, 'runAsync');
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('accepts its live owned root without probing a PID', async () => {
|
||||
await expect(isProcessOwnedByServiceAsync(90001, child, controller.signal)).resolves.toBe(true);
|
||||
expect(commands.runAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
it('walks a descendant to its owned root and rejects a foreign tree', async () => {
|
||||
jest.mocked(commands.runAsync).mockResolvedValueOnce('90003').mockResolvedValueOnce('90001');
|
||||
await expect(isProcessOwnedByServiceAsync(90002, child, controller.signal)).resolves.toBe(true);
|
||||
expect(commands.runAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'ps',
|
||||
['-p', '90002', '-o', 'ppid='],
|
||||
controller.signal
|
||||
);
|
||||
jest.mocked(commands.runAsync).mockResolvedValue('1');
|
||||
await expect(isProcessOwnedByServiceAsync(90004, child, controller.signal)).resolves.toBe(false);
|
||||
});
|
||||
it('rejects a root that exits during ancestry discovery without probing its reused PID', async () => {
|
||||
const parent = deferred<string>();
|
||||
jest.mocked(commands.runAsync).mockReturnValue(parent.promise);
|
||||
const owned = isProcessOwnedByServiceAsync(90002, child, controller.signal);
|
||||
Object.assign(child, { exitCode: 1 });
|
||||
parent.resolve('90001');
|
||||
await expect(owned).resolves.toBe(false);
|
||||
expect(commands.runAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('stops at a cycle, a gone process, or cancellation', async () => {
|
||||
jest.mocked(commands.runAsync).mockResolvedValue('90002');
|
||||
await expect(isProcessOwnedByServiceAsync(90002, child, controller.signal)).resolves.toBe(false);
|
||||
jest.mocked(commands.runAsync).mockRejectedValue(Object.assign(new Error('gone'), { code: 1 }));
|
||||
await expect(isProcessOwnedByServiceAsync(90002, child, controller.signal)).resolves.toBe(false);
|
||||
controller.abort(new Error('cancelled'));
|
||||
await expect(isProcessOwnedByServiceAsync(90001, child, controller.signal)).rejects.toThrow(
|
||||
'cancelled'
|
||||
);
|
||||
});
|
||||
it('preserves unexpected process-query failures', async () => {
|
||||
jest.mocked(commands.runAsync).mockRejectedValue(new Error('ps failed'));
|
||||
await expect(isProcessOwnedByServiceAsync(90002, child, controller.signal)).rejects.toThrow(
|
||||
'ps failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('bounds ancestry discovery even when every parent exists but none belongs to this service', async () => {
|
||||
let parent = 90100;
|
||||
jest.mocked(commands.runAsync).mockImplementation(async () => String(parent++));
|
||||
await expect(isProcessOwnedByServiceAsync(90002, child, controller.signal)).resolves.toBe(false);
|
||||
expect(commands.runAsync).toHaveBeenCalledTimes(32);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { selectWarmupSimulators } from '../utils/simulator';
|
||||
|
||||
const devices = [
|
||||
{ udid: 'DUO', name: 'iPhone Duo' },
|
||||
{ udid: 'PHONE18', name: 'iPhone 18' },
|
||||
{ udid: 'PRO18', name: 'iPhone 18 Pro' },
|
||||
];
|
||||
|
||||
test('keeps the shortest available iPhone as the default when unset', () => {
|
||||
expect(selectWarmupSimulators(devices)).toEqual([devices[1]]);
|
||||
});
|
||||
test('splits and trims a list, preserving its default and accepting case-insensitive UDIDs', () => {
|
||||
expect(selectWarmupSimulators(devices, ' iPhone 18 , duo ')).toEqual([devices[1], devices[0]]);
|
||||
});
|
||||
test.each(['iPhone Duo,', ',iPhone 18', 'iPhone Duo, ,iPhone 18'])(
|
||||
'rejects an empty entry in %j',
|
||||
selectors => {
|
||||
expect(() => selectWarmupSimulators(devices, selectors)).toThrow('nonempty');
|
||||
}
|
||||
);
|
||||
test('rejects a missing or ambiguous device instead of choosing arbitrarily', () => {
|
||||
expect(() => selectWarmupSimulators(devices, 'missing')).toThrow('found 0');
|
||||
expect(() =>
|
||||
selectWarmupSimulators([...devices, { udid: 'OTHER', name: 'iPhone Duo' }], 'iPhone Duo')
|
||||
).toThrow('found 2');
|
||||
});
|
||||
test('rejects two selectors for the same device', () => {
|
||||
expect(() => selectWarmupSimulators(devices, 'iPhone Duo,duo')).toThrow('more than once');
|
||||
});
|
||||
test.each(['', ' ', '\n'])('keeps the default iPhone for blank configuration %j', selectors => {
|
||||
expect(selectWarmupSimulators(devices, selectors)).toEqual([devices[1]]);
|
||||
});
|
||||
|
||||
test('observes an orphan-owned boot without requesting a new boot', async () => {
|
||||
const { commands } = await import('../utils/processes');
|
||||
const { waitForSimulatorBootAsync } = await import('../utils/simulator');
|
||||
const run = jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockImplementation(async (_file, args) =>
|
||||
args.includes('devices')
|
||||
? JSON.stringify({ devices: { ios: [{ udid: 'DUO', state: 'Booted' }] } })
|
||||
: ''
|
||||
);
|
||||
try {
|
||||
await waitForSimulatorBootAsync('DUO', new AbortController().signal);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'bootstatus', 'DUO'],
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
expect(run.mock.calls.some(([, args]) => args.includes('-b') || args.includes('boot'))).toBe(
|
||||
false
|
||||
);
|
||||
} finally {
|
||||
run.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a claim can cancel boot observation without issuing a native command', async () => {
|
||||
const { commands } = await import('../utils/processes');
|
||||
const { waitForSimulatorBootAsync } = await import('../utils/simulator');
|
||||
const run = jest
|
||||
.spyOn(commands, 'runAsync')
|
||||
.mockRejectedValue(new Error('unexpected native command'));
|
||||
const cancel = new AbortController();
|
||||
cancel.abort(new Error('claimed'));
|
||||
try {
|
||||
await expect(waitForSimulatorBootAsync('DUO', cancel.signal)).rejects.toThrow('claimed');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
run.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,556 @@
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
|
||||
import { createWarmSimulatorSession } from '../commands/start';
|
||||
import { createArgentTool } from '../utils/argent';
|
||||
import { startExpoGoPreinstall } from '../utils/expoGo';
|
||||
import {
|
||||
type ChildProcessHandle,
|
||||
commands,
|
||||
stopProcessAndDescendantsAsync,
|
||||
} from '../utils/processes';
|
||||
import { startServeSim, waitForServeSimReadyAsync } from '../utils/serveSim';
|
||||
import {
|
||||
disableApsdAsync,
|
||||
shutdownSimulatorAsync,
|
||||
waitForSimulatorBootAsync,
|
||||
} from '../utils/simulator';
|
||||
import { deferred, until } from './helpers';
|
||||
|
||||
jest.mock('../utils/expoGo');
|
||||
jest.mock('../utils/argent');
|
||||
jest.mock('../utils/processes');
|
||||
jest.mock('../utils/serveSim');
|
||||
jest.mock('../utils/simulator', () => ({
|
||||
...jest.requireActual('../utils/simulator'),
|
||||
shutdownSimulatorAsync: jest.fn(),
|
||||
disableApsdAsync: jest.fn(),
|
||||
waitForSimulatorBootAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const defaultDevice = { udid: 'DUO', name: 'iPhone Duo' };
|
||||
const secondary = { udid: 'PHONE18', name: 'iPhone 18' };
|
||||
const otherDevice = { udid: 'OTHER', name: 'iPhone Pro' };
|
||||
const serverInfo = {
|
||||
startupDevice: 'DUO',
|
||||
pid: 10,
|
||||
nativeStartTime: 'server-start',
|
||||
port: 3200,
|
||||
url: 'http://127.0.0.1:3200',
|
||||
token: 'fixture-token',
|
||||
};
|
||||
function handle(pid: number): ChildProcessHandle {
|
||||
return { child: { pid } as ChildProcess, resultPromise: Promise.resolve('') };
|
||||
}
|
||||
const server = handle(10);
|
||||
const agent = handle(11);
|
||||
const argent = handle(12);
|
||||
let controller: AbortController;
|
||||
let env: NodeJS.ProcessEnv;
|
||||
let onPreparationFailure: jest.Mock;
|
||||
const starts = (word: string) =>
|
||||
jest.mocked(commands.start).mock.calls.filter(([, args]) => args.includes(word));
|
||||
function createSession() {
|
||||
return createWarmSimulatorSession({
|
||||
signal: controller.signal,
|
||||
throwIfStoppingAsync: async () => controller.signal.throwIfAborted(),
|
||||
onPreparationFailure,
|
||||
});
|
||||
}
|
||||
|
||||
/** The default device's Expo Go preinstall finishes with `install`; later devices finish at once. */
|
||||
function mockDefaultExpoGoInstall(install: Promise<void>) {
|
||||
const preinstall = {
|
||||
preinstallOnDeviceAsync: jest.fn().mockReturnValueOnce(install).mockResolvedValue(undefined),
|
||||
claimAsync: jest.fn(async () => undefined),
|
||||
stopAsync: jest.fn(async () => {}),
|
||||
};
|
||||
jest.mocked(startExpoGoPreinstall).mockReturnValue(preinstall);
|
||||
return preinstall;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
env = { ...process.env };
|
||||
process.env.EXPO_SIMULATOR_DEVICE = 'iPhone Duo,iPhone 18';
|
||||
process.env.AGENT_DEVICE_STATE_DIR = '/fixture/agent';
|
||||
process.env.ARGENT_EVENT_LOG = '/fixture/argent-events';
|
||||
controller = new AbortController();
|
||||
onPreparationFailure = jest.fn(error => controller.abort(error));
|
||||
jest.mocked(commands.runAsync).mockImplementation(async (_file, args) => {
|
||||
if (args.includes('devices')) {
|
||||
return JSON.stringify({ devices: { ios: [defaultDevice, secondary, otherDevice] } });
|
||||
}
|
||||
if (args.includes('launchctl') && args.includes('list')) {
|
||||
throw Object.assign(new Error('not loaded'), { code: 113 });
|
||||
}
|
||||
return 'controller-start';
|
||||
});
|
||||
jest
|
||||
.mocked(commands.start)
|
||||
.mockImplementation((_file, args) => (args.includes('prepare') ? agent : argent));
|
||||
jest.mocked(createArgentTool).mockImplementation(() => ({
|
||||
name: 'argent',
|
||||
startAsync: async () => {
|
||||
commands.start('bunx', ['argent', 'server']);
|
||||
},
|
||||
stopAsync: async () => {
|
||||
await stopProcessAndDescendantsAsync(argent);
|
||||
},
|
||||
}));
|
||||
jest.mocked(startServeSim).mockReturnValue(server);
|
||||
jest.mocked(waitForServeSimReadyAsync).mockResolvedValue(serverInfo);
|
||||
jest.mocked(waitForSimulatorBootAsync).mockResolvedValue();
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockResolvedValue();
|
||||
jest.mocked(shutdownSimulatorAsync).mockResolvedValue();
|
||||
jest
|
||||
.mocked(disableApsdAsync)
|
||||
.mockImplementation(jest.requireActual('../utils/simulator').disableApsdAsync);
|
||||
jest.mocked(startExpoGoPreinstall).mockReturnValue({
|
||||
preinstallOnDeviceAsync: jest.fn(async () => {}),
|
||||
claimAsync: jest.fn(async () => undefined),
|
||||
stopAsync: jest.fn(async () => {}),
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env = env;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('creates a stoppable owner before any native work and does not start after stop', async () => {
|
||||
const session = createSession();
|
||||
expect(commands.runAsync).not.toHaveBeenCalled();
|
||||
await session.stopAsync();
|
||||
await expect(session.prepareAsync()).rejects.toThrow();
|
||||
expect(startServeSim).not.toHaveBeenCalled();
|
||||
expect(shutdownSimulatorAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stop joins pending device selection without starting a late server', async () => {
|
||||
const listing = deferred<string>();
|
||||
jest.mocked(commands.runAsync).mockReturnValueOnce(listing.promise);
|
||||
const session = createSession();
|
||||
const preparing = session.prepareAsync();
|
||||
void preparing.catch(() => {});
|
||||
let stopped = false;
|
||||
const stopping = session.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
listing.resolve(JSON.stringify({ devices: { ios: [defaultDevice] } }));
|
||||
await stopping;
|
||||
await expect(preparing).rejects.toThrow();
|
||||
expect(startServeSim).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts one orphan with all configured devices and keeps its identity separate from selection', async () => {
|
||||
const session = createSession();
|
||||
const info = await session.prepareAsync();
|
||||
expect(startServeSim).toHaveBeenCalledWith(['DUO', 'PHONE18'], expect.any(AbortSignal));
|
||||
expect(info).toMatchObject({
|
||||
device: defaultDevice,
|
||||
server: serverInfo,
|
||||
controllerPid: process.pid,
|
||||
controllerStartTime: 'controller-start',
|
||||
});
|
||||
const selected = await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'OTHER' });
|
||||
expect(selected.device).toEqual(otherDevice);
|
||||
expect(startServeSim).toHaveBeenCalledTimes(1);
|
||||
expect(commands.runAsync).not.toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
expect.arrayContaining(['bootstatus']),
|
||||
expect.anything()
|
||||
);
|
||||
await session.stopAsync();
|
||||
expect(shutdownSimulatorAsync).toHaveBeenCalledWith('DUO');
|
||||
expect(shutdownSimulatorAsync).toHaveBeenCalledWith('PHONE18');
|
||||
expect(shutdownSimulatorAsync).toHaveBeenCalledWith('OTHER');
|
||||
});
|
||||
|
||||
it('publishes default readiness without joining Expo Go or requested-tool startup', async () => {
|
||||
const install = deferred();
|
||||
const preparing = deferred<string>();
|
||||
mockDefaultExpoGoInstall(install.promise);
|
||||
jest
|
||||
.mocked(commands.start)
|
||||
.mockImplementation((_file, args) =>
|
||||
args.includes('prepare') ? { ...agent, resultPromise: preparing.promise } : argent
|
||||
);
|
||||
const session = createSession();
|
||||
try {
|
||||
await session.prepareAsync();
|
||||
await expect(session.claimAsync({ engine: 'agent-device' })).resolves.toMatchObject({
|
||||
device: defaultDevice,
|
||||
});
|
||||
expect(starts('prepare')).toHaveLength(1);
|
||||
} finally {
|
||||
preparing.resolve('');
|
||||
install.resolve();
|
||||
await session.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('primes the exact version once before readiness', async () => {
|
||||
const priming = deferred<string>();
|
||||
const run = jest.mocked(commands.runAsync).getMockImplementation()!;
|
||||
jest
|
||||
.mocked(commands.runAsync)
|
||||
.mockImplementation((file, args, signal) =>
|
||||
file === 'bun' ? priming.promise : run(file, args, signal)
|
||||
);
|
||||
const session = createSession();
|
||||
let ready = false;
|
||||
const preparing = session.prepareAsync().then(() => {
|
||||
ready = true;
|
||||
});
|
||||
await until(() => jest.mocked(commands.runAsync).mock.calls.some(([file]) => file === 'bun'));
|
||||
expect(ready).toBe(false);
|
||||
expect(jest.mocked(commands.runAsync).mock.calls.filter(([file]) => file === 'bun')).toHaveLength(
|
||||
1
|
||||
);
|
||||
priming.resolve('25.3.0');
|
||||
await preparing;
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('consumed claim forbids the secondary preinstall of a device still booting', async () => {
|
||||
const preinstall = mockDefaultExpoGoInstall(Promise.resolve());
|
||||
jest.mocked(waitForSimulatorBootAsync).mockImplementation(async (udid, signal) => {
|
||||
if (udid === 'PHONE18') {
|
||||
await new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
|
||||
);
|
||||
}
|
||||
});
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await until(() => jest.mocked(waitForSimulatorBootAsync).mock.calls.length === 1);
|
||||
await session.claimAsync({ engine: 'serve-sim' });
|
||||
await session.stopAsync();
|
||||
expect(preinstall.preinstallOnDeviceAsync).not.toHaveBeenCalledWith(
|
||||
'PHONE18',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('a secondary boot observation failure leaves the claimed default usable', async () => {
|
||||
jest
|
||||
.mocked(waitForSimulatorBootAsync)
|
||||
.mockImplementation(async udid =>
|
||||
udid === 'PHONE18' ? Promise.reject(new Error('optional boot failed')) : undefined
|
||||
);
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
// serve-sim's ready covers the default device's boot; only the second device is checked.
|
||||
await until(() => jest.mocked(waitForSimulatorBootAsync).mock.calls.length === 1);
|
||||
expect(waitForSimulatorBootAsync).toHaveBeenCalledWith('PHONE18', expect.any(AbortSignal));
|
||||
await expect(session.claimAsync({ engine: 'serve-sim' })).resolves.toMatchObject({
|
||||
device: defaultDevice,
|
||||
});
|
||||
expect(onPreparationFailure).not.toHaveBeenCalled();
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('claim latches preinstalls before async selection; prepare-tools waits for the selected install', async () => {
|
||||
const installation = deferred<undefined>();
|
||||
const preinstall = startExpoGoPreinstall(controller.signal);
|
||||
jest
|
||||
.mocked(preinstall.claimAsync)
|
||||
.mockImplementation(udid =>
|
||||
udid === 'PHONE18' ? installation.promise : Promise.resolve(undefined)
|
||||
);
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
const claiming = session.claimAsync({ engine: 'serve-sim', requestedDevice: 'iPhone 18' });
|
||||
expect(preinstall.claimAsync).toHaveBeenCalledWith('DUO');
|
||||
expect(preinstall.claimAsync).toHaveBeenCalledWith('PHONE18');
|
||||
expect((await claiming).device).toEqual(secondary);
|
||||
let prepared = false;
|
||||
const preparing = session.prepareToolsAsync().then(() => {
|
||||
prepared = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(prepared).toBe(false);
|
||||
installation.resolve(undefined);
|
||||
await preparing;
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('unused preinstall failure does not reject a default claim, but terminal stop retains uncertainty', async () => {
|
||||
const failure = new Error('unused install not drained');
|
||||
const preinstall = startExpoGoPreinstall(controller.signal);
|
||||
jest.mocked(preinstall.claimAsync).mockImplementation(async udid => {
|
||||
if (udid === 'PHONE18') {
|
||||
throw failure;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
jest.mocked(preinstall.stopAsync).mockImplementation(async udid => {
|
||||
if (udid === 'PHONE18') {
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await expect(session.claimAsync({ engine: 'serve-sim' })).resolves.toMatchObject({
|
||||
device: defaultDevice,
|
||||
});
|
||||
await expect(session.stopAsync()).rejects.toMatchObject({ errors: [failure] });
|
||||
expect(shutdownSimulatorAsync).toHaveBeenCalledWith('DUO');
|
||||
});
|
||||
|
||||
it('EXPO_SIMULATOR_PREINSTALL_EXPO_GO=0 skips the Expo Go preinstall', async () => {
|
||||
process.env.EXPO_SIMULATOR_PREINSTALL_EXPO_GO = '0';
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'serve-sim' });
|
||||
await expect(session.prepareToolsAsync()).resolves.toEqual({});
|
||||
expect(startExpoGoPreinstall).not.toHaveBeenCalled();
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('retains selected preinstall failure instead of handing over an uncertain install', async () => {
|
||||
const failure = new Error('selected install failed');
|
||||
const preinstall = startExpoGoPreinstall(controller.signal);
|
||||
jest.mocked(preinstall.claimAsync).mockRejectedValue(failure);
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'serve-sim' });
|
||||
await expect(session.prepareToolsAsync()).rejects.toBe(failure);
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('reuses the default runner after adoption without preparing it twice', async () => {
|
||||
const preparation = deferred<string>();
|
||||
jest
|
||||
.mocked(commands.start)
|
||||
.mockImplementation((_file, args) =>
|
||||
args.includes('prepare') ? { ...agent, resultPromise: preparation.promise } : argent
|
||||
);
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'agent-device' });
|
||||
let ready = false;
|
||||
const tools = session.prepareToolsAsync().then(() => {
|
||||
ready = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(ready).toBe(false);
|
||||
preparation.resolve('');
|
||||
await tools;
|
||||
expect(starts('prepare')).toHaveLength(1);
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('starts the selected runner only in the post-adopt phase, after old runner retirement', async () => {
|
||||
const retirement = deferred();
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async handle => {
|
||||
if (handle === agent) {
|
||||
await retirement.promise;
|
||||
}
|
||||
});
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'agent-device', requestedDevice: 'PHONE18' });
|
||||
expect(starts('prepare')).toHaveLength(1);
|
||||
let finished = false;
|
||||
const tools = session.prepareToolsAsync().then(() => {
|
||||
finished = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(finished).toBe(false);
|
||||
expect(starts('prepare')).toHaveLength(1);
|
||||
retirement.resolve();
|
||||
await tools;
|
||||
expect(starts('prepare')[1][1]).toEqual(expect.arrayContaining(['--udid', 'PHONE18']));
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('keeps global Argent when selecting another device', async () => {
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'argent', requestedDevice: 'PHONE18' });
|
||||
await session.prepareToolsAsync();
|
||||
expect(starts('server')).toHaveLength(1);
|
||||
expect(stopProcessAndDescendantsAsync).not.toHaveBeenCalledWith(argent);
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('rejects a changed server identity before starting the selected runner', async () => {
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'agent-device', requestedDevice: 'PHONE18' });
|
||||
jest.mocked(waitForServeSimReadyAsync).mockResolvedValueOnce({ ...serverInfo, pid: 99 });
|
||||
await expect(session.prepareToolsAsync()).rejects.toThrow('does not match');
|
||||
expect(starts('prepare')).toHaveLength(1);
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('drains partial preparation, all shutdowns and sibling failures before rejecting cleanup', async () => {
|
||||
const readyFailure = new Error('server not ready');
|
||||
jest.mocked(waitForServeSimReadyAsync).mockRejectedValue(readyFailure);
|
||||
const failure = new Error('server stop failed');
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async handle => {
|
||||
if (handle === server) {
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
const delayed = deferred();
|
||||
jest.mocked(shutdownSimulatorAsync).mockImplementation(async udid => {
|
||||
if (udid === 'PHONE18') {
|
||||
await delayed.promise;
|
||||
}
|
||||
});
|
||||
const session = createSession();
|
||||
await expect(session.prepareAsync()).rejects.toBe(readyFailure);
|
||||
let finished = false;
|
||||
const stopping = session.stopAsync().finally(() => {
|
||||
finished = true;
|
||||
});
|
||||
void stopping.catch(() => {});
|
||||
await until(() => jest.mocked(shutdownSimulatorAsync).mock.calls.length === 2);
|
||||
expect(finished).toBe(false);
|
||||
delayed.resolve();
|
||||
await expect(stopping).rejects.toMatchObject({ errors: [failure] });
|
||||
});
|
||||
|
||||
it('serve-sim tool preparation does not gate on unused tool stops, but terminal cleanup drains them', async () => {
|
||||
const retirement = deferred();
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async handle => {
|
||||
if (handle === agent) {
|
||||
await retirement.promise;
|
||||
}
|
||||
});
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'serve-sim' });
|
||||
await session.prepareToolsAsync();
|
||||
let stopped = false;
|
||||
const stopping = session.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
retirement.resolve();
|
||||
await stopping;
|
||||
});
|
||||
|
||||
it('a failed unused-tool stop after the claim keeps the session and the stop retries it', async () => {
|
||||
const failure = new Error('Argent is still running');
|
||||
const stop = jest.mocked(stopProcessAndDescendantsAsync).mockImplementation(async handle => {
|
||||
if (handle === argent) {
|
||||
throw failure;
|
||||
}
|
||||
});
|
||||
const warning = jest.spyOn(process.stderr, 'write').mockReturnValue(true);
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'serve-sim' });
|
||||
await until(() => warning.mock.calls.some(([text]) => String(text).includes('retrying at stop')));
|
||||
await session.prepareToolsAsync();
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
// The retry fails too, so the stop reports it.
|
||||
await expect(session.stopAsync()).rejects.toMatchObject({ errors: [failure] });
|
||||
expect(stop.mock.calls.filter(([handle]) => handle === argent)).toHaveLength(2);
|
||||
expect(shutdownSimulatorAsync).toHaveBeenCalledWith('DUO');
|
||||
});
|
||||
|
||||
it('starts an owned nonblocking apsd attempt after selected adoption and drains it before shutdown', async () => {
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'OTHER' });
|
||||
const setup = deferred();
|
||||
const disable = jest.mocked(disableApsdAsync).mockImplementation(() => setup.promise);
|
||||
try {
|
||||
await session.prepareToolsAsync();
|
||||
// The setup runs in the background.
|
||||
await until(() => disable.mock.calls.some(([udid]) => udid === 'OTHER'));
|
||||
expect(disable).toHaveBeenCalledWith('OTHER', expect.any(AbortSignal));
|
||||
let stopped = false;
|
||||
const stopping = session.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
expect(shutdownSimulatorAsync).not.toHaveBeenCalled();
|
||||
setup.resolve();
|
||||
await stopping;
|
||||
expect(shutdownSimulatorAsync).toHaveBeenCalledWith('OTHER');
|
||||
} finally {
|
||||
setup.resolve();
|
||||
await session.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not repeat completed default-device setup after adoption', async () => {
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
jest.mocked(disableApsdAsync).mockClear();
|
||||
await session.claimAsync({ engine: 'serve-sim' });
|
||||
await session.prepareToolsAsync();
|
||||
expect(disableApsdAsync).not.toHaveBeenCalled();
|
||||
await session.stopAsync();
|
||||
});
|
||||
|
||||
it('reports ready before apsd finishes; the claim does not wait for it and stop joins it', async () => {
|
||||
const disabled = deferred();
|
||||
const signals = new Map<string, AbortSignal>();
|
||||
const disable = jest.mocked(disableApsdAsync).mockImplementation(
|
||||
(udid, signal) =>
|
||||
new Promise((resolve, reject) => {
|
||||
signals.set(udid, signal);
|
||||
if (udid === 'DUO') {
|
||||
void disabled.promise.then(resolve);
|
||||
}
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||
})
|
||||
);
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await until(() => signals.has('DUO') && signals.has('PHONE18'));
|
||||
// The claim returns while both apsd steps still run; stop cancels and joins them.
|
||||
await session.claimAsync({ engine: 'serve-sim' });
|
||||
expect(signals.get('DUO')!.aborted).toBe(false);
|
||||
expect(signals.get('PHONE18')!.aborted).toBe(false);
|
||||
await session.stopAsync();
|
||||
expect(disable.mock.calls.filter(([udid]) => udid === 'DUO')).toHaveLength(1);
|
||||
expect(signals.get('DUO')!.aborted).toBe(true);
|
||||
expect(signals.get('PHONE18')!.aborted).toBe(true);
|
||||
disabled.resolve();
|
||||
});
|
||||
|
||||
it('does not report a background failure when a stop aborts the default setup', async () => {
|
||||
const disable = jest
|
||||
.mocked(disableApsdAsync)
|
||||
.mockImplementation(
|
||||
(_udid, signal) =>
|
||||
new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
|
||||
)
|
||||
);
|
||||
const session = createSession();
|
||||
const preparing = session.prepareAsync().catch(() => {});
|
||||
await until(() => disable.mock.calls.length > 0);
|
||||
await session.stopAsync();
|
||||
await preparing;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
});
|
||||
|
||||
it('warns on ordinary adopted-device apsd failure without failing the claimed session', async () => {
|
||||
const session = createSession();
|
||||
await session.prepareAsync();
|
||||
await session.claimAsync({ engine: 'serve-sim', requestedDevice: 'OTHER' });
|
||||
jest.mocked(disableApsdAsync).mockRejectedValue(new Error('apsd remains loaded'));
|
||||
const warning = jest.spyOn(process.stderr, 'write').mockReturnValue(true);
|
||||
await session.prepareToolsAsync();
|
||||
// The setup runs in the background; wait for its warning before stopping.
|
||||
await until(() =>
|
||||
warning.mock.calls.some(([text]) => String(text).includes('Could not disable apsd on OTHER'))
|
||||
);
|
||||
await session.stopAsync();
|
||||
expect(onPreparationFailure).not.toHaveBeenCalled();
|
||||
expect(warning.mock.calls.map(([text]) => text).join('')).toContain(
|
||||
'Could not disable apsd on OTHER; continuing'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { claimAsync } from '../commands/claim';
|
||||
import { stopAsync } from '../commands/stop';
|
||||
import {
|
||||
LAUNCHER_STATE_FILE,
|
||||
readLauncherStateAsync,
|
||||
updateLauncherStateAsync,
|
||||
waitForLauncherStateAsync,
|
||||
} from '../utils/state';
|
||||
import { deferred } from './helpers';
|
||||
|
||||
const boot = 'boot-one';
|
||||
let root: string;
|
||||
const info = {
|
||||
device: { udid: 'PHONE', name: 'iPhone 17' },
|
||||
server: {
|
||||
startupDevice: 'PHONE',
|
||||
pid: 9001,
|
||||
nativeStartTime: 'native-start',
|
||||
port: 3200,
|
||||
url: 'http://127.0.0.1:3200',
|
||||
token: 'fixture-token',
|
||||
},
|
||||
controllerPid: 9000,
|
||||
controllerStartTime: 'Thu Oct 22 10:00:00 2026',
|
||||
agentDeviceStateDir: '/agent',
|
||||
argentEventLogPath: '/argent/events',
|
||||
};
|
||||
const directory = () => path.join(root, 'control');
|
||||
|
||||
const nativeFs = {
|
||||
readFile: fs.readFile.bind(fs),
|
||||
writeFile: fs.writeFile.bind(fs),
|
||||
rename: fs.rename.bind(fs),
|
||||
rm: fs.rm.bind(fs),
|
||||
mkdir: fs.mkdir.bind(fs),
|
||||
rmdir: fs.rmdir.bind(fs),
|
||||
stat: fs.stat.bind(fs),
|
||||
};
|
||||
const remap = (file: unknown): unknown =>
|
||||
typeof file === 'string' && file.startsWith(LAUNCHER_STATE_FILE)
|
||||
? path.join(root, 'control', path.basename(file))
|
||||
: file;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'launcher-state-'));
|
||||
jest.spyOn(os, 'homedir').mockReturnValue(root);
|
||||
await fs.mkdir(path.join(root, 'control'));
|
||||
for (const name of ['readFile', 'writeFile', 'rename', 'rm', 'mkdir', 'rmdir', 'stat'] as const) {
|
||||
jest.spyOn(fs, name).mockImplementation(((...args: unknown[]) => {
|
||||
args[0] = remap(args[0]);
|
||||
if (name === 'rename') {
|
||||
args[1] = remap(args[1]);
|
||||
}
|
||||
return (nativeFs[name] as (...args: unknown[]) => unknown)(...args);
|
||||
}) as never);
|
||||
}
|
||||
});
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('missing and old-boot state are unavailable, while access errors propagate', async () => {
|
||||
expect(await readLauncherStateAsync(boot)).toBeUndefined();
|
||||
await updateLauncherStateAsync('old-boot', () => ({ bootUuid: 'old-boot', status: 'disabled' }));
|
||||
expect(await readLauncherStateAsync(boot)).toBeUndefined();
|
||||
const error = Object.assign(new Error('state inaccessible'), { code: 'EACCES' });
|
||||
jest.spyOn(fs, 'readFile').mockRejectedValueOnce(error);
|
||||
await expect(readLauncherStateAsync(boot)).rejects.toBe(error);
|
||||
});
|
||||
|
||||
test('old-boot locks are ignored without being removed or stolen', async () => {
|
||||
await fs.mkdir(`${LAUNCHER_STATE_FILE}.old-boot.lock`, { recursive: true });
|
||||
await stopAsync(boot);
|
||||
expect(await readLauncherStateAsync(boot)).toEqual({ bootUuid: boot, status: 'disabled' });
|
||||
expect((await fs.stat(`${LAUNCHER_STATE_FILE}.old-boot.lock`)).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
test('a current-boot crash lock times out fail-closed and remains untouched', async () => {
|
||||
const lock = `${LAUNCHER_STATE_FILE}.${boot}.lock`;
|
||||
await fs.mkdir(lock, { recursive: true });
|
||||
let calls = 0;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => (calls++ ? 5_001 : 0));
|
||||
await expect(stopAsync(boot)).rejects.toThrow('state is locked');
|
||||
expect((await fs.stat(lock)).isDirectory()).toBe(true);
|
||||
expect(await readLauncherStateAsync(boot)).toBeUndefined();
|
||||
});
|
||||
|
||||
for (const operation of ['callback', 'writeFile', 'rename'] as const) {
|
||||
test(`${operation} failure preserves old state and releases only the owned temporary files/lock`, async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'starting' }));
|
||||
const error = Object.assign(new Error('disk error'), { code: 'EIO' });
|
||||
if (operation !== 'callback') {
|
||||
jest.spyOn(fs, operation).mockRejectedValueOnce(error);
|
||||
}
|
||||
await expect(
|
||||
updateLauncherStateAsync(boot, state => {
|
||||
if (operation === 'callback') {
|
||||
throw error;
|
||||
}
|
||||
return { ...state!, status: 'ready' };
|
||||
})
|
||||
).rejects.toBe(error);
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe('starting');
|
||||
expect(await fs.readdir(directory())).toEqual([path.basename(LAUNCHER_STATE_FILE)]);
|
||||
});
|
||||
}
|
||||
|
||||
test('cancellation after the temporary write cannot publish the next state', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'starting' }));
|
||||
const abort = new AbortController();
|
||||
const write = nativeFs.writeFile;
|
||||
jest.spyOn(fs, 'writeFile').mockImplementation((async (file, data, ...args) => {
|
||||
await write(remap(file) as string, data, ...args);
|
||||
abort.abort(new Error('cancelled'));
|
||||
}) as typeof fs.writeFile);
|
||||
await expect(
|
||||
updateLauncherStateAsync(boot, state => ({ ...state!, status: 'ready' }), abort.signal)
|
||||
).rejects.toThrow('cancelled');
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe('starting');
|
||||
expect(await fs.readdir(directory())).toEqual([path.basename(LAUNCHER_STATE_FILE)]);
|
||||
});
|
||||
|
||||
test.each(['ready', 'claimed'] as const)(
|
||||
'a pending %s writer publishes complete JSON before a stop, which cannot be overwritten',
|
||||
async publication => {
|
||||
await updateLauncherStateAsync(boot, () => ({
|
||||
bootUuid: boot,
|
||||
status: publication === 'ready' ? 'starting' : 'claiming',
|
||||
engine: 'serve-sim',
|
||||
...info,
|
||||
}));
|
||||
const writing = deferred();
|
||||
const release = deferred();
|
||||
const write = nativeFs.writeFile;
|
||||
jest.spyOn(fs, 'writeFile').mockImplementation((async (file, data, ...args) => {
|
||||
if (JSON.parse(String(data)).status === publication) {
|
||||
await write(remap(file) as string, '{"bootUuid":');
|
||||
writing.resolve();
|
||||
await release.promise;
|
||||
await write(remap(file) as string, data);
|
||||
return;
|
||||
}
|
||||
await write(remap(file) as string, data, ...args);
|
||||
}) as typeof fs.writeFile);
|
||||
const ready = updateLauncherStateAsync(boot, state => ({ ...state!, status: publication }));
|
||||
try {
|
||||
await writing.promise;
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe(
|
||||
publication === 'ready' ? 'starting' : 'claiming'
|
||||
);
|
||||
const stopping = stopAsync(boot);
|
||||
release.resolve();
|
||||
await ready;
|
||||
await waitForLauncherStateAsync(boot, state => state?.status === 'stopping', 1_000);
|
||||
await expect(
|
||||
updateLauncherStateAsync(boot, state => {
|
||||
if (state?.status !== 'ready') {
|
||||
throw new Error('stop already won');
|
||||
}
|
||||
return { ...state, status: 'claimed' };
|
||||
})
|
||||
).rejects.toThrow('stop already won');
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'disabled' }));
|
||||
expect(await stopping).toBe(0);
|
||||
} finally {
|
||||
release.resolve();
|
||||
await ready;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test('two job claims acquire only one ready session and the result includes its metadata', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'ready', ...info }));
|
||||
const output = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
const results: unknown[] = [];
|
||||
const claims = ['serve-sim', 'argent'].map(engine =>
|
||||
claimAsync(boot, engine).then(
|
||||
value => results.push(value),
|
||||
error => results.push(error)
|
||||
)
|
||||
);
|
||||
await waitForLauncherStateAsync(boot, state => state?.status === 'claiming', 1_000);
|
||||
for (let i = 0; i < 100 && results.length === 0; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
expect(results[0]).toBeInstanceOf(Error);
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'claimed' }));
|
||||
await Promise.all(claims);
|
||||
expect(results.filter(value => value === 0)).toHaveLength(1);
|
||||
expect(JSON.parse(String(output.mock.calls[0][0]))).toMatchObject({
|
||||
...info,
|
||||
status: 'claimed',
|
||||
bootUuid: boot,
|
||||
});
|
||||
await expect(claimAsync(boot, 'serve-sim')).rejects.toThrow('available');
|
||||
});
|
||||
|
||||
test('a claim fails when the launcher hands over a different device than requested', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'ready', ...info }));
|
||||
const claim = expect(claimAsync(boot, 'serve-sim', 'iPad Pro')).rejects.toThrow(
|
||||
'claimed iPhone 17, not iPad Pro'
|
||||
);
|
||||
await waitForLauncherStateAsync(boot, state => state?.status === 'claiming', 1_000);
|
||||
await updateLauncherStateAsync(boot, state => ({ ...state!, status: 'claimed' }));
|
||||
await claim;
|
||||
});
|
||||
|
||||
test('an interrupted claim reports the launcher error', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'ready', ...info }));
|
||||
const claim = expect(claimAsync(boot, 'serve-sim')).rejects.toThrow(
|
||||
'cleanup may still be running. serve-sim exited'
|
||||
);
|
||||
await waitForLauncherStateAsync(boot, state => state?.status === 'claiming', 1_000);
|
||||
await updateLauncherStateAsync(boot, state => ({
|
||||
...state!,
|
||||
status: 'stopping',
|
||||
error: 'serve-sim exited',
|
||||
}));
|
||||
await claim;
|
||||
});
|
||||
|
||||
test.each([
|
||||
[undefined, undefined],
|
||||
['unsupported', undefined],
|
||||
['serve-sim', ' '],
|
||||
])(
|
||||
'invalid claim engine/selector %s/%s cannot mutate or acquire ready state',
|
||||
async (engine, selector) => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'ready', ...info }));
|
||||
const before = await readLauncherStateAsync(boot);
|
||||
await expect(claimAsync(boot, engine, selector)).rejects.toThrow(
|
||||
'Invalid Simulator session request'
|
||||
);
|
||||
expect(await readLauncherStateAsync(boot)).toEqual(before);
|
||||
expect(await fs.readdir(directory())).toEqual([path.basename(LAUNCHER_STATE_FILE)]);
|
||||
}
|
||||
);
|
||||
|
||||
test('concurrent resource-free stops leave one disabled record without extra ownership files', async () => {
|
||||
expect(await Promise.all([stopAsync(boot), stopAsync(boot)])).toEqual([0, 0]);
|
||||
expect(await fs.readdir(directory())).toEqual([path.basename(LAUNCHER_STATE_FILE)]);
|
||||
expect(await readLauncherStateAsync(boot)).toEqual({ bootUuid: boot, status: 'disabled' });
|
||||
});
|
||||
|
||||
test('stopping is not cleanup completion and polling stays bounded', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({
|
||||
bootUuid: boot,
|
||||
status: 'stopping',
|
||||
error: 'cleanup incomplete',
|
||||
}));
|
||||
await expect(
|
||||
waitForLauncherStateAsync(boot, state => state?.status === 'disabled', 1)
|
||||
).rejects.toThrow('cleanup may still be running');
|
||||
expect((await readLauncherStateAsync(boot))?.status).toBe('stopping');
|
||||
});
|
||||
|
||||
test('the single state record keeps server credentials private to its OS user', async () => {
|
||||
await updateLauncherStateAsync(boot, () => ({ bootUuid: boot, status: 'ready', ...info }));
|
||||
expect((await fs.stat(LAUNCHER_STATE_FILE)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
|
||||
import { createAgentDeviceTool } from '../utils/agentDevice';
|
||||
import { commands, stopProcessAndDescendantsAsync } from '../utils/processes';
|
||||
import { type WarmTool, startTool } from '../utils/tools';
|
||||
import { deferred } from './helpers';
|
||||
|
||||
jest.mock('../utils/processes');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(commands.runAsync).mockResolvedValue('');
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockResolvedValue();
|
||||
});
|
||||
|
||||
it('prepares at once, starts after serve-sim, and reports a failure before cancellation', async () => {
|
||||
const serveSimReady = deferred();
|
||||
const failure = new Error('start failed');
|
||||
const tool: WarmTool = {
|
||||
name: 'argent',
|
||||
prepareAsync: jest.fn(async () => {}),
|
||||
startAsync: jest.fn(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
stopAsync: jest.fn(async () => {}),
|
||||
};
|
||||
const onFailure = jest.fn();
|
||||
const started = startTool(
|
||||
tool,
|
||||
'PHONE',
|
||||
serveSimReady.promise,
|
||||
new AbortController().signal,
|
||||
onFailure
|
||||
);
|
||||
expect(tool.prepareAsync).toHaveBeenCalledTimes(1);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(tool.startAsync).not.toHaveBeenCalled();
|
||||
serveSimReady.resolve();
|
||||
await expect(started.ready).rejects.toBe(failure);
|
||||
expect(tool.startAsync).toHaveBeenCalledWith('PHONE', expect.any(AbortSignal));
|
||||
expect(onFailure).toHaveBeenCalledWith(failure);
|
||||
});
|
||||
|
||||
it('stops agent-device mid-prepare without reporting it, and stops the daemon', async () => {
|
||||
const preparing = deferred<string>();
|
||||
const prepare = { child: { pid: 11 } as ChildProcess, resultPromise: preparing.promise };
|
||||
jest.mocked(commands.start).mockReturnValue(prepare);
|
||||
const onFailure = jest.fn();
|
||||
const started = startTool(
|
||||
createAgentDeviceTool(),
|
||||
'PHONE',
|
||||
Promise.resolve(),
|
||||
new AbortController().signal,
|
||||
onFailure
|
||||
);
|
||||
try {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(commands.start).toHaveBeenCalledTimes(1);
|
||||
expect(commands.start).toHaveBeenCalledWith(
|
||||
'bunx',
|
||||
expect.arrayContaining(['prepare', 'ios-runner']),
|
||||
{ detached: true }
|
||||
);
|
||||
// The prepare process is still running; the stop does not wait for it to exit by itself.
|
||||
await started.stopAsync();
|
||||
expect(stopProcessAndDescendantsAsync).toHaveBeenCalledWith(prepare, true);
|
||||
expect(commands.runAsync).toHaveBeenCalledWith(
|
||||
'bunx',
|
||||
expect.arrayContaining(['daemon', 'stop']),
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
expect(onFailure).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
preparing.resolve('');
|
||||
}
|
||||
});
|
||||
|
||||
it('stops the agent-device daemon even when the prepare process cannot be stopped', async () => {
|
||||
const prepare = { child: { pid: 11 } as ChildProcess, resultPromise: Promise.resolve('') };
|
||||
const failure = new Error('prepare stop failed');
|
||||
jest.mocked(commands.start).mockReturnValue(prepare);
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockRejectedValueOnce(failure);
|
||||
const tool = createAgentDeviceTool();
|
||||
await tool.startAsync('PHONE', new AbortController().signal);
|
||||
await expect(tool.stopAsync()).rejects.toBe(failure);
|
||||
expect(commands.runAsync).toHaveBeenCalledWith(
|
||||
'bunx',
|
||||
expect.arrayContaining(['daemon', 'stop']),
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
// The prepare process stays owned, so a later stop tries it again.
|
||||
await tool.stopAsync();
|
||||
expect(jest.mocked(stopProcessAndDescendantsAsync).mock.calls).toEqual([
|
||||
[prepare, true],
|
||||
[prepare, true],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a synchronous prepare throw through ready instead of throwing from startTool', async () => {
|
||||
const failure = new Error('prepare threw');
|
||||
const tool: WarmTool = {
|
||||
name: 'argent',
|
||||
prepareAsync: () => {
|
||||
throw failure;
|
||||
},
|
||||
startAsync: jest.fn(async () => {}),
|
||||
stopAsync: jest.fn(async () => {}),
|
||||
};
|
||||
const onFailure = jest.fn();
|
||||
const started = startTool(
|
||||
tool,
|
||||
'PHONE',
|
||||
Promise.resolve(),
|
||||
new AbortController().signal,
|
||||
onFailure
|
||||
);
|
||||
await expect(started.ready).rejects.toBe(failure);
|
||||
expect(tool.startAsync).not.toHaveBeenCalled();
|
||||
expect(onFailure).toHaveBeenCalledWith(failure);
|
||||
});
|
||||
|
||||
it('keeps both errors when the agent-device prepare and daemon stops fail', async () => {
|
||||
const prepare = { child: { pid: 11 } as ChildProcess, resultPromise: Promise.resolve('') };
|
||||
const prepareFailure = new Error('prepare stop failed');
|
||||
const daemonFailure = new Error('daemon stop failed');
|
||||
jest.mocked(commands.start).mockReturnValue(prepare);
|
||||
jest.mocked(stopProcessAndDescendantsAsync).mockRejectedValueOnce(prepareFailure);
|
||||
jest.mocked(commands.runAsync).mockRejectedValueOnce(daemonFailure);
|
||||
const tool = createAgentDeviceTool();
|
||||
await tool.startAsync('PHONE', new AbortController().signal);
|
||||
await expect(tool.stopAsync()).rejects.toMatchObject({ errors: [prepareFailure, daemonFailure] });
|
||||
});
|
||||
|
||||
it('drains independent tool preparation after serve-sim readiness rejects', async () => {
|
||||
const prepared = deferred();
|
||||
const failure = new Error('serve-sim readiness failed');
|
||||
const tool: WarmTool = {
|
||||
name: 'argent',
|
||||
prepareAsync: () => prepared.promise,
|
||||
startAsync: jest.fn(async () => {}),
|
||||
stopAsync: jest.fn(async () => {}),
|
||||
};
|
||||
const started = startTool(
|
||||
tool,
|
||||
'PHONE',
|
||||
Promise.reject(failure),
|
||||
new AbortController().signal,
|
||||
jest.fn()
|
||||
);
|
||||
await expect(started.ready).rejects.toBe(failure);
|
||||
let stopped = false;
|
||||
const stopping = started.stopAsync().then(() => {
|
||||
stopped = true;
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopped).toBe(false);
|
||||
expect(tool.stopAsync).not.toHaveBeenCalled();
|
||||
prepared.resolve();
|
||||
await stopping;
|
||||
expect(tool.startAsync).not.toHaveBeenCalled();
|
||||
expect(tool.stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
ClaimInputSchema,
|
||||
WarmSessionInfoSchema,
|
||||
updateLauncherStateAsync,
|
||||
waitForLauncherStateAsync,
|
||||
} from '../utils/state';
|
||||
|
||||
export async function claimAsync(
|
||||
bootUuid: string,
|
||||
engine?: string,
|
||||
requestedDevice?: string
|
||||
): Promise<number> {
|
||||
const input = ClaimInputSchema.safeParse({ engine, requestedDevice });
|
||||
if (!input.success) {
|
||||
throw new Error(
|
||||
`Invalid Simulator session request. Choose ${ClaimInputSchema.shape.engine.options.join(', ')} and an optional device name or UDID.`
|
||||
);
|
||||
}
|
||||
const acquired = await updateLauncherStateAsync(bootUuid, state => {
|
||||
if (state?.status !== 'ready') {
|
||||
throw new Error('No warm Simulator session is available to claim on this VM.');
|
||||
}
|
||||
WarmSessionInfoSchema.parse(state);
|
||||
return { ...state, ...input.data, status: 'claiming' };
|
||||
});
|
||||
const claimed = await waitForLauncherStateAsync(
|
||||
bootUuid,
|
||||
state => {
|
||||
if (!state || state.status === 'stopping' || state.status === 'disabled') {
|
||||
throw new Error(
|
||||
`Simulator preparation was interrupted; cleanup may still be running.${state?.error ? ` ${state.error}` : ''}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
state.controllerPid !== acquired.controllerPid ||
|
||||
state.controllerStartTime !== acquired.controllerStartTime ||
|
||||
state.engine !== engine
|
||||
) {
|
||||
throw new Error('The Simulator launcher returned a different session or engine.');
|
||||
}
|
||||
return state.status === 'claimed';
|
||||
},
|
||||
420_000
|
||||
);
|
||||
const { device } = WarmSessionInfoSchema.parse(claimed);
|
||||
if (
|
||||
requestedDevice &&
|
||||
device.name !== requestedDevice &&
|
||||
device.udid.toLowerCase() !== requestedDevice.toLowerCase()
|
||||
) {
|
||||
throw new Error(`The Simulator launcher claimed ${device.name}, not ${requestedDevice}.`);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(claimed)}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Continue the consumed claim after private serve-sim adoption, without acquiring another job. */
|
||||
export async function prepareToolsAsync(bootUuid: string): Promise<number> {
|
||||
const acquired = await updateLauncherStateAsync(bootUuid, state => {
|
||||
if (state?.status !== 'claimed') {
|
||||
throw new Error('Claim and adopt the warm Simulator session before preparing its tools.');
|
||||
}
|
||||
WarmSessionInfoSchema.parse(state);
|
||||
return { ...state, prepareToolsRequested: true };
|
||||
});
|
||||
const ready = await waitForLauncherStateAsync(
|
||||
bootUuid,
|
||||
state => {
|
||||
if (
|
||||
!state ||
|
||||
state.status !== 'claimed' ||
|
||||
state.controllerPid !== acquired.controllerPid ||
|
||||
state.controllerStartTime !== acquired.controllerStartTime ||
|
||||
state.device?.udid !== acquired.device?.udid ||
|
||||
state.engine !== acquired.engine
|
||||
) {
|
||||
throw new Error(
|
||||
'Warm Simulator tool preparation was interrupted; cleanup may still be running.'
|
||||
);
|
||||
}
|
||||
return state.toolsReady === true;
|
||||
},
|
||||
420_000
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify(ready)}\n`);
|
||||
return 0;
|
||||
}
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user