mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62d46fece5 | ||
|
|
d66dc2920f | ||
|
|
d725a6c7f4 | ||
|
|
f93b75b9a6 | ||
|
|
a323655f7c | ||
|
|
4bb500ae01 | ||
|
|
a207e5d62e | ||
|
|
f81efbacde | ||
|
|
06c6a8416d |
No files matched your search
@@ -8,6 +8,14 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Support drill-down from event lists in observe:session. ([#3987](https://github.com/expo/eas-cli/pull/3987) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [21.0.2](https://github.com/expo/eas-cli/releases/tag/v21.0.2) - 2026-07-17
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Remove temporary working directories after `eas build:inspect` copies inspect output, avoiding leftover disk usage after failed builds. ([#3981](https://github.com/expo/eas-cli/pull/3981) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
@@ -42,6 +50,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas env:set`, which creates or updates an environment variable in one command. `eas env:create` and `eas env:update` are now deprecated. ([#4002](https://github.com/expo/eas-cli/pull/4002) by [@jonsamp](https://github.com/jonsamp))
|
||||
- [eas-cli] Improve `eas workflow:create`: add a `--template` flag, generate a placeholder workflow when a file name is passed, use shorter default file names (`build.yml`, `update.yml`, `deploy.yml`), configure EAS Build and EAS Update automatically when the chosen template requires them, set default app identifiers without prompting for the development build and deploy templates, install `expo-dev-client` during development build setup, and tighten the generated comments and next steps. ([#3943](https://github.com/expo/eas-cli/pull/3943) by [@jonsamp](https://github.com/jonsamp))
|
||||
- [eas-cli] `eas integrations:posthog:dashboard` now opens PostHog via a signed-in link, skipping the login prompt. ([#3975](https://github.com/expo/eas-cli/pull/3975) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/posthog_capture_event` workflow function to send PostHog events from workflow runs. ([#3934](https://github.com/expo/eas-cli/pull/3934) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "21.0.2",
|
||||
"version": "21.0.3",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "21.0.2",
|
||||
"version": "21.0.3",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -39,14 +39,14 @@
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "21.0.0",
|
||||
"@expo/eas-build-job": "21.0.2",
|
||||
"@expo/eas-build-job": "21.0.3",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.0.2",
|
||||
"@expo/steps": "21.0.3",
|
||||
"@expo/template-file": "21.0.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { restoreCredentials } from '../../android/credentials';
|
||||
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
|
||||
import androidBuilder from '../android';
|
||||
import { runBuilderWithHooksAsync } from '../common';
|
||||
import { setupAsync } from '../../common/setup';
|
||||
import {
|
||||
injectConfigureVersionGradleConfig,
|
||||
injectCredentialsGradleConfig,
|
||||
@@ -89,6 +90,48 @@ describe(androidBuilder, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the install_node_modules anchor and a hooks ref to setupAsync', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(setupAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
wrappedAnchors: ['install_node_modules'],
|
||||
jobHooksRef: expect.objectContaining({ current: null }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('drains queued hook metric uploads even when setup fails after assigning the ref', async () => {
|
||||
const drainPendingMetricUploads = jest.fn();
|
||||
jest.mocked(setupAsync).mockImplementationOnce(async (_ctx, { jobHooksRef }) => {
|
||||
// A before-hook can queue a metric upload and then fail setup; the drain
|
||||
// must still run.
|
||||
jobHooksRef.current = {
|
||||
customBuildContext: { drainPendingMetricUploads },
|
||||
} as never;
|
||||
throw new Error('setup boom');
|
||||
});
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
|
||||
await expect(androidBuilder(ctx)).rejects.toThrow('setup boom');
|
||||
expect(drainPendingMetricUploads).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('injects Android version config without build credentials', async () => {
|
||||
const job: Android.Job = {
|
||||
...createTestAndroidJob(),
|
||||
|
||||
@@ -10,11 +10,11 @@ import {
|
||||
resolveGradleCommand,
|
||||
runGradleCommand,
|
||||
} from '../android/gradle';
|
||||
import { parseGradleProfile, formatGradleProfileReport } from '../android/gradleProfile';
|
||||
import { formatGradleProfileReport, parseGradleProfile } from '../android/gradleProfile';
|
||||
import { Sentry } from '../sentry';
|
||||
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
|
||||
import { prebuildAsync } from '../common/prebuild';
|
||||
import { setupAsync } from '../common/setup';
|
||||
import { JobHooksRef, setupAsync } from '../common/setup';
|
||||
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
@@ -50,7 +50,21 @@ export default async function androidBuilder(ctx: BuildContext<Android.Job>): Pr
|
||||
}
|
||||
|
||||
async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
await setupAsync(ctx);
|
||||
const jobHooksRef: JobHooksRef = { current: null };
|
||||
try {
|
||||
await buildInnerAsync(ctx, jobHooksRef);
|
||||
} finally {
|
||||
// Native builds have no other drain path for the hook context's queued
|
||||
// step-metric uploads.
|
||||
await jobHooksRef.current?.customBuildContext.drainPendingMetricUploads();
|
||||
}
|
||||
}
|
||||
|
||||
async function buildInnerAsync(
|
||||
ctx: BuildContext<Android.Job>,
|
||||
jobHooksRef: JobHooksRef
|
||||
): Promise<void> {
|
||||
await setupAsync(ctx, { wrappedAnchors: ['install_node_modules'], jobHooksRef });
|
||||
const evictUsedBefore = new Date();
|
||||
const workingDirectory = ctx.getReactNativeProjectDirectory();
|
||||
const hasNativeCode = ctx.job.type === Workflow.GENERIC;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { runBuilderWithHooksAsync } from './common';
|
||||
import { runCustomBuildAsync } from './custom';
|
||||
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
|
||||
import { prebuildAsync } from '../common/prebuild';
|
||||
import { setupAsync } from '../common/setup';
|
||||
import { JobHooksRef, setupAsync } from '../common/setup';
|
||||
import { Artifacts, BuildContext } from '../context';
|
||||
import { configureXcodeProject } from '../ios/configure';
|
||||
import CredentialsManager from '../ios/credentials/manager';
|
||||
@@ -49,7 +49,22 @@ export default async function iosBuilder(ctx: BuildContext<Ios.Job>): Promise<Ar
|
||||
}
|
||||
|
||||
async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
await setupAsync(ctx);
|
||||
const jobHooksRef: JobHooksRef = { current: null };
|
||||
try {
|
||||
await buildInnerAsync(ctx, jobHooksRef);
|
||||
} finally {
|
||||
// Native builds have no other drain path for the hook context's queued
|
||||
// step-metric uploads. Must wrap the WHOLE body — iOS's existing inner
|
||||
// try/finally only starts after setupAsync returns.
|
||||
await jobHooksRef.current?.customBuildContext.drainPendingMetricUploads();
|
||||
}
|
||||
}
|
||||
|
||||
async function buildInnerAsync(
|
||||
ctx: BuildContext<Ios.Job>,
|
||||
jobHooksRef: JobHooksRef
|
||||
): Promise<void> {
|
||||
await setupAsync(ctx, { wrappedAnchors: ['install_node_modules'], jobHooksRef });
|
||||
const hasNativeCode = ctx.job.type === Workflow.GENERIC;
|
||||
const evictUsedBefore = new Date();
|
||||
const credentialsManager = new CredentialsManager(ctx);
|
||||
|
||||
@@ -196,4 +196,67 @@ describe('easBuildInternal', () => {
|
||||
|
||||
expect((newJob as any).refreshAdHocProvisioningProfile).toBe(true);
|
||||
});
|
||||
|
||||
describe('hooks retention', () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), child: jest.fn() } as any;
|
||||
const originalHooks = { before_install_node_modules: [{ run: 'echo original' }] };
|
||||
|
||||
function mockRegeneratedJob(hooks?: object): void {
|
||||
const internalJob = {
|
||||
platform: Platform.IOS,
|
||||
type: Workflow.GENERIC,
|
||||
triggeredBy: 'EAS_CLI',
|
||||
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
|
||||
projectRootDirectory: '.',
|
||||
secrets: {
|
||||
buildCredentials: {
|
||||
testapp: {
|
||||
distributionCertificate: {
|
||||
dataBase64: 'YmluYXJ5Y29udGVudDE=',
|
||||
password: 'distCertPassword',
|
||||
},
|
||||
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
|
||||
},
|
||||
},
|
||||
},
|
||||
initiatingUserId: 'user-id',
|
||||
appId: 'app-id',
|
||||
...(hooks ? { hooks } : null),
|
||||
};
|
||||
jest.mocked(spawn).mockResolvedValue({
|
||||
stdout: Buffer.from(JSON.stringify({ job: internalJob, metadata: {} })),
|
||||
stderr: Buffer.from(''),
|
||||
} as any);
|
||||
}
|
||||
|
||||
function runWith(jobHooks?: object): Promise<{ newJob: BuildJob }> {
|
||||
const job = {
|
||||
platform: Platform.IOS,
|
||||
buildProfile: 'preview',
|
||||
appId: 'app-id',
|
||||
initiatingUserId: 'user-id',
|
||||
secrets: { robotAccessToken: 'token' },
|
||||
...(jobHooks ? { hooks: jobHooks } : null),
|
||||
} as unknown as BuildJob;
|
||||
return runEasBuildInternalAsync({ job, logger, env: {}, cwd: '/tmp/project' });
|
||||
}
|
||||
|
||||
it('keeps the original hooks when the regenerated job has none', async () => {
|
||||
mockRegeneratedJob();
|
||||
const { newJob } = await runWith(originalHooks);
|
||||
expect((newJob as any).hooks).toEqual(originalHooks);
|
||||
});
|
||||
|
||||
it('lets the original hooks win over hooks in the regenerated job', async () => {
|
||||
mockRegeneratedJob({ after_install_node_modules: [{ run: 'echo from-eas-json' }] });
|
||||
const { newJob } = await runWith(originalHooks);
|
||||
expect((newJob as any).hooks).toEqual(originalHooks);
|
||||
});
|
||||
|
||||
it('carries no hooks when the original had none, even if the regenerated job adds some', async () => {
|
||||
mockRegeneratedJob({ before_install_node_modules: [{ run: 'echo from-eas-json' }] });
|
||||
const { newJob } = await runWith(undefined);
|
||||
expect((newJob as any).hooks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { BuildPhase, BuildTrigger, Hooks, Ios } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { executeHookStepsAsync } from '@expo/steps';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { BuildContext } from '../../context';
|
||||
import { runHookableBuildPhaseAsync } from '../hookableBuildPhase';
|
||||
import { ParsedJobHooks, parseJobHooksAsync } from '../jobHooks';
|
||||
|
||||
// The primitive's step-execution rules (failure()/success(), no-if continuation,
|
||||
// metric emission) are covered in @expo/steps. Here we mock it to unit-test the
|
||||
// wrapper's own orchestration: ordering, error precedence, and the env bridge.
|
||||
jest.mock('@expo/steps', () => ({
|
||||
...jest.requireActual('@expo/steps'),
|
||||
executeHookStepsAsync: jest.fn(),
|
||||
}));
|
||||
const executeHookStepsMock = jest.mocked(executeHookStepsAsync);
|
||||
|
||||
async function setup(hooks: Hooks | undefined): Promise<{
|
||||
ctx: BuildContext<Ios.Job>;
|
||||
hooks: ParsedJobHooks | null;
|
||||
}> {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn(() => logger),
|
||||
} as unknown as bunyan;
|
||||
const ctx = new BuildContext<Ios.Job>(
|
||||
{ ...createTestIosJob({ triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION }), hooks },
|
||||
{
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger,
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '/tmp/wd',
|
||||
}
|
||||
);
|
||||
jest
|
||||
.spyOn(ctx, 'runBuildPhase')
|
||||
.mockImplementation((_phase: BuildPhase, fn: () => Promise<unknown>) => fn() as never);
|
||||
return { ctx, hooks: await parseJobHooksAsync(ctx, ['install_node_modules']) };
|
||||
}
|
||||
|
||||
const runArgs = <TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>,
|
||||
hooks: ParsedJobHooks | null,
|
||||
fn: () => Promise<unknown>
|
||||
): Parameters<typeof runHookableBuildPhaseAsync>[0] => ({
|
||||
ctx,
|
||||
hooks,
|
||||
buildPhase: BuildPhase.INSTALL_DEPENDENCIES,
|
||||
anchor: 'install_node_modules',
|
||||
fn,
|
||||
});
|
||||
|
||||
describe(runHookableBuildPhaseAsync, () => {
|
||||
beforeEach(() => {
|
||||
executeHookStepsMock.mockReset();
|
||||
executeHookStepsMock.mockResolvedValue({ failedLocally: false, firstError: undefined });
|
||||
});
|
||||
|
||||
it('runs the phase once with no hook execution when there are no hooks', async () => {
|
||||
const { ctx, hooks } = await setup(undefined);
|
||||
const fn = jest.fn().mockResolvedValue('ok');
|
||||
expect(await runHookableBuildPhaseAsync(runArgs(ctx, hooks, fn))).toBe('ok');
|
||||
expect(ctx.runBuildPhase).toHaveBeenCalledTimes(1);
|
||||
expect(executeHookStepsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs before -> phase -> after in order and returns the phase result', async () => {
|
||||
const { ctx, hooks } = await setup({
|
||||
before_install_node_modules: [{ run: 'echo before' }],
|
||||
after_install_node_modules: [{ run: 'echo after' }],
|
||||
});
|
||||
const order: string[] = [];
|
||||
executeHookStepsMock.mockImplementation(async (_ctx, _entries, opts) => {
|
||||
order.push(opts.timing);
|
||||
return { failedLocally: false, firstError: undefined };
|
||||
});
|
||||
const fn = jest.fn().mockImplementation(async () => {
|
||||
order.push('phase');
|
||||
return 'result';
|
||||
});
|
||||
expect(await runHookableBuildPhaseAsync(runArgs(ctx, hooks, fn))).toBe('result');
|
||||
expect(order).toEqual(['before', 'phase', 'after']);
|
||||
expect(executeHookStepsMock).toHaveBeenLastCalledWith(hooks!.globalContext, expect.anything(), {
|
||||
anchor: 'install_node_modules',
|
||||
timing: 'after',
|
||||
anchorResult: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the phase and after-hooks when a before-hook fails', async () => {
|
||||
const { ctx, hooks } = await setup({
|
||||
before_install_node_modules: [{ run: 'echo before' }],
|
||||
after_install_node_modules: [{ run: 'echo after' }],
|
||||
});
|
||||
executeHookStepsMock.mockResolvedValue({
|
||||
failedLocally: true,
|
||||
firstError: new Error('before boom'),
|
||||
});
|
||||
const fn = jest.fn().mockResolvedValue('ok');
|
||||
await expect(runHookableBuildPhaseAsync(runArgs(ctx, hooks, fn))).rejects.toThrow(
|
||||
'Hook "before_install_node_modules" failed'
|
||||
);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(executeHookStepsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('throws the after-hook error when the phase succeeded but an after-hook fails', async () => {
|
||||
const { ctx, hooks } = await setup({
|
||||
after_install_node_modules: [{ run: 'echo after' }],
|
||||
});
|
||||
executeHookStepsMock.mockResolvedValue({
|
||||
failedLocally: true,
|
||||
firstError: new Error('after boom'),
|
||||
});
|
||||
await expect(
|
||||
runHookableBuildPhaseAsync(runArgs(ctx, hooks, jest.fn().mockResolvedValue('ok')))
|
||||
).rejects.toThrow('Hook "after_install_node_modules" failed');
|
||||
});
|
||||
|
||||
it('lets the phase error win over a failing after-hook, marking the context failed', async () => {
|
||||
const { ctx, hooks } = await setup({
|
||||
after_install_node_modules: [{ run: 'echo after' }],
|
||||
});
|
||||
const markAsFailed = jest.spyOn(hooks!.globalContext, 'markAsFailed');
|
||||
executeHookStepsMock.mockResolvedValue({
|
||||
failedLocally: true,
|
||||
firstError: new Error('after boom'),
|
||||
});
|
||||
await expect(
|
||||
runHookableBuildPhaseAsync(
|
||||
runArgs(ctx, hooks, jest.fn().mockRejectedValue(new Error('phase boom')))
|
||||
)
|
||||
).rejects.toThrow('phase boom');
|
||||
// after ran against the failed anchor
|
||||
expect(markAsFailed).toHaveBeenCalled();
|
||||
expect(executeHookStepsMock).toHaveBeenLastCalledWith(hooks!.globalContext, expect.anything(), {
|
||||
anchor: 'install_node_modules',
|
||||
timing: 'after',
|
||||
anchorResult: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes the hook context before and writes env back after each hook', async () => {
|
||||
const { ctx, hooks } = await setup({
|
||||
before_install_node_modules: [{ run: 'echo before' }],
|
||||
});
|
||||
const forwardRefresh = jest.spyOn(hooks!.customBuildContext, 'updateEnv');
|
||||
const writeBack = jest.spyOn(ctx, 'updateEnv');
|
||||
// Captured before the run: the reverse write-back replaces ctx.env, so
|
||||
// comparing the forward-refresh arg against the post-run ctx.env would drift.
|
||||
const envBeforeRun = ctx.env;
|
||||
await runHookableBuildPhaseAsync(runArgs(ctx, hooks, jest.fn().mockResolvedValue('ok')));
|
||||
expect(forwardRefresh).toHaveBeenCalledWith(envBeforeRun);
|
||||
// The job/metadata snapshot is synced onto the hook context before the hook.
|
||||
expect(hooks!.customBuildContext.job).toBe(ctx.job);
|
||||
expect(hooks!.customBuildContext.metadata).toBe(ctx.metadata);
|
||||
expect(writeBack).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { BuildTrigger, ErrorCode, Hooks, Ios, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { BuildContext } from '../../context';
|
||||
import { parseJobHooksAsync } from '../jobHooks';
|
||||
|
||||
const INSTALL: ['install_node_modules'] = ['install_node_modules'];
|
||||
|
||||
function createCtx(hooks: Hooks | undefined): {
|
||||
ctx: BuildContext<Ios.Job>;
|
||||
warn: jest.Mock;
|
||||
} {
|
||||
const warn = jest.fn();
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn,
|
||||
child: jest.fn(() => logger),
|
||||
} as unknown as bunyan;
|
||||
const ctx = new BuildContext<Ios.Job>(
|
||||
{ ...createTestIosJob({ triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION }), hooks },
|
||||
{
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger,
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '/tmp/wd',
|
||||
}
|
||||
);
|
||||
return { ctx, warn };
|
||||
}
|
||||
|
||||
describe(parseJobHooksAsync, () => {
|
||||
it('returns null when the job has no hooks', async () => {
|
||||
const { ctx } = createCtx(undefined);
|
||||
expect(await parseJobHooksAsync(ctx, INSTALL)).toBeNull();
|
||||
});
|
||||
|
||||
it('parses a wrapped-anchor hook into one entry in a checked-out context', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_install_node_modules: [{ run: 'echo hi' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed!.hookEntriesByKey.before_install_node_modules).toHaveLength(1);
|
||||
expect(parsed!.globalContext.wasCheckedOut()).toBe(true);
|
||||
});
|
||||
|
||||
it('throws a shape error for a malformed step, tagged with the hooks error code', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_install_node_modules: [{} as never],
|
||||
});
|
||||
await expect(parseJobHooksAsync(ctx, INSTALL)).rejects.toThrow(
|
||||
'Invalid steps in "hooks.before_install_node_modules"'
|
||||
);
|
||||
await expect(parseJobHooksAsync(ctx, INSTALL)).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.HOOKS_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an unknown hook key as inert and warns', async () => {
|
||||
const { ctx, warn } = createCtx({
|
||||
before_bogus: [{ run: 'echo hi' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey).toEqual({});
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown hook key "before_bogus"'));
|
||||
});
|
||||
|
||||
it('shape-checks a registered-but-unwrapped key, then warns and skips it', async () => {
|
||||
const { ctx, warn } = createCtx({
|
||||
before_submit: [{ run: 'echo hi' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey.before_submit).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
|
||||
});
|
||||
|
||||
it('shape-validates a registered-but-unwrapped key even though it is skipped', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_submit: [{} as never],
|
||||
});
|
||||
await expect(parseJobHooksAsync(ctx, INSTALL)).rejects.toThrow(
|
||||
'Invalid steps in "hooks.before_submit"'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an empty-array opt-out without validating or constructing it', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_install_node_modules: [],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey.before_install_node_modules).toBeUndefined();
|
||||
});
|
||||
|
||||
it('errors on an unknown function under a wrapped key (construction, not shape)', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_install_node_modules: [{ uses: 'eas/nonexistent' }],
|
||||
});
|
||||
await expect(parseJobHooksAsync(ctx, INSTALL)).rejects.toThrow(
|
||||
'Failed to parse hooks.before_install_node_modules'
|
||||
);
|
||||
});
|
||||
|
||||
it('parses a function group in an after-hook (no after-hook fence)', async () => {
|
||||
const { ctx } = createCtx({
|
||||
after_install_node_modules: [{ uses: 'eas/checkout' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey.after_install_node_modules).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shares one global context across the before and after entries of an anchor', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_install_node_modules: [{ run: 'echo before' }],
|
||||
after_install_node_modules: [{ run: 'echo after' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
const before = parsed!.hookEntriesByKey.before_install_node_modules![0];
|
||||
const after = parsed!.hookEntriesByKey.after_install_node_modules![0];
|
||||
expect(before.steps[0].ctx.global).toBe(parsed!.globalContext);
|
||||
expect(after.steps[0].ctx.global).toBe(parsed!.globalContext);
|
||||
});
|
||||
|
||||
it('wraps a cross-key duplicate step id in the aggregate error', async () => {
|
||||
const { ctx } = createCtx({
|
||||
before_install_node_modules: [{ id: 'dup', run: 'echo a' }],
|
||||
after_install_node_modules: [{ id: 'dup', run: 'echo b' }],
|
||||
});
|
||||
const result = parseJobHooksAsync(ctx, INSTALL);
|
||||
await expect(result).rejects.toThrow(UserError);
|
||||
await expect(result).rejects.toMatchObject({
|
||||
errorCode: ErrorCode.HOOKS_ERROR,
|
||||
message: expect.stringContaining("The job's hooks are invalid"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Android, BuildTrigger } from '@expo/eas-build-job';
|
||||
|
||||
import { createTestAndroidJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
|
||||
import { runHookableBuildPhaseAsync } from '../hookableBuildPhase';
|
||||
import { JobHooksRef, setupAsync } from '../setup';
|
||||
import { ParsedJobHooks, parseJobHooksAsync } from '../jobHooks';
|
||||
import { prepareProjectSourcesAsync } from '../projectSources';
|
||||
import { runHookIfPresent } from '../../utils/hooks';
|
||||
|
||||
jest.mock('../projectSources', () => ({ prepareProjectSourcesAsync: jest.fn() }));
|
||||
jest.mock('../easBuildInternal', () => ({
|
||||
resolveEnvFromBuildProfileAsync: jest.fn(),
|
||||
runEasBuildInternalAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../hookableBuildPhase', () => ({ runHookableBuildPhaseAsync: jest.fn() }));
|
||||
jest.mock('../jobHooks', () => ({ parseJobHooksAsync: jest.fn() }));
|
||||
jest.mock('../../utils/npmrc', () => ({ setUpNpmrcAsync: jest.fn() }));
|
||||
jest.mock('../../utils/hooks', () => ({
|
||||
Hook: { PRE_INSTALL: 'PRE_INSTALL' },
|
||||
runHookIfPresent: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/project', () => ({
|
||||
readAndLogPackageJson: jest.fn(() => ({})),
|
||||
readEasJsonContents: jest.fn(() => '{}'),
|
||||
readPackageJson: jest.fn(() => ({})),
|
||||
}));
|
||||
jest.mock('../../ios/xcodeEnv', () => ({ deleteXcodeEnvLocalIfExistsAsync: jest.fn() }));
|
||||
|
||||
// A sentinel the parse mock returns; the install wrapper must receive exactly it.
|
||||
const PARSED = { sentinel: true } as unknown as ParsedJobHooks;
|
||||
|
||||
describe(setupAsync, () => {
|
||||
let order: string[];
|
||||
|
||||
function createCtx(triggeredBy: BuildTrigger): BuildContext<Android.Job> {
|
||||
const ctx = new BuildContext<Android.Job>(
|
||||
{ ...createTestAndroidJob(), triggeredBy },
|
||||
{
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '/workingdir',
|
||||
}
|
||||
);
|
||||
// Run each phase closure inline; skip the phase markers.
|
||||
jest
|
||||
.spyOn(ctx, 'runBuildPhase')
|
||||
.mockImplementation((_phase, fn: () => Promise<unknown>) => fn() as never);
|
||||
// READ_APP_CONFIG reads this; a minimal config passes validateAppConfigAsync
|
||||
// and the EAS_BUILD_INTERNAL bundle-id/package checks.
|
||||
jest.spyOn(ctx, 'appConfig', 'get').mockReturnValue(
|
||||
Promise.resolve({
|
||||
android: { package: 'com.test' },
|
||||
ios: { bundleIdentifier: 'com.test' },
|
||||
} as never)
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
order = [];
|
||||
jest.mocked(prepareProjectSourcesAsync).mockImplementation(async () => {
|
||||
order.push('prepare-project');
|
||||
return { handled: false };
|
||||
});
|
||||
jest.mocked(resolveEnvFromBuildProfileAsync).mockImplementation(async () => {
|
||||
order.push('profile-env');
|
||||
return {};
|
||||
});
|
||||
jest.mocked(runHookIfPresent).mockImplementation(async () => {
|
||||
order.push('pre-install-hook');
|
||||
});
|
||||
jest.mocked(parseJobHooksAsync).mockImplementation(async () => {
|
||||
order.push('parse-hooks');
|
||||
return PARSED;
|
||||
});
|
||||
jest.mocked(runHookableBuildPhaseAsync).mockImplementation(async () => {
|
||||
// Record the wrapper call; deliberately do NOT run its `fn` so the install
|
||||
// internals stay out of the ordering test.
|
||||
order.push('install-wrapper');
|
||||
return undefined as never;
|
||||
});
|
||||
jest.mocked(runEasBuildInternalAsync).mockImplementation(async ({ job }) => ({
|
||||
newJob: job,
|
||||
newMetadata: {} as never,
|
||||
}));
|
||||
});
|
||||
|
||||
it('parses hooks after project prep + profile-env + pre-install hook, and before the install wrapper', async () => {
|
||||
const jobHooksRef: JobHooksRef = { current: null };
|
||||
await setupAsync(createCtx(BuildTrigger.GIT_BASED_INTEGRATION), {
|
||||
wrappedAnchors: ['install_node_modules'],
|
||||
jobHooksRef,
|
||||
});
|
||||
const parseAt = order.indexOf('parse-hooks');
|
||||
expect(order.indexOf('prepare-project')).toBeLessThan(parseAt);
|
||||
expect(order.indexOf('profile-env')).toBeLessThan(parseAt);
|
||||
expect(order.indexOf('pre-install-hook')).toBeLessThan(parseAt);
|
||||
expect(parseAt).toBeLessThan(order.indexOf('install-wrapper'));
|
||||
});
|
||||
|
||||
it('parses hooks unconditionally on a non-git-based build (never parked in the git-only branch)', async () => {
|
||||
const jobHooksRef: JobHooksRef = { current: null };
|
||||
await setupAsync(createCtx(BuildTrigger.EAS_CLI), {
|
||||
wrappedAnchors: ['install_node_modules'],
|
||||
jobHooksRef,
|
||||
});
|
||||
expect(parseJobHooksAsync).toHaveBeenCalledTimes(1);
|
||||
// The profile-env block is git-only; the parse must not be coupled to it.
|
||||
expect(resolveEnvFromBuildProfileAsync).not.toHaveBeenCalled();
|
||||
expect(order.indexOf('parse-hooks')).toBeLessThan(order.indexOf('install-wrapper'));
|
||||
});
|
||||
|
||||
it('publishes the parsed hooks to the ref before the install wrapper runs', async () => {
|
||||
const jobHooksRef: JobHooksRef = { current: null };
|
||||
await setupAsync(createCtx(BuildTrigger.GIT_BASED_INTEGRATION), {
|
||||
wrappedAnchors: ['install_node_modules'],
|
||||
jobHooksRef,
|
||||
});
|
||||
expect(jobHooksRef.current).toBe(PARSED);
|
||||
// The wrapper received the same parsed hooks the ref holds.
|
||||
expect(runHookableBuildPhaseAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ hooks: PARSED, anchor: 'install_node_modules' })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -152,6 +152,9 @@ function validateEasBuildInternalResult<TJob extends BuildJob>({
|
||||
// We want to retain values that we have set on the job.
|
||||
appId: oldJob.appId,
|
||||
initiatingUserId: oldJob.initiatingUserId,
|
||||
// Hooks are resolved server-side at submit; the mid-build eas.json
|
||||
// regeneration must not add, drop, or change them (the original wins).
|
||||
hooks: oldJob.hooks,
|
||||
...(oldJob.platform === Platform.IOS && oldJob.refreshAdHocProvisioningProfile === true
|
||||
? { refreshAdHocProvisioningProfile: true }
|
||||
: null),
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
BuildJob,
|
||||
BuildPhase,
|
||||
ErrorCode,
|
||||
HookAnchorId,
|
||||
HookKey,
|
||||
UserError,
|
||||
} from '@expo/eas-build-job';
|
||||
import { executeHookStepsAsync } from '@expo/steps';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { ParsedJobHooks } from './jobHooks';
|
||||
|
||||
/**
|
||||
* Runs a native build phase with its `before_<anchor>` / `after_<anchor>` hooks
|
||||
* wrapped around it: before-entries -> phase (in a try) -> after-entries, with
|
||||
* after-on-failure guaranteed. A failing before sequence skips the phase AND the
|
||||
* after-hooks (steps-engine parity). The phase error always wins over an
|
||||
* after-hook error. Passthrough (no hooks or no keys for this anchor) runs the
|
||||
* phase exactly once with zero overhead.
|
||||
*/
|
||||
export async function runHookableBuildPhaseAsync<TJob extends BuildJob, T>({
|
||||
ctx,
|
||||
hooks,
|
||||
buildPhase,
|
||||
anchor,
|
||||
fn,
|
||||
}: {
|
||||
ctx: BuildContext<TJob>;
|
||||
hooks: ParsedJobHooks | null;
|
||||
buildPhase: BuildPhase;
|
||||
anchor: HookAnchorId;
|
||||
fn: () => Promise<T>;
|
||||
}): Promise<T> {
|
||||
// runHookEntriesAsync no-ops when the key has no entries.
|
||||
if (hooks) {
|
||||
await runHookEntriesAsync({ ctx, hooks, timing: 'before', anchor });
|
||||
}
|
||||
|
||||
let result: T;
|
||||
try {
|
||||
result = await ctx.runBuildPhase(buildPhase, fn);
|
||||
} catch (phaseError) {
|
||||
if (hooks) {
|
||||
try {
|
||||
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: true });
|
||||
} catch (hookError) {
|
||||
// The phase error wins; still surface the after-hook failure in the log.
|
||||
ctx.logger.error({ err: hookError }, `after_${anchor} hook failed`);
|
||||
}
|
||||
}
|
||||
throw phaseError;
|
||||
}
|
||||
|
||||
if (hooks) {
|
||||
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: false });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function runHookEntriesAsync<TJob extends BuildJob>({
|
||||
ctx,
|
||||
hooks,
|
||||
timing,
|
||||
anchor,
|
||||
anchorFailed = false,
|
||||
}: {
|
||||
ctx: BuildContext<TJob>;
|
||||
hooks: ParsedJobHooks;
|
||||
timing: 'before' | 'after';
|
||||
anchor: HookAnchorId;
|
||||
anchorFailed?: boolean;
|
||||
}): Promise<void> {
|
||||
const hookKey: HookKey = `${timing}_${anchor}`;
|
||||
const entries = hooks.hookEntriesByKey[hookKey];
|
||||
if (!entries?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the hook context from the live BuildContext before running: env
|
||||
// moves as the build runs (set-env written back below; profile env), and
|
||||
// job/metadata are replaced by eas build:internal.
|
||||
hooks.customBuildContext.updateEnv(ctx.env);
|
||||
// Overwrite the job/metadata snapshot; NOT updateJobInformation, whose
|
||||
// delta-merge would duplicate environmentSecrets on each refresh.
|
||||
hooks.customBuildContext.job = ctx.job;
|
||||
hooks.customBuildContext.metadata = ctx.metadata;
|
||||
|
||||
if (timing === 'after' && anchorFailed) {
|
||||
// Mirror the engine: the anchor's failure is marked on the shared context so
|
||||
// `failure()` / `success()` on after-hook steps read the phase outcome.
|
||||
hooks.globalContext.markAsFailed();
|
||||
}
|
||||
|
||||
const { failedLocally, firstError } = await executeHookStepsAsync(hooks.globalContext, entries, {
|
||||
anchor,
|
||||
timing,
|
||||
...(timing === 'after' ? { anchorResult: anchorFailed ? 'failed' : 'success' } : null),
|
||||
});
|
||||
|
||||
// Reverse write-back BEFORE propagating a failure: env set by the entries that
|
||||
// did run must reach the build context even when a later entry failed, so the
|
||||
// native phases (and the outer lifecycle hooks) see it. Hook-carrying builds
|
||||
// are git-based, so updateEnv's gate does not fire here.
|
||||
ctx.updateEnv(hooks.globalContext.env);
|
||||
|
||||
if (failedLocally) {
|
||||
throw new UserError(
|
||||
ErrorCode.HOOKS_ERROR,
|
||||
`Hook "${hookKey}" failed: ${
|
||||
firstError instanceof Error ? firstError.message : String(firstError)
|
||||
}. Check the failing hook step's logs above.`,
|
||||
{ cause: firstError }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
BuildJob,
|
||||
ErrorCode,
|
||||
HookAnchorId,
|
||||
HookKey,
|
||||
UserError,
|
||||
parseHookKey,
|
||||
validateSteps,
|
||||
} from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildStep,
|
||||
BuildStepGlobalContext,
|
||||
HookEntry,
|
||||
constructHookEntriesAsync,
|
||||
validateHookStepsAsync,
|
||||
} from '@expo/steps';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
|
||||
import { getEasFunctions } from '../steps/easFunctions';
|
||||
|
||||
export interface ParsedJobHooks {
|
||||
globalContext: BuildStepGlobalContext;
|
||||
// The hook runner re-syncs this from the live BuildContext and runs the
|
||||
// hooks against it.
|
||||
customBuildContext: CustomBuildContext;
|
||||
// One HookEntry per authored hook step (a `uses:` group's whole expansion
|
||||
// stays inside one entry). The engine evaluates each entry's `if:` once and
|
||||
// isolates within-entry failures, so the boundary must survive to execution.
|
||||
hookEntriesByKey: Partial<Record<HookKey, HookEntry[]>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the native job's `hooks` into engine-ready entries, once, at setup
|
||||
* time. Shape-validates every registered key (reachable or not); constructs
|
||||
* entries only for the anchors this build actually wraps. Returns null when the
|
||||
* job declares no hooks.
|
||||
*/
|
||||
export async function parseJobHooksAsync<TJob extends BuildJob>(
|
||||
ctx: BuildContext<TJob>,
|
||||
wrappedAnchors: readonly HookAnchorId[]
|
||||
): Promise<ParsedJobHooks | null> {
|
||||
const hooks = ctx.job.hooks;
|
||||
if (!hooks || Object.keys(hooks).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const customBuildContext = new CustomBuildContext(ctx);
|
||||
const globalContext = new BuildStepGlobalContext(customBuildContext, false);
|
||||
// Hook steps run in the checked-out project directory, not the pre-checkout
|
||||
// target directory the global context defaults to.
|
||||
globalContext.markAsCheckedOut(ctx.logger);
|
||||
|
||||
const externalFunctions = getEasFunctions(customBuildContext);
|
||||
const externalFunctionGroups = getEasFunctionGroups(customBuildContext);
|
||||
|
||||
// Shape-validate every REGISTERED key, reachable or not (steps-world parity:
|
||||
// a malformed `before_submit` fails a steps build, so it fails here too), and
|
||||
// warn on keys this build won't run. An unknown key is inert — a key newer
|
||||
// than this worker must be ignored, never fail the job.
|
||||
for (const [key, steps] of Object.entries(hooks)) {
|
||||
const parsed = parseHookKey(key);
|
||||
if (parsed === null) {
|
||||
ctx.logger.warn(`Ignoring unknown hook key "${key}".`);
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(steps) && steps.length === 0) {
|
||||
// `[]` is an explicit opt-out; validateSteps requires >= 1 step.
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
validateSteps(steps);
|
||||
} catch (err) {
|
||||
throw new UserError(
|
||||
ErrorCode.HOOKS_ERROR,
|
||||
`Invalid steps in "hooks.${key}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
// Registered but not wrapped by this build: shape-checked above, then
|
||||
// skipped. Function existence is deliberately NOT checked (that happens only
|
||||
// during construction) — an unknown `uses:` under an unwrapped key warns
|
||||
// rather than failing the build.
|
||||
if (!wrappedAnchors.includes(parsed.anchorId)) {
|
||||
ctx.logger.warn(
|
||||
`Ignoring "hooks.${key}": this build does not run the "${parsed.anchorId}" step.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct entries for the wrapped anchors in EXECUTION order (per anchor,
|
||||
// before_ then after_) so generated step ids and output references follow the
|
||||
// order steps actually run. All entries share one globalContext, so env and
|
||||
// outputs accumulate across keys.
|
||||
const hookEntriesByKey: Partial<Record<HookKey, HookEntry[]>> = {};
|
||||
const orderedSteps: BuildStep[] = [];
|
||||
for (const anchor of wrappedAnchors) {
|
||||
for (const side of ['before', 'after'] as const) {
|
||||
const key: HookKey = `${side}_${anchor}`;
|
||||
const steps = hooks[key];
|
||||
if (!Array.isArray(steps) || steps.length === 0) {
|
||||
continue;
|
||||
}
|
||||
let entries: HookEntry[];
|
||||
try {
|
||||
entries = await constructHookEntriesAsync(globalContext, steps, {
|
||||
externalFunctions,
|
||||
externalFunctionGroups,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new UserError(
|
||||
ErrorCode.HOOKS_ERROR,
|
||||
`Failed to parse hooks.${key}: ${err instanceof Error ? err.message : String(err)}. Hooks use the same syntax as workflow steps.`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
hookEntriesByKey[key] = entries;
|
||||
for (const entry of entries) {
|
||||
orderedSteps.push(...entry.steps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One aggregate pass over every constructed step in execution order: unique
|
||||
// ids, output references, and platform allowance. Per-key validation misses
|
||||
// cross-key id collisions and would reject a legal after -> before output
|
||||
// reference as a future reference.
|
||||
try {
|
||||
await validateHookStepsAsync(globalContext, orderedSteps);
|
||||
} catch (err) {
|
||||
throw new UserError(
|
||||
ErrorCode.HOOKS_ERROR,
|
||||
`The job's hooks are invalid: ${err instanceof Error ? err.message : String(err)}. Fix the reported step ids or output references.`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
|
||||
return { globalContext, customBuildContext, hookEntriesByKey };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
BuildJob,
|
||||
BuildPhase,
|
||||
BuildTrigger,
|
||||
HookAnchorId,
|
||||
Ios,
|
||||
Job,
|
||||
Platform,
|
||||
@@ -14,7 +15,9 @@ import nullthrows from 'nullthrows';
|
||||
import path from 'path';
|
||||
|
||||
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from './easBuildInternal';
|
||||
import { runHookableBuildPhaseAsync } from './hookableBuildPhase';
|
||||
import { installDependenciesAsync, resolvePackagerDir } from './installDependencies';
|
||||
import { ParsedJobHooks, parseJobHooksAsync } from './jobHooks';
|
||||
import { prepareProjectSourcesAsync } from './projectSources';
|
||||
import { BuildContext } from '../context';
|
||||
import { deleteXcodeEnvLocalIfExistsAsync } from '../ios/xcodeEnv';
|
||||
@@ -26,7 +29,7 @@ import {
|
||||
shouldUseFrozenLockfile,
|
||||
} from '../utils/packageManager';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
|
||||
import { readAndLogPackageJson, readEasJsonContents, readPackageJson } from '../utils/project';
|
||||
import { retryAsync } from '../utils/retry';
|
||||
|
||||
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
|
||||
@@ -36,7 +39,22 @@ const INSTALL_DEPENDENCIES_KILL_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
class DoctorTimeoutError extends Error {}
|
||||
class InstallDependenciesTimeoutError extends Error {}
|
||||
|
||||
export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>): Promise<void> {
|
||||
/**
|
||||
* Mutable holder the builder passes into setupAsync. setupAsync assigns
|
||||
* `current` the moment hooks are parsed (before any hook runs), so the builder's
|
||||
* finally can drain queued metric uploads even if setup fails partway through.
|
||||
*/
|
||||
export interface JobHooksRef {
|
||||
current: ParsedJobHooks | null;
|
||||
}
|
||||
|
||||
export async function setupAsync<TJob extends BuildJob>(
|
||||
ctx: BuildContext<TJob>,
|
||||
{
|
||||
wrappedAnchors,
|
||||
jobHooksRef,
|
||||
}: { wrappedAnchors: readonly HookAnchorId[]; jobHooksRef: JobHooksRef }
|
||||
): Promise<void> {
|
||||
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {
|
||||
await retryAsync(
|
||||
async () => {
|
||||
@@ -81,32 +99,48 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
}
|
||||
});
|
||||
|
||||
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
|
||||
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
|
||||
readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
|
||||
const expoVersion =
|
||||
ctx.metadata?.sdkVersion ??
|
||||
getPackageVersionFromPackageJson({
|
||||
packageJson,
|
||||
packageName: 'expo',
|
||||
});
|
||||
// Parse hooks unconditionally, after every pre-install env mutation
|
||||
// (PREPARE_PROJECT, the git-based profile-env block, PRE_INSTALL_HOOK) and
|
||||
// before the first wrapped phase.
|
||||
jobHooksRef.current = await parseJobHooksAsync(ctx, wrappedAnchors);
|
||||
|
||||
const reactNativeVersion =
|
||||
ctx.metadata?.reactNativeVersion ??
|
||||
getPackageVersionFromPackageJson({
|
||||
packageJson,
|
||||
packageName: 'react-native',
|
||||
});
|
||||
await runHookableBuildPhaseAsync({
|
||||
ctx,
|
||||
hooks: jobHooksRef.current,
|
||||
buildPhase: BuildPhase.INSTALL_DEPENDENCIES,
|
||||
anchor: 'install_node_modules',
|
||||
fn: async () => {
|
||||
// Read package.json fresh here rather than reusing the READ_PACKAGE_JSON
|
||||
// capture: a before_install_node_modules hook may have patched it, and the
|
||||
// steps-world install already reads it fresh. Silent read — READ_PACKAGE_JSON
|
||||
// stays the only logging site.
|
||||
const freshPackageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
|
||||
const expoVersion =
|
||||
ctx.metadata?.sdkVersion ??
|
||||
getPackageVersionFromPackageJson({
|
||||
packageJson: freshPackageJson,
|
||||
packageName: 'expo',
|
||||
});
|
||||
|
||||
await runInstallDependenciesAsync(ctx, {
|
||||
useFrozenLockfile: shouldUseFrozenLockfile({
|
||||
env: ctx.env,
|
||||
sdkVersion: expoVersion,
|
||||
reactNativeVersion,
|
||||
}),
|
||||
});
|
||||
const reactNativeVersion =
|
||||
ctx.metadata?.reactNativeVersion ??
|
||||
getPackageVersionFromPackageJson({
|
||||
packageJson: freshPackageJson,
|
||||
packageName: 'react-native',
|
||||
});
|
||||
|
||||
await runInstallDependenciesAsync(ctx, {
|
||||
useFrozenLockfile: shouldUseFrozenLockfile({
|
||||
env: ctx.env,
|
||||
sdkVersion: expoVersion,
|
||||
reactNativeVersion,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
|
||||
@@ -140,7 +174,9 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
});
|
||||
}
|
||||
|
||||
const hasExpoPackage = !!packageJson.dependencies?.expo;
|
||||
// Read fresh: a before_install_node_modules hook may have added or removed
|
||||
// the expo dependency.
|
||||
const hasExpoPackage = !!readPackageJson(ctx.getReactNativeProjectDirectory()).dependencies?.expo;
|
||||
if (!ctx.env.EAS_BUILD_DISABLE_EXPO_DOCTOR_STEP && hasExpoPackage) {
|
||||
await ctx.runBuildPhase(BuildPhase.RUN_EXPO_DOCTOR, async () => {
|
||||
try {
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import fetch, { FetchError } from 'node-fetch';
|
||||
import { Readable } from 'stream';
|
||||
import { URL } from 'url';
|
||||
|
||||
import { retryOnGCSUploadFailure } from './retry';
|
||||
|
||||
interface UploadWithSignedUrlParams {
|
||||
signedUrl: GCS.SignedUrl;
|
||||
srcGeneratorAsync: () => Promise<Readable>;
|
||||
retryIntervalMs?: number;
|
||||
retries?: number;
|
||||
}
|
||||
|
||||
export namespace GCS {
|
||||
export type SignedUrl = {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
export async function uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync,
|
||||
retries = 2,
|
||||
retryIntervalMs = 30_000,
|
||||
}: UploadWithSignedUrlParams): Promise<string> {
|
||||
let resp;
|
||||
try {
|
||||
resp = await retryOnGCSUploadFailure(
|
||||
async () => {
|
||||
const src = await srcGeneratorAsync();
|
||||
return await fetch(signedUrl.url, {
|
||||
method: 'PUT',
|
||||
headers: signedUrl.headers,
|
||||
body: src,
|
||||
});
|
||||
},
|
||||
{ retries, retryIntervalMs }
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof FetchError) {
|
||||
throw new Error(`Failed to upload the file, reason: ${err.code}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
let body: string | undefined;
|
||||
try {
|
||||
body = await resp.text();
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(signedUrl.url);
|
||||
return `${url.protocol}//${url.host}${url.pathname}`;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import * as Builders from './builders';
|
||||
import GCSLoggerStream from './gcs/LoggerStream';
|
||||
import { GCS } from './gcs/client';
|
||||
import RemoteLoggerStream from './logging/RemoteLoggerStream';
|
||||
|
||||
export { Builders, GCS, GCSLoggerStream };
|
||||
export { Builders, RemoteLoggerStream };
|
||||
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
|
||||
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
|
||||
|
||||
export {
|
||||
ArtifactToUpload,
|
||||
|
||||
+12
-12
@@ -8,17 +8,17 @@ import { Readable, Writable, pipeline } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import zlib from 'zlib';
|
||||
|
||||
import { GCS } from './client';
|
||||
import { type SignedUrl, uploadWithSignedUrl } from '../storage/uploadWithSignedUrl';
|
||||
|
||||
type PromiseResolveFn = (value?: void | PromiseLike<void> | undefined) => void;
|
||||
|
||||
const pipe = promisify(pipeline);
|
||||
|
||||
class GCSLoggerStream extends Writable {
|
||||
class RemoteLoggerStream extends Writable {
|
||||
public writable = true;
|
||||
private readonly logger: bunyan;
|
||||
private readonly uploadMethod?: GCSLoggerStream.UploadMethod;
|
||||
private readonly options: GCSLoggerStream.Options;
|
||||
private readonly uploadMethod?: RemoteLoggerStream.UploadMethod;
|
||||
private readonly options: RemoteLoggerStream.Options;
|
||||
private readonly temporaryLogsPath: string;
|
||||
private readonly temporaryCompressedLogsPath: string;
|
||||
private readonly compress: string | null;
|
||||
@@ -30,7 +30,7 @@ class GCSLoggerStream extends Writable {
|
||||
private writePromise?: Promise<any>;
|
||||
private cleanUpCalled: boolean = false;
|
||||
|
||||
constructor({ logger, uploadMethod, options }: GCSLoggerStream.Config) {
|
||||
constructor({ logger, uploadMethod, options }: RemoteLoggerStream.Config) {
|
||||
super();
|
||||
this.logger = logger;
|
||||
this.uploadMethod = uploadMethod;
|
||||
@@ -84,7 +84,7 @@ class GCSLoggerStream extends Writable {
|
||||
|
||||
await fs.remove(this.temporaryLogsPath);
|
||||
await fs.remove(this.temporaryCompressedLogsPath);
|
||||
this.logger.info('Cleaning up GCS log stream');
|
||||
this.logger.info('Cleaning up log stream');
|
||||
}
|
||||
|
||||
public write(rec: any): boolean {
|
||||
@@ -115,7 +115,7 @@ class GCSLoggerStream extends Writable {
|
||||
this.buffer = this.buffer.slice(buffer.length);
|
||||
this.hasChangesToUpload = true;
|
||||
} catch (err) {
|
||||
this.logger.error({ err, origin: 'gcs-logger' }, 'Failed to write logs to file');
|
||||
this.logger.error({ err, origin: 'remote-logger' }, 'Failed to write logs to file');
|
||||
} finally {
|
||||
this.writePromise = undefined;
|
||||
}
|
||||
@@ -144,7 +144,7 @@ class GCSLoggerStream extends Writable {
|
||||
return result;
|
||||
})
|
||||
.catch(err => {
|
||||
this.logger.error({ err }, 'Failed to upload logs file to GCS');
|
||||
this.logger.error({ err }, 'Failed to upload logs file');
|
||||
})
|
||||
.then(result => {
|
||||
this.uploadingPromise = undefined;
|
||||
@@ -165,7 +165,7 @@ class GCSLoggerStream extends Writable {
|
||||
);
|
||||
};
|
||||
|
||||
return await GCS.uploadWithSignedUrl({
|
||||
return await uploadWithSignedUrl({
|
||||
signedUrl: this.uploadMethod.signedUrl,
|
||||
srcGeneratorAsync,
|
||||
});
|
||||
@@ -197,7 +197,7 @@ class GCSLoggerStream extends Writable {
|
||||
}
|
||||
}
|
||||
|
||||
namespace GCSLoggerStream {
|
||||
namespace RemoteLoggerStream {
|
||||
export enum CompressionMethod {
|
||||
GZIP = 'gzip',
|
||||
BR = 'br',
|
||||
@@ -209,7 +209,7 @@ namespace GCSLoggerStream {
|
||||
}
|
||||
|
||||
export type UploadMethod = {
|
||||
signedUrl: GCS.SignedUrl;
|
||||
signedUrl: SignedUrl;
|
||||
};
|
||||
|
||||
export interface Config {
|
||||
@@ -219,4 +219,4 @@ namespace GCSLoggerStream {
|
||||
}
|
||||
}
|
||||
|
||||
export default GCSLoggerStream;
|
||||
export default RemoteLoggerStream;
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isFileAttrRun,
|
||||
junitFileHasFileAttrs,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowNamesFromJUnitFile,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseJUnitTestCases,
|
||||
@@ -14,6 +15,33 @@ import {
|
||||
parseMaestroResultsFromFileAttrs,
|
||||
} from '../maestroResultParser';
|
||||
|
||||
describe(parseFailedFlowNamesFromJUnitFile, () => {
|
||||
it('returns the names of failed testcases, keeping slashes', async () => {
|
||||
vol.fromJSON({
|
||||
'/attempt/android-maestro-junit-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="3" failures="2">',
|
||||
' <testcase name="home" status="SUCCESS"/>',
|
||||
' <testcase name="sub/login" status="ERROR"><failure>boom</failure></testcase>',
|
||||
' <testcase name="checkout" status="ERROR"><failure>boom</failure></testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const failed = await parseFailedFlowNamesFromJUnitFile(
|
||||
'/attempt/android-maestro-junit-attempt-0.xml'
|
||||
);
|
||||
|
||||
expect([...failed].sort()).toEqual(['checkout', 'sub/login']);
|
||||
});
|
||||
|
||||
it('returns an empty set when the file cannot be read', async () => {
|
||||
expect((await parseFailedFlowNamesFromJUnitFile('/missing.xml')).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMaestroResults, () => {
|
||||
it('parses JUnit results and enriches with name→path map', async () => {
|
||||
vol.fromJSON({
|
||||
|
||||
@@ -80,6 +80,7 @@ describe(harvestFailureScreenshotsAsync, () => {
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 1,
|
||||
failedFlowNames: new Set(),
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -113,6 +114,7 @@ describe(harvestFailureScreenshotsAsync, () => {
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(),
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -136,6 +138,7 @@ describe(harvestFailureScreenshotsAsync, () => {
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(),
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -154,6 +157,7 @@ describe(harvestFailureScreenshotsAsync, () => {
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(),
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -175,6 +179,7 @@ describe(harvestFailureScreenshotsAsync, () => {
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 1,
|
||||
failedFlowNames: new Set(),
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -186,11 +191,304 @@ describe(harvestFailureScreenshotsAsync, () => {
|
||||
testsDirectory: path.join(testsDirectory, 'does-not-exist'),
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([]);
|
||||
});
|
||||
|
||||
// --- Maestro >= 2.7.0 bundle layout: <session>/<flow>[-shard-N]/screenshots/step-<NNN>-<slug>.png ---
|
||||
|
||||
async function makeBundle(args: {
|
||||
sessionDir: string;
|
||||
flowDir: string;
|
||||
stepFiles: { name: string; mtimeMs: number }[];
|
||||
sessionDirMtimeMs: number;
|
||||
}): Promise<void> {
|
||||
const sessionDir = path.join(testsDirectory, args.sessionDir);
|
||||
const screenshotsDir = path.join(sessionDir, args.flowDir, 'screenshots');
|
||||
await fs.mkdir(screenshotsDir, { recursive: true });
|
||||
for (const { name, mtimeMs } of args.stepFiles) {
|
||||
const filePath = path.join(screenshotsDir, name);
|
||||
await fs.writeFile(filePath, '');
|
||||
const when = new Date(mtimeMs);
|
||||
await fs.utimes(filePath, when, when);
|
||||
}
|
||||
const dirWhen = new Date(args.sessionDirMtimeMs);
|
||||
await fs.utimes(sessionDir, dirWhen, dirWhen);
|
||||
}
|
||||
|
||||
it('picks the highest step-NNN screenshot in a failed flow bundle (mtime as capturedAtMs)', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: '2026-01-01_100000',
|
||||
flowDir: 'Login Flow',
|
||||
stepFiles: [
|
||||
{ name: 'step-001-launchApp.png', mtimeMs: since + 1_000 },
|
||||
{ name: 'step-003-assertVisible-Welcome.png', mtimeMs: since + 3_000 },
|
||||
],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Login Flow']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([
|
||||
{
|
||||
fileAbsPath: path.join(
|
||||
testsDirectory,
|
||||
'2026-01-01_100000',
|
||||
'Login Flow',
|
||||
'screenshots',
|
||||
'step-003-assertVisible-Welcome.png'
|
||||
),
|
||||
displayName: 'Failure Screenshot: Login Flow (attempt 1)',
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName: 'Login Flow',
|
||||
attemptIndex: 0,
|
||||
capturedAtMs: since + 3_000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores final.png and non-step pngs in the bundle', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Flow',
|
||||
stepFiles: [
|
||||
{ name: 'step-002-tapOn.png', mtimeMs: since + 2_000 },
|
||||
{ name: 'final.png', mtimeMs: since + 9_000 },
|
||||
],
|
||||
sessionDirMtimeMs: since + 9_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Flow']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toHaveLength(1);
|
||||
expect(shots[0].fileAbsPath.endsWith('step-002-tapOn.png')).toBe(true);
|
||||
});
|
||||
|
||||
it('harvests only flows that JUnit reports as failed', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Failed Flow',
|
||||
stepFiles: [{ name: 'step-001-tapOn.png', mtimeMs: since + 1_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
// A passing flow can still leave a warned-step screenshot; it must not be harvested.
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Passed Flow',
|
||||
stepFiles: [{ name: 'step-001-assertVisible.png', mtimeMs: since + 1_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Failed Flow']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots.map(shot => shot.metadata.flowName)).toEqual(['Failed Flow']);
|
||||
});
|
||||
|
||||
it('strips the -shard-N suffix to match the JUnit flow name', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Checkout-shard-2',
|
||||
stepFiles: [{ name: 'step-004-tapOn-Pay.png', mtimeMs: since + 1_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Checkout']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toHaveLength(1);
|
||||
expect(shots[0].metadata.flowName).toBe('Checkout');
|
||||
});
|
||||
|
||||
it('resolves a combined shard + collision dir name to the flow', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Checkout-shard-2-3',
|
||||
stepFiles: [{ name: 'step-004-tapOn-Pay.png', mtimeMs: since + 1_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Checkout']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toHaveLength(1);
|
||||
expect(shots[0].metadata.flowName).toBe('Checkout');
|
||||
});
|
||||
|
||||
it('normalizes / to _ when matching the bundle dir to the JUnit flow name', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'sub_Login',
|
||||
stepFiles: [{ name: 'step-002-tapOn.png', mtimeMs: since + 1_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['sub/Login']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toHaveLength(1);
|
||||
expect(shots[0].metadata.flowName).toBe('sub_Login');
|
||||
});
|
||||
|
||||
it('skips a bundle whose dir name is ambiguous against the failed set', async () => {
|
||||
const since = 1_000_000;
|
||||
// 'foo-2' could be the literal flow 'foo-2' or a collision-suffixed 'foo' — unresolvable.
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'foo-2',
|
||||
stepFiles: [{ name: 'step-001-tapOn.png', mtimeMs: since + 1_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['foo', 'foo-2']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a step screenshot captured before capturedSinceMs (dir touched later)', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Flow',
|
||||
stepFiles: [{ name: 'step-001-tapOn.png', mtimeMs: since - 5_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Flow']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([]);
|
||||
});
|
||||
|
||||
it('yields nothing for a failed flow whose screenshots dir is empty', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Flow',
|
||||
stepFiles: [],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Flow']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([]);
|
||||
});
|
||||
|
||||
it('reduces two bundle dirs that resolve to the same flow to a single shot', async () => {
|
||||
const since = 1_000_000;
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Dup',
|
||||
stepFiles: [{ name: 'step-001-tapOn.png', mtimeMs: since + 1_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Dup-2',
|
||||
stepFiles: [{ name: 'step-001-tapOn.png', mtimeMs: since + 2_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Dup']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toHaveLength(1);
|
||||
expect(shots[0].metadata.flowName).toBe('Dup');
|
||||
});
|
||||
|
||||
it('collects both a legacy flat screenshot and a new-layout bundle in the same session dir', async () => {
|
||||
const since = 1_000_000;
|
||||
const sessionDir = path.join(testsDirectory, 'session');
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sessionDir, `screenshot-❌-${since + 1_000}-(Legacy Flow).png`),
|
||||
''
|
||||
);
|
||||
await makeBundle({
|
||||
sessionDir: 'session',
|
||||
flowDir: 'Bundle Flow',
|
||||
stepFiles: [{ name: 'step-002-tapOn.png', mtimeMs: since + 2_000 }],
|
||||
sessionDirMtimeMs: since + 5_000,
|
||||
});
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: since,
|
||||
attemptIndex: 0,
|
||||
failedFlowNames: new Set(['Bundle Flow']),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots.map(shot => shot.metadata.flowName).sort()).toEqual([
|
||||
'Bundle Flow',
|
||||
'Legacy Flow',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(computePureFailureFlowNames, () => {
|
||||
|
||||
@@ -122,6 +122,18 @@ async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]>
|
||||
}
|
||||
}
|
||||
|
||||
// Raw names (slashes kept) of the testcases a single JUnit file marks failed. Best-effort: an
|
||||
// unreadable or malformed file yields an empty set rather than throwing.
|
||||
export async function parseFailedFlowNamesFromJUnitFile(junitFile: string): Promise<Set<string>> {
|
||||
const failed = new Set<string>();
|
||||
for (const testcase of await parseJUnitFile(junitFile)) {
|
||||
if (testcase.status === 'failed') {
|
||||
failed.add(testcase.name);
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
|
||||
@@ -8,11 +8,20 @@ export interface ParsedFailureScreenshot {
|
||||
capturedAtMs: number;
|
||||
}
|
||||
|
||||
// Maestro failure screenshot: `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png`.
|
||||
// pre-v2.7.0 (Maestro <= 2.6.x): failure screenshots are flat files named
|
||||
// `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png` directly in the session dir.
|
||||
// The flow name may contain parentheses, so anchor on the `-(` after the epoch and the
|
||||
// `).png` suffix rather than scanning for parens.
|
||||
// `).png` suffix rather than scanning for parens. Remove this path once the build fleet is
|
||||
// entirely on Maestro >= 2.7.0.
|
||||
const FAILURE_SCREENSHOT_PATTERN = /^screenshot-(?:shard-\d+-)?❌-(\d+)-\((.+)\)\.png$/u;
|
||||
|
||||
// Maestro >= 2.7.0 bundle layout. Each flow gets its own dir under the session dir, with step
|
||||
// screenshots under `screenshots/` named `step-<NNN>-<slug>.png` (NNN is a 1-based, zero-padded
|
||||
// monotonic step sequence). Under the CLI (no `--analyze`) only failed/warned steps produce one.
|
||||
const STEP_SCREENSHOTS_DIRNAME = 'screenshots';
|
||||
const STEP_SCREENSHOT_PATTERN = /^step-(\d+)(?:-.*)?\.png$/;
|
||||
|
||||
// pre-v2.7.0: parses the legacy flat failure-screenshot filename.
|
||||
export function parseFailureScreenshotFilename(filename: string): ParsedFailureScreenshot | null {
|
||||
const match = filename.match(FAILURE_SCREENSHOT_PATTERN);
|
||||
if (!match) {
|
||||
@@ -41,65 +50,212 @@ export interface HarvestedScreenshot {
|
||||
};
|
||||
}
|
||||
|
||||
// Scans every debug dir under testsDirectory whose mtime is recent enough (mtime-based to cover
|
||||
// the maestro <2.5.0 bug that splits one invocation across two dirs), then keeps only screenshots
|
||||
// whose own capturedAtMs is at/after capturedSinceMs. Never throws: an unreadable dir is logged
|
||||
// and skipped so a screenshot harvest can't affect the maestro step outcome.
|
||||
// Harvests one attempt's failure screenshots from the maestro debug output. Walks each session
|
||||
// dir under testsDirectory whose mtime is recent enough (mtime-based to also cover the maestro
|
||||
// <2.5.0 bug that splits one invocation across two dirs). Within a session dir, a child is either
|
||||
// a flat pre-v2.7.0 screenshot file or a Maestro >= 2.7.0 per-flow bundle dir — the single
|
||||
// structural seam below. Never throws: an unreadable dir/file is logged and skipped so a
|
||||
// screenshot harvest can't affect the maestro step outcome.
|
||||
export async function harvestFailureScreenshotsAsync(args: {
|
||||
testsDirectory: string;
|
||||
capturedSinceMs: number;
|
||||
attemptIndex: number;
|
||||
// Flow names the JUnit report marks failed for THIS attempt (raw; normalized here). A v2.7.0
|
||||
// bundle dir carries no failure marker, so this is what tells a failed flow's bundle apart from
|
||||
// a passing flow that merely left a warned-step screenshot. Unused by the legacy path.
|
||||
failedFlowNames: ReadonlySet<string>;
|
||||
logger: bunyan;
|
||||
}): Promise<HarvestedScreenshot[]> {
|
||||
// Gate dirs by mtime floored to the second, so a dir created within the same second as the
|
||||
// attempt start isn't stat'd below the baseline. The exact capturedAtMs gate below is what
|
||||
// attempt start isn't stat'd below the baseline. The exact capturedAtMs gate per file is what
|
||||
// prevents mis-attributing an earlier attempt's screenshot.
|
||||
const dirSinceMtimeMs = Math.floor(args.capturedSinceMs / 1000) * 1000;
|
||||
let entries: Dirent[];
|
||||
// Bundle dir names already have '/' -> '_'; normalize the JUnit names the same way to match.
|
||||
const normalizedFailedFlowNames = new Set([...args.failedFlowNames].map(normalizeFlowName));
|
||||
|
||||
let sessionEntries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(args.testsDirectory, { withFileTypes: true });
|
||||
sessionEntries = await fs.readdir(args.testsDirectory, { withFileTypes: true });
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping screenshot harvest: cannot read ${args.testsDirectory}.`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: HarvestedScreenshot[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
const legacyShots: HarvestedScreenshot[] = [];
|
||||
const bundleShots: HarvestedScreenshot[] = [];
|
||||
for (const sessionEntry of sessionEntries) {
|
||||
if (!sessionEntry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const dirPath = path.join(args.testsDirectory, entry.name);
|
||||
let filenames: string[];
|
||||
const sessionDir = path.join(args.testsDirectory, sessionEntry.name);
|
||||
let children: Dirent[];
|
||||
try {
|
||||
if ((await fs.stat(dirPath)).mtimeMs < dirSinceMtimeMs) {
|
||||
if ((await fs.stat(sessionDir)).mtimeMs < dirSinceMtimeMs) {
|
||||
continue;
|
||||
}
|
||||
filenames = await fs.readdir(dirPath);
|
||||
children = await fs.readdir(sessionDir, { withFileTypes: true });
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping unreadable debug dir ${dirPath}.`);
|
||||
args.logger.info({ err }, `Skipping unreadable debug dir ${sessionDir}.`);
|
||||
continue;
|
||||
}
|
||||
for (const filename of filenames) {
|
||||
const parsed = parseFailureScreenshotFilename(filename);
|
||||
// Re-check capture time per file: a dir's mtime can advance after this attempt began (or
|
||||
// fall within the floored-second dir baseline), so the dir gate alone would re-attribute an
|
||||
// earlier attempt's screenshot to this one.
|
||||
if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
fileAbsPath: path.join(dirPath, filename),
|
||||
displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName: parsed.flowName,
|
||||
for (const child of children) {
|
||||
if (child.isDirectory()) {
|
||||
// Maestro >= 2.7.0: a per-flow bundle dir.
|
||||
const shot = await harvestFromBundleAsync({
|
||||
bundleDir: path.join(sessionDir, child.name),
|
||||
bundleName: child.name,
|
||||
normalizedFailedFlowNames,
|
||||
capturedSinceMs: args.capturedSinceMs,
|
||||
attemptIndex: args.attemptIndex,
|
||||
capturedAtMs: parsed.capturedAtMs,
|
||||
},
|
||||
});
|
||||
logger: args.logger,
|
||||
});
|
||||
if (shot !== null) {
|
||||
bundleShots.push(shot);
|
||||
}
|
||||
} else {
|
||||
// pre-v2.7.0: a flat failure screenshot file in the session dir.
|
||||
const shot = harvestLegacyFlatShot({
|
||||
fileName: child.name,
|
||||
sessionDir,
|
||||
capturedSinceMs: args.capturedSinceMs,
|
||||
attemptIndex: args.attemptIndex,
|
||||
});
|
||||
if (shot !== null) {
|
||||
legacyShots.push(shot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
// The website shows one screenshot per (flow, attempt). Two v2.7.0 bundle dirs can resolve to
|
||||
// the same flow — a collision dir (`<flow>-2`) or a duplicate flow name — so keep the latest.
|
||||
// Legacy shots need no dedupe: a flat filename is emitted once per failed flow, so there is no
|
||||
// `<flow>-2` collision to collapse (and deduping them would change historical behavior).
|
||||
return [...legacyShots, ...dedupeBundleShotsByFlowName(bundleShots)];
|
||||
}
|
||||
|
||||
// Maestro >= 2.7.0: resolve a bundle dir to its owning failed flow and return that flow's failure
|
||||
// screenshot — the highest-numbered step in `screenshots/`. A required failure halts the flow, so
|
||||
// the failed step is normally the last (highest-numbered) captured step; earlier warned steps have
|
||||
// lower numbers. Best-effort limitation: without reading Maestro's per-command JSON we can't tell a
|
||||
// failed-step frame from a warned-step frame, so if the failing command captured nothing (e.g. a
|
||||
// non-visible command like runScript) but an earlier optional step warned, we return that warned
|
||||
// frame instead. Returns null when the bundle isn't a failed flow, its name is ambiguous, or it has
|
||||
// no usable step screenshot. Never throws.
|
||||
async function harvestFromBundleAsync(args: {
|
||||
bundleDir: string;
|
||||
bundleName: string;
|
||||
normalizedFailedFlowNames: ReadonlySet<string>;
|
||||
capturedSinceMs: number;
|
||||
attemptIndex: number;
|
||||
logger: bunyan;
|
||||
}): Promise<HarvestedScreenshot | null> {
|
||||
const flowName = resolveBundleFlowName(args.bundleName, args.normalizedFailedFlowNames);
|
||||
if (flowName === null) {
|
||||
return null;
|
||||
}
|
||||
const screenshotsDir = path.join(args.bundleDir, STEP_SCREENSHOTS_DIRNAME);
|
||||
let filenames: string[];
|
||||
try {
|
||||
filenames = await fs.readdir(screenshotsDir);
|
||||
} catch {
|
||||
// No screenshots/ dir — e.g. the flow failed on a non-visible command, which captures none.
|
||||
return null;
|
||||
}
|
||||
|
||||
let best: { fileAbsPath: string; stepIndex: number; capturedAtMs: number } | null = null;
|
||||
for (const filename of filenames) {
|
||||
const match = filename.match(STEP_SCREENSHOT_PATTERN);
|
||||
if (match === null) {
|
||||
continue; // final.png and any non-step file
|
||||
}
|
||||
const stepIndex = Number(match[1]);
|
||||
const fileAbsPath = path.join(screenshotsDir, filename);
|
||||
let capturedAtMs: number;
|
||||
try {
|
||||
capturedAtMs = Math.round((await fs.stat(fileAbsPath)).mtimeMs);
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping unreadable screenshot ${fileAbsPath}.`);
|
||||
continue;
|
||||
}
|
||||
if (capturedAtMs < args.capturedSinceMs) {
|
||||
continue; // a prior attempt's frame (bundle dir touched later than this attempt started)
|
||||
}
|
||||
if (best === null || stepIndex > best.stepIndex) {
|
||||
best = { fileAbsPath, stepIndex, capturedAtMs };
|
||||
}
|
||||
}
|
||||
if (best === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
fileAbsPath: best.fileAbsPath,
|
||||
displayName: `Failure Screenshot: ${flowName} (attempt ${args.attemptIndex + 1})`,
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName,
|
||||
attemptIndex: args.attemptIndex,
|
||||
capturedAtMs: best.capturedAtMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Maestro names a bundle dir `<cleanFlow>` optionally + `-shard-<n>` (sharded) + `-<n>` (name
|
||||
// collision), where cleanFlow is the flow name with '/' -> '_'. Those suffixes share a namespace
|
||||
// with legitimate flow names, so we can't strip blindly (a flow may be named `x-shard-1` or `x-2`).
|
||||
// Enumerate the ways the dir name could peel back to a flow name, keep those that match a failed
|
||||
// flow, and use the result only if exactly one fits — otherwise skip rather than guess.
|
||||
function resolveBundleFlowName(
|
||||
bundleName: string,
|
||||
normalizedFailedFlowNames: ReadonlySet<string>
|
||||
): string | null {
|
||||
const withoutCollision = bundleName.replace(/-\d+$/, '');
|
||||
const preimages = [
|
||||
bundleName,
|
||||
withoutCollision,
|
||||
withoutCollision.replace(/-shard-\d+$/, ''),
|
||||
bundleName.replace(/-shard-\d+$/, ''),
|
||||
];
|
||||
const matches = new Set(preimages.filter(name => normalizedFailedFlowNames.has(name)));
|
||||
return matches.size === 1 ? [...matches][0] : null;
|
||||
}
|
||||
|
||||
// Keep one screenshot per flow name (attemptIndex is constant within a harvest call), preferring
|
||||
// the latest capture when two bundles resolve to the same flow.
|
||||
function dedupeBundleShotsByFlowName(shots: HarvestedScreenshot[]): HarvestedScreenshot[] {
|
||||
const byFlowName = new Map<string, HarvestedScreenshot>();
|
||||
for (const shot of shots) {
|
||||
const existing = byFlowName.get(shot.metadata.flowName);
|
||||
if (existing === undefined || shot.metadata.capturedAtMs > existing.metadata.capturedAtMs) {
|
||||
byFlowName.set(shot.metadata.flowName, shot);
|
||||
}
|
||||
}
|
||||
return [...byFlowName.values()];
|
||||
}
|
||||
|
||||
// pre-v2.7.0 (Maestro <= 2.6.x) reader: a flat `screenshot-...❌...png` file whose name carries the
|
||||
// flow name and capture epoch.
|
||||
function harvestLegacyFlatShot(args: {
|
||||
fileName: string;
|
||||
sessionDir: string;
|
||||
capturedSinceMs: number;
|
||||
attemptIndex: number;
|
||||
}): HarvestedScreenshot | null {
|
||||
const parsed = parseFailureScreenshotFilename(args.fileName);
|
||||
// Re-check capture time per file — a dir's mtime can advance after this attempt began, so the dir
|
||||
// gate alone would re-attribute an earlier attempt's screenshot to this one.
|
||||
if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
fileAbsPath: path.join(args.sessionDir, args.fileName),
|
||||
displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName: parsed.flowName,
|
||||
attemptIndex: args.attemptIndex,
|
||||
capturedAtMs: parsed.capturedAtMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Maestro substitutes '/' -> '_' in screenshot filenames (so HarvestedScreenshot.flowName
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
copyLatestAttemptXml,
|
||||
junitFileHasFileAttrs,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowNamesFromJUnitFile,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseJUnitTestCases,
|
||||
@@ -276,11 +277,15 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
|
||||
// runs, so harvesting other formats would just create orphan artifacts the website hides.
|
||||
if (outputFormat === 'junit') {
|
||||
const failedFlowNames = outputPath
|
||||
? await parseFailedFlowNamesFromJUnitFile(outputPath)
|
||||
: new Set<string>();
|
||||
harvested.push(
|
||||
...(await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: attemptStartedAtMs,
|
||||
attemptIndex: attempt,
|
||||
failedFlowNames,
|
||||
logger,
|
||||
}))
|
||||
);
|
||||
|
||||
+12
-12
@@ -2,7 +2,7 @@ import { randomBytes } from 'crypto';
|
||||
import fetch, { RequestInit, Response } from 'node-fetch';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { GCS } from '../client';
|
||||
import { type SignedUrl, uploadWithSignedUrl } from '../uploadWithSignedUrl';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
@@ -27,14 +27,14 @@ class DNSError extends ErrorWithCode {
|
||||
|
||||
const TEST_BUCKET = 'turtle-v2-test';
|
||||
|
||||
function createSignedUrl(key: string, contentType: string): GCS.SignedUrl {
|
||||
function createSignedUrl(key: string, contentType: string): SignedUrl {
|
||||
return {
|
||||
url: `https://storage.googleapis.com/${TEST_BUCKET}/${key}?signature=test`,
|
||||
headers: { 'content-type': contentType },
|
||||
};
|
||||
}
|
||||
|
||||
describe('GCS client', () => {
|
||||
describe('signed URL uploader', () => {
|
||||
describe('uploadWithSignedUrl', () => {
|
||||
it('should throw an error if upload fails', async () => {
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
@@ -49,7 +49,7 @@ describe('GCS client', () => {
|
||||
|
||||
fetchMock.mockImplementation(async () => res);
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
@@ -69,7 +69,7 @@ describe('GCS client', () => {
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
|
||||
fetchMock.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
const result = await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
@@ -96,7 +96,7 @@ describe('GCS client', () => {
|
||||
throw new DNSError('failed twice', 'EAI_AGAIN');
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
const result = await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
@@ -130,7 +130,7 @@ describe('GCS client', () => {
|
||||
} as Response;
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
const result = await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
@@ -161,7 +161,7 @@ describe('GCS client', () => {
|
||||
throw new DNSError('failed once');
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
const result = await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
@@ -194,7 +194,7 @@ describe('GCS client', () => {
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
@@ -235,7 +235,7 @@ describe('GCS client', () => {
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
@@ -262,7 +262,7 @@ describe('GCS client', () => {
|
||||
})
|
||||
.mockImplementation(async () => res);
|
||||
await expect(
|
||||
GCS.uploadWithSignedUrl({
|
||||
uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
@@ -305,7 +305,7 @@ describe('GCS client', () => {
|
||||
|
||||
const bufferToUpload = randomBytes(16);
|
||||
|
||||
const result = await GCS.uploadWithSignedUrl({
|
||||
const result = await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from(bufferToUpload),
|
||||
retryIntervalMs: 0,
|
||||
@@ -8,7 +8,7 @@ type RetryOptions = {
|
||||
};
|
||||
|
||||
// based on https://github.com/googleapis/nodejs-storage/blob/8ab50804fc7bae3bbd159bbb4adf65c02215b11b/src/storage.ts#L284-L320
|
||||
export async function retryOnGCSUploadFailure(
|
||||
export async function retryOnUploadFailure(
|
||||
fn: (attemptCount: number) => Promise<Response>,
|
||||
{
|
||||
retries,
|
||||
@@ -0,0 +1,57 @@
|
||||
import fetch, { FetchError } from 'node-fetch';
|
||||
import { Readable } from 'stream';
|
||||
import { URL } from 'url';
|
||||
|
||||
import { retryOnUploadFailure } from './retry';
|
||||
|
||||
export type SignedUrl = {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
export type UploadWithSignedUrlParams = {
|
||||
signedUrl: SignedUrl;
|
||||
srcGeneratorAsync: () => Promise<Readable>;
|
||||
retryIntervalMs?: number;
|
||||
retries?: number;
|
||||
};
|
||||
|
||||
export async function uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync,
|
||||
retries = 2,
|
||||
retryIntervalMs = 30_000,
|
||||
}: UploadWithSignedUrlParams): Promise<string> {
|
||||
let resp;
|
||||
try {
|
||||
resp = await retryOnUploadFailure(
|
||||
async () => {
|
||||
const src = await srcGeneratorAsync();
|
||||
return await fetch(signedUrl.url, {
|
||||
method: 'PUT',
|
||||
headers: signedUrl.headers,
|
||||
body: src,
|
||||
});
|
||||
},
|
||||
{ retries, retryIntervalMs }
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof FetchError) {
|
||||
throw new Error(`Failed to upload the file, reason: ${err.code}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
let body: string | undefined;
|
||||
try {
|
||||
body = await resp.text();
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(signedUrl.url);
|
||||
return `${url.protocol}//${url.host}${url.pathname}`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "21.0.2",
|
||||
"version": "21.0.3",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "21.0.2"
|
||||
"@expo/steps": "21.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "21.0.2",
|
||||
"version": "21.0.3",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { HOOK_ANCHORS, parseHookKey } from '../hooks';
|
||||
|
||||
describe('HOOK_ANCHORS', () => {
|
||||
it('contains the v1 anchors', () => {
|
||||
it('contains the registered anchors', () => {
|
||||
expect([...HOOK_ANCHORS].sort()).toEqual([
|
||||
'checkout',
|
||||
'install_node_modules',
|
||||
'maestro_cloud',
|
||||
'maestro_tests',
|
||||
'submit',
|
||||
'update',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export enum ErrorCode {
|
||||
UNKNOWN_FASTLANE_RESIGN_ERROR = 'EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR',
|
||||
UNKNOWN_GRADLE_ERROR = 'EAS_BUILD_UNKNOWN_GRADLE_ERROR',
|
||||
BUILD_TIMEOUT_ERROR = 'EAS_BUILD_TIMEOUT_ERROR',
|
||||
HOOKS_ERROR = 'EAS_BUILD_HOOKS_ERROR',
|
||||
}
|
||||
|
||||
export interface ExternalBuildError {
|
||||
|
||||
@@ -18,6 +18,7 @@ export const HOOK_ANCHORS = [
|
||||
'maestro_tests',
|
||||
'maestro_cloud',
|
||||
'checkout',
|
||||
'update',
|
||||
] as const;
|
||||
|
||||
export type HookAnchorId = (typeof HOOK_ANCHORS)[number];
|
||||
|
||||
+144
-170
File diff suppressed because it is too large.
Load diff
Generated
+501
@@ -12174,6 +12174,87 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "workflowCachesPaginated",
|
||||
"description": "Caches associated with this app, ordered by last access time (most recent first).",
|
||||
"args": [
|
||||
{
|
||||
"name": "after",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "before",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "first",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "last",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Int",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "AppWorkflowCachesConnection",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "workflowCachingConfig",
|
||||
"description": "Per-type caching configuration for this app.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowCachingConfig",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "workflowDeviceTestCaseHistory",
|
||||
"description": null,
|
||||
@@ -13802,6 +13883,55 @@
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "setWorkflowCachingConfig",
|
||||
"description": "Set per-type caching configuration for the app.",
|
||||
"args": [
|
||||
{
|
||||
"name": "appId",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "config",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "WorkflowCachingConfigInput",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "App",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
@@ -23454,6 +23584,100 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "AppWorkflowCacheEdge",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "cursor",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "node",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowCache",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "AppWorkflowCachesConnection",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "edges",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "AppWorkflowCacheEdge",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "pageInfo",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "PageInfo",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "AppWorkflowFilterInput",
|
||||
@@ -64215,6 +64439,22 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "workflowCache",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowCacheMutation",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "workflowDeviceTestCaseResult",
|
||||
"description": null,
|
||||
@@ -75243,6 +75483,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "searchTerm",
|
||||
"description": "Case-insensitive substring match on update group message and branch name.\nEmbedded updates are matched on channel and runtime version.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "types",
|
||||
"description": null,
|
||||
@@ -86280,6 +86532,255 @@
|
||||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowCache",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "createdAt",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "DateTime",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "creatingActor",
|
||||
"description": "Actor who created this cache. Null if the creating user no longer exists.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "INTERFACE",
|
||||
"name": "Actor",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "gitBranchName",
|
||||
"description": "Git branch name the cache was created from. Null for user-scoped caches.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "hydratingBuild",
|
||||
"description": "Build that created this cache. Null if the cache was created by a job run\nor the build no longer exists.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "OBJECT",
|
||||
"name": "Build",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "hydratingJobRun",
|
||||
"description": "Job run that created this cache. Null if the cache was created by a build\nor the job run no longer exists.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "OBJECT",
|
||||
"name": "JobRun",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"description": "Cache key used to identify the cache entry.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "lastAccessedAt",
|
||||
"description": "Time the cache was last used to restore, or the creation time if it has\nnever been restored.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "DateTime",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "sizeBytes",
|
||||
"description": "Size of the cache archive in bytes. Null if the cache has not been hydrated yet.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Float",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowCacheMutation",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "deleteWorkflowCache",
|
||||
"description": null,
|
||||
"args": [
|
||||
{
|
||||
"name": "cacheId",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowCache",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowCachingConfig",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "ccacheEnabled",
|
||||
"description": "Whether the ccache compiler cache is enabled. Null if not explicitly\nconfigured, in which case the account-level default applies.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "gradleCacheEnabled",
|
||||
"description": "Whether the Gradle build cache is enabled. Null if not explicitly\nconfigured, in which case it is disabled.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "WorkflowCachingConfigInput",
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "ccacheEnabled",
|
||||
"description": "Pass true or false to explicitly enable or disable, null to reset to the account-level\ndefault, or omit the field to leave it unchanged.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "gradleCacheEnabled",
|
||||
"description": "Pass true or false to explicitly enable or disable, null to reset to the default\n(disabled), or omit the field to leave it unchanged.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"interfaces": null,
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowDeviceTestCaseErrorPattern",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "21.0.2",
|
||||
"version": "21.0.3",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,8 +48,8 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "21.0.2",
|
||||
"@expo/eas-json": "21.0.2",
|
||||
"@expo/eas-build-job": "21.0.3",
|
||||
"@expo/eas-json": "21.0.3",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "21.0.0",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.0.2",
|
||||
"@expo/steps": "21.0.3",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import { makeUserMessage, streamChatResponseAsync } from '../chatClient';
|
||||
|
||||
const mockSpinners: { isSpinning: boolean; text: string }[] = [];
|
||||
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../ora', () => ({
|
||||
ora: () => {
|
||||
const spinner = {
|
||||
isSpinning: true,
|
||||
text: '',
|
||||
start: () => spinner,
|
||||
stop: () => {
|
||||
spinner.isSpinning = false;
|
||||
return spinner;
|
||||
},
|
||||
};
|
||||
mockSpinners.push(spinner);
|
||||
return spinner;
|
||||
},
|
||||
}));
|
||||
|
||||
const WEBSITE_ORIGIN = 'https://expo.dev';
|
||||
|
||||
function sseBody(frames: object[]): string {
|
||||
return [...frames.map(frame => `data: ${JSON.stringify(frame)}`), 'data: [DONE]', ''].join(
|
||||
'\n\n'
|
||||
);
|
||||
}
|
||||
|
||||
describe(streamChatResponseAsync, () => {
|
||||
let writeSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
nock.cleanAll();
|
||||
mockSpinners.length = 0;
|
||||
writeSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('streams text deltas to stdout and returns the full text', async () => {
|
||||
nock(WEBSITE_ORIGIN)
|
||||
.post('/api/chat')
|
||||
.reply(
|
||||
200,
|
||||
sseBody([
|
||||
{ type: 'start' },
|
||||
{ type: 'tool-input-available', toolCallId: 't1', toolName: 'get_latest_builds' },
|
||||
{ type: 'text-start', id: '0' },
|
||||
{ type: 'text-delta', id: '0', delta: 'Your latest ' },
|
||||
{ type: 'text-delta', id: '0', delta: 'build passed.' },
|
||||
{ type: 'finish' },
|
||||
]),
|
||||
{ 'content-type': 'text/event-stream' }
|
||||
);
|
||||
|
||||
const result = await streamChatResponseAsync({
|
||||
messages: [makeUserMessage('how are my builds?')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"session-id","version":"1"}',
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const written = writeSpy.mock.calls.map(call => call[0]).join('');
|
||||
expect(written).toContain('Your latest ');
|
||||
expect(written).toContain('build passed.');
|
||||
expect(result.text).toBe('Your latest build passed.');
|
||||
});
|
||||
|
||||
it('neutralizes terminal control characters in streamed text deltas', async () => {
|
||||
const delta = 'Safe \u001b[2Jtext\u009b31m\u0007.';
|
||||
nock(WEBSITE_ORIGIN)
|
||||
.post('/api/chat')
|
||||
.reply(200, sseBody([{ type: 'text-delta', id: '0', delta }]), {
|
||||
'content-type': 'text/event-stream',
|
||||
});
|
||||
|
||||
const result = await streamChatResponseAsync({
|
||||
messages: [makeUserMessage('show the build log')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const written = writeSpy.mock.calls.map(call => call[0]).join('');
|
||||
expect(written).toContain('Safe [2Jtext31m.');
|
||||
expect(written).not.toContain('\u001b[2J');
|
||||
expect(written).not.toContain('\u009b31m');
|
||||
expect(written).not.toContain('\u0007');
|
||||
expect(result.text).toBe(delta);
|
||||
});
|
||||
|
||||
it('keeps the spinner active until a full line is ready instead of clearing it on the first delta', async () => {
|
||||
nock(WEBSITE_ORIGIN)
|
||||
.post('/api/chat')
|
||||
.reply(
|
||||
200,
|
||||
sseBody([
|
||||
{ type: 'text-start', id: '0' },
|
||||
{ type: 'text-delta', id: '0', delta: 'Looking good' },
|
||||
{ type: 'tool-input-available', toolCallId: 't1', toolName: 'get_latest_builds' },
|
||||
{ type: 'text-delta', id: '0', delta: ' so far.' },
|
||||
{ type: 'finish' },
|
||||
]),
|
||||
{ 'content-type': 'text/event-stream' }
|
||||
);
|
||||
|
||||
const result = await streamChatResponseAsync({
|
||||
messages: [makeUserMessage('how are my builds?')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const written = writeSpy.mock.calls.map(call => call[0]).join('');
|
||||
// No newline arrives until the end, so nothing is flushed mid-stream: the tool activity
|
||||
// is shown on the still-running spinner rather than being printed inline.
|
||||
expect(mockSpinners[0].text).toContain('Looking up builds');
|
||||
expect(written).not.toContain('Looking up builds');
|
||||
// The buffered text is rendered in one go once the response completes.
|
||||
expect(written).toContain('Looking good so far.');
|
||||
expect(result.text).toBe('Looking good so far.');
|
||||
});
|
||||
|
||||
it('captures tool calls with inputs and outputs without writing to stdout when not streaming', async () => {
|
||||
nock(WEBSITE_ORIGIN)
|
||||
.post('/api/chat')
|
||||
.reply(
|
||||
200,
|
||||
sseBody([
|
||||
{ type: 'start-step' },
|
||||
{
|
||||
type: 'tool-input-available',
|
||||
toolCallId: 't1',
|
||||
toolName: 'get_latest_builds',
|
||||
input: { limit: 1 },
|
||||
},
|
||||
{ type: 'tool-output-available', toolCallId: 't1', output: { builds: ['b1'] } },
|
||||
{ type: 'finish-step' },
|
||||
{ type: 'start-step' },
|
||||
{ type: 'text-delta', delta: 'Done.' },
|
||||
{ type: 'finish-step' },
|
||||
{ type: 'finish' },
|
||||
])
|
||||
);
|
||||
|
||||
const result = await streamChatResponseAsync({
|
||||
messages: [makeUserMessage('builds?')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: false,
|
||||
});
|
||||
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
expect(result.text).toBe('Done.');
|
||||
expect(result.toolCalls).toEqual([
|
||||
{
|
||||
toolName: 'get_latest_builds',
|
||||
input: { limit: 1 },
|
||||
output: { builds: ['b1'] },
|
||||
errorText: undefined,
|
||||
},
|
||||
]);
|
||||
expect(result.assistantMessage).toEqual(
|
||||
expect.objectContaining({
|
||||
role: 'assistant',
|
||||
parts: [
|
||||
{ type: 'step-start' },
|
||||
{
|
||||
type: 'tool-get_latest_builds',
|
||||
toolCallId: 't1',
|
||||
state: 'output-available',
|
||||
input: { limit: 1 },
|
||||
output: { builds: ['b1'] },
|
||||
},
|
||||
{ type: 'step-start' },
|
||||
{ type: 'text', text: 'Done.' },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the session secret as a cookie and the account as a header', async () => {
|
||||
let capturedHeaders: Record<string, string | string[]> = {};
|
||||
let capturedBody: any;
|
||||
nock(WEBSITE_ORIGIN)
|
||||
.post('/api/chat')
|
||||
.reply(function (_uri, body) {
|
||||
capturedHeaders = this.req.headers;
|
||||
capturedBody = body;
|
||||
return [200, sseBody([{ type: 'text-delta', delta: 'hi' }])];
|
||||
});
|
||||
|
||||
await streamChatResponseAsync({
|
||||
messages: [makeUserMessage('hello')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const cookie = [capturedHeaders.cookie].flat().join('; ');
|
||||
expect(cookie).toContain('io.expo.auth.sessionSecret=');
|
||||
expect(cookie).toContain(encodeURIComponent('{"id":"abc","version":"1"}'));
|
||||
expect([capturedHeaders['x-account-name']].flat()[0]).toBe('my-account');
|
||||
expect(capturedBody.messages[0].role).toBe('system');
|
||||
expect(capturedBody.messages[0].parts[0].text).toContain('https://expo.dev');
|
||||
expect(capturedBody.messages[1]).toEqual(
|
||||
expect.objectContaining({ role: 'user', parts: [{ type: 'text', text: 'hello' }] })
|
||||
);
|
||||
});
|
||||
|
||||
it('maps a 429 response to a usage-limit error', async () => {
|
||||
nock(WEBSITE_ORIGIN).post('/api/chat').reply(429, 'Chat usage limit reached');
|
||||
|
||||
await expect(
|
||||
streamChatResponseAsync({
|
||||
messages: [makeUserMessage('hello')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: false,
|
||||
})
|
||||
).rejects.toThrow(/usage limit/);
|
||||
});
|
||||
|
||||
it('maps a 403 response to a not-enabled error', async () => {
|
||||
nock(WEBSITE_ORIGIN).post('/api/chat').reply(403, 'Forbidden');
|
||||
|
||||
await expect(
|
||||
streamChatResponseAsync({
|
||||
messages: [makeUserMessage('hello')],
|
||||
accountName: 'my-account',
|
||||
sessionSecret: '{"id":"abc","version":"1"}',
|
||||
stream: false,
|
||||
})
|
||||
).rejects.toThrow(/not enabled/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
createMarkdownRenderState,
|
||||
renderInlineMarkdown,
|
||||
renderMarkdownLine,
|
||||
wrapToWidth,
|
||||
} from '../renderMarkdown';
|
||||
|
||||
describe(renderInlineMarkdown, () => {
|
||||
it('renders bold with chalk.bold', () => {
|
||||
expect(renderInlineMarkdown('a **bold** word')).toBe(`a ${chalk.bold('bold')} word`);
|
||||
expect(renderInlineMarkdown('a __bold__ word')).toBe(`a ${chalk.bold('bold')} word`);
|
||||
});
|
||||
|
||||
it('renders inline code with chalk.cyan', () => {
|
||||
expect(renderInlineMarkdown('run `eas build` now')).toBe(`run ${chalk.cyan('eas build')} now`);
|
||||
});
|
||||
|
||||
it('leaves underscores inside a word untouched', () => {
|
||||
expect(renderInlineMarkdown('my_project_name')).toBe('my_project_name');
|
||||
});
|
||||
|
||||
it('renders links as underlined label with dimmed url', () => {
|
||||
expect(renderInlineMarkdown('see [build](https://expo.dev/b/1)')).toBe(
|
||||
`see ${chalk.underline('build')} ${chalk.dim('(https://expo.dev/b/1)')}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(renderMarkdownLine, () => {
|
||||
it('renders bullet lines with a bullet glyph', () => {
|
||||
const state = createMarkdownRenderState();
|
||||
expect(renderMarkdownLine('- first', state)).toBe(`${chalk.dim('•')} first`);
|
||||
});
|
||||
|
||||
it('drops code fences and dims code block contents', () => {
|
||||
const state = createMarkdownRenderState();
|
||||
expect(renderMarkdownLine('```ts', state)).toBeNull();
|
||||
expect(state.inCodeBlock).toBe(true);
|
||||
expect(renderMarkdownLine('const x = 1;', state)).toBe(chalk.gray('const x = 1;'));
|
||||
expect(renderMarkdownLine('```', state)).toBeNull();
|
||||
expect(state.inCodeBlock).toBe(false);
|
||||
});
|
||||
|
||||
it('applies inline styling to plain lines', () => {
|
||||
const state = createMarkdownRenderState();
|
||||
expect(renderMarkdownLine('the **latest** build', state)).toBe(
|
||||
`the ${chalk.bold('latest')} build`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(wrapToWidth, () => {
|
||||
it('word-wraps to the given width', () => {
|
||||
const segments = wrapToWidth('one two three four five', 12);
|
||||
expect(segments.length).toBeGreaterThan(1);
|
||||
for (const segment of segments) {
|
||||
expect(segment.length).toBeLessThanOrEqual(12);
|
||||
}
|
||||
expect(segments.join(' ').replace(/\s+/g, ' ').trim()).toBe('one two three four five');
|
||||
});
|
||||
|
||||
it('hard-breaks long unbreakable tokens like URLs', () => {
|
||||
const url = 'https://expo.dev/accounts/acme/projects/mobile/builds/cb7c9bdd-56e6-42b8';
|
||||
const segments = wrapToWidth(url, 20);
|
||||
expect(segments.length).toBeGreaterThan(1);
|
||||
for (const segment of segments) {
|
||||
expect(segment.length).toBeLessThanOrEqual(20);
|
||||
}
|
||||
expect(segments.join('')).toBe(url);
|
||||
});
|
||||
|
||||
it('returns the text unchanged when the width is too small to wrap into', () => {
|
||||
expect(wrapToWidth('anything at all here', 4)).toEqual(['anything at all here']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { createChatReplInput } from '../replInput';
|
||||
|
||||
describe(createChatReplInput, () => {
|
||||
it('resolves the typed line', async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const replInput = createChatReplInput({ input, output, terminal: false });
|
||||
|
||||
const pending = replInput.askAsync('> ');
|
||||
input.write('how are my builds?\n');
|
||||
|
||||
expect(await pending).toBe('how are my builds?');
|
||||
replInput.close();
|
||||
});
|
||||
|
||||
it('reads input even when the stream was left paused (e.g. by a spinner)', async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const replInput = createChatReplInput({ input, output, terminal: false });
|
||||
input.pause();
|
||||
|
||||
const pending = replInput.askAsync('> ');
|
||||
input.write('hello\n');
|
||||
|
||||
expect(await pending).toBe('hello');
|
||||
replInput.close();
|
||||
});
|
||||
|
||||
it('resolves null when the input stream ends', async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const replInput = createChatReplInput({ input, output, terminal: false });
|
||||
|
||||
const pending = replInput.askAsync('> ');
|
||||
input.end();
|
||||
|
||||
expect(await pending).toBeNull();
|
||||
replInput.close();
|
||||
});
|
||||
|
||||
it('resolves null immediately once closed', async () => {
|
||||
const input = new PassThrough();
|
||||
const output = new PassThrough();
|
||||
const replInput = createChatReplInput({ input, output, terminal: false });
|
||||
replInput.close();
|
||||
|
||||
expect(await replInput.askAsync('> ')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
import { StringDecoder } from 'node:string_decoder';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getExpoWebsiteBaseUrl } from '../api';
|
||||
import fetch, { RequestError } from '../fetch';
|
||||
import Log from '../log';
|
||||
import { ora } from '../ora';
|
||||
import {
|
||||
MarkdownRenderState,
|
||||
createMarkdownRenderState,
|
||||
renderMarkdownLine,
|
||||
wrapToWidth,
|
||||
} from './renderMarkdown';
|
||||
|
||||
const CHAT_SYSTEM_GUIDANCE = [
|
||||
'You are being used through the EAS CLI in a terminal, not the web dashboard.',
|
||||
'The user cannot see the interactive tables the dashboard renders from tool results, so include the important details directly in your text answer instead of assuming a table is shown.',
|
||||
'Whenever you reference a specific artifact (a build, update, submission, deployment, workflow run, channel, or branch), include its direct https://expo.dev link so the user can open it.',
|
||||
'Use only simple markdown: bold, inline code, and bullet or numbered lists.',
|
||||
].join(' ');
|
||||
|
||||
function getWebsiteAuthCookieName(): string {
|
||||
if (process.env.EXPO_STAGING) {
|
||||
return 'staging.expo.auth.sessionSecret';
|
||||
} else if (process.env.EXPO_LOCAL) {
|
||||
return 'local.expo.auth.sessionSecret';
|
||||
} else {
|
||||
return 'io.expo.auth.sessionSecret';
|
||||
}
|
||||
}
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
get_projects: 'Looking up your projects',
|
||||
get_latest_builds: 'Looking up builds',
|
||||
get_build: 'Looking up a build',
|
||||
get_build_logs: 'Reading build logs',
|
||||
get_activity: 'Looking up recent activity',
|
||||
get_updates: 'Looking up updates',
|
||||
get_workflow_runs: 'Looking up workflow runs',
|
||||
get_workflow_run: 'Looking up a workflow run',
|
||||
get_workflow_run_logs: 'Reading workflow logs',
|
||||
get_submissions: 'Looking up submissions',
|
||||
get_channels: 'Looking up channels',
|
||||
get_branches: 'Looking up branches',
|
||||
get_worker_deployments: 'Looking up deployments',
|
||||
get_environment_variables: 'Looking up environment variables',
|
||||
get_account_members: 'Looking up account members',
|
||||
get_usage: 'Looking up usage',
|
||||
get_usage_history: 'Looking up usage history',
|
||||
get_invoices: 'Looking up invoices',
|
||||
search_expo_docs: 'Searching Expo docs',
|
||||
navigate: 'Finding the right page',
|
||||
};
|
||||
|
||||
function describeTool(toolName: string): string {
|
||||
return TOOL_LABELS[toolName] ?? `Using ${toolName.replace(/_/g, ' ')}`;
|
||||
}
|
||||
|
||||
const ASSISTANT_LABEL_TEXT = 'Expo';
|
||||
const ASSISTANT_LABEL = `${chalk.bold.magenta(ASSISTANT_LABEL_TEXT)}${chalk.dim(' > ')}`;
|
||||
const ASSISTANT_INDENT = ' '.repeat(`${ASSISTANT_LABEL_TEXT} > `.length);
|
||||
const ASSISTANT_SPINNER_PREFIX = `${chalk.bold.magenta(ASSISTANT_LABEL_TEXT)}${chalk.dim(' >')}`;
|
||||
|
||||
type UIMessageStreamFrame = {
|
||||
type: string;
|
||||
id?: string;
|
||||
delta?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
type ChatMessagePart =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'step-start' }
|
||||
| {
|
||||
type: `tool-${string}`;
|
||||
toolCallId: string;
|
||||
state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error';
|
||||
input: unknown;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
parts: ChatMessagePart[];
|
||||
};
|
||||
|
||||
function makeSystemMessage(text: string): ChatMessage {
|
||||
return { id: uuidv4(), role: 'system', parts: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
export function makeUserMessage(text: string): ChatMessage {
|
||||
return { id: uuidv4(), role: 'user', parts: [{ type: 'text', text }] };
|
||||
}
|
||||
|
||||
function makeAssistantMessage(parts: ChatMessagePart[]): ChatMessage {
|
||||
return { id: uuidv4(), role: 'assistant', parts };
|
||||
}
|
||||
|
||||
export type ChatToolCall = {
|
||||
toolName: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
errorText?: string;
|
||||
};
|
||||
|
||||
export type ChatResult = {
|
||||
text: string;
|
||||
toolCalls: ChatToolCall[];
|
||||
assistantMessage: ChatMessage;
|
||||
};
|
||||
|
||||
export async function streamChatResponseAsync({
|
||||
messages,
|
||||
accountName,
|
||||
sessionSecret,
|
||||
stream,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
accountName: string | undefined;
|
||||
sessionSecret: string;
|
||||
stream: boolean;
|
||||
}): Promise<ChatResult> {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${getExpoWebsiteBaseUrl()}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'text/event-stream',
|
||||
cookie: `${getWebsiteAuthCookieName()}=${encodeURIComponent(sessionSecret)}`,
|
||||
...(accountName ? { 'x-account-name': accountName } : {}),
|
||||
},
|
||||
body: JSON.stringify({ messages: [makeSystemMessage(CHAT_SYSTEM_GUIDANCE), ...messages] }),
|
||||
});
|
||||
} catch (error) {
|
||||
throw toFriendlyChatError(error);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('The chat service returned an empty response.');
|
||||
}
|
||||
|
||||
const spinner = stream
|
||||
? ora({ text: 'Thinking…', discardStdin: false, prefixText: ASSISTANT_SPINNER_PREFIX }).start()
|
||||
: undefined;
|
||||
|
||||
let spinnerStopped = false;
|
||||
const stopSpinner = (): void => {
|
||||
if (!spinner || spinnerStopped) {
|
||||
return;
|
||||
}
|
||||
spinnerStopped = true;
|
||||
spinner.stop();
|
||||
if (process.stdout.isTTY) {
|
||||
process.stdout.write('\r');
|
||||
}
|
||||
};
|
||||
const toolMessagePartsById = new Map<string, Extract<ChatMessagePart, { toolCallId: string }>>();
|
||||
const textMessagePartsById = new Map<string, Extract<ChatMessagePart, { type: 'text' }>>();
|
||||
const assistantMessageParts: ChatMessagePart[] = [];
|
||||
const announcedTools = new Set<string>();
|
||||
const markdownState: MarkdownRenderState = createMarkdownRenderState();
|
||||
let fullText = '';
|
||||
let streamingText = false;
|
||||
let errorText: string | undefined;
|
||||
let buffer = '';
|
||||
let displayBuffer = '';
|
||||
let wroteAssistantLine = false;
|
||||
let currentTextMessagePart: Extract<ChatMessagePart, { type: 'text' }> | undefined;
|
||||
|
||||
const getOrCreateToolMessagePart = (
|
||||
toolCallId: string,
|
||||
toolName: string
|
||||
): Extract<ChatMessagePart, { toolCallId: string }> => {
|
||||
const existing = toolMessagePartsById.get(toolCallId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const part: Extract<ChatMessagePart, { toolCallId: string }> = {
|
||||
type: `tool-${toolName}`,
|
||||
toolCallId,
|
||||
state: 'input-streaming',
|
||||
input: undefined,
|
||||
};
|
||||
toolMessagePartsById.set(toolCallId, part);
|
||||
assistantMessageParts.push(part);
|
||||
return part;
|
||||
};
|
||||
|
||||
const getOrCreateTextMessagePart = (
|
||||
id: string | undefined
|
||||
): Extract<ChatMessagePart, { type: 'text' }> => {
|
||||
const existing = id ? textMessagePartsById.get(id) : currentTextMessagePart;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const part: Extract<ChatMessagePart, { type: 'text' }> = { type: 'text', text: '' };
|
||||
if (id) {
|
||||
textMessagePartsById.set(id, part);
|
||||
}
|
||||
currentTextMessagePart = part;
|
||||
assistantMessageParts.push(part);
|
||||
return part;
|
||||
};
|
||||
|
||||
const writeAssistantLine = (rendered: string, withNewline: boolean): void => {
|
||||
if (!streamingText) {
|
||||
stopSpinner();
|
||||
streamingText = true;
|
||||
}
|
||||
const columns = process.stdout.columns ?? 0;
|
||||
const width = columns > 0 ? columns - ASSISTANT_INDENT.length : 0;
|
||||
const segments = wrapToWidth(rendered, width);
|
||||
segments.forEach((segment, index) => {
|
||||
const prefix = wroteAssistantLine ? ASSISTANT_INDENT : ASSISTANT_LABEL;
|
||||
wroteAssistantLine = true;
|
||||
const isLastSegment = index === segments.length - 1;
|
||||
const needsNewline = !isLastSegment || withNewline;
|
||||
process.stdout.write(`${prefix}${segment}${needsNewline ? '\n' : ''}`);
|
||||
});
|
||||
};
|
||||
|
||||
const flushDisplayLines = (flushRemainder: boolean): void => {
|
||||
let newlineIndex: number;
|
||||
while ((newlineIndex = displayBuffer.indexOf('\n')) !== -1) {
|
||||
const rawLine = displayBuffer.slice(0, newlineIndex);
|
||||
displayBuffer = displayBuffer.slice(newlineIndex + 1);
|
||||
const rendered = renderMarkdownLine(rawLine, markdownState);
|
||||
if (rendered !== null) {
|
||||
writeAssistantLine(rendered, true);
|
||||
}
|
||||
}
|
||||
if (flushRemainder && displayBuffer.length > 0) {
|
||||
const rendered = renderMarkdownLine(displayBuffer, markdownState);
|
||||
displayBuffer = '';
|
||||
if (rendered !== null) {
|
||||
writeAssistantLine(rendered, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const announceTool = (toolName: string): void => {
|
||||
if (!stream || announcedTools.has(toolName)) {
|
||||
return;
|
||||
}
|
||||
announcedTools.add(toolName);
|
||||
const label = describeTool(toolName);
|
||||
if (streamingText) {
|
||||
flushDisplayLines(true);
|
||||
process.stdout.write(chalk.dim(`\n${ASSISTANT_INDENT}(${label})\n`));
|
||||
} else if (spinner) {
|
||||
spinner.text = `${label}…`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFrame = (frame: UIMessageStreamFrame): void => {
|
||||
switch (frame.type) {
|
||||
case 'start-step': {
|
||||
assistantMessageParts.push({ type: 'step-start' });
|
||||
currentTextMessagePart = undefined;
|
||||
break;
|
||||
}
|
||||
case 'text-start': {
|
||||
currentTextMessagePart = getOrCreateTextMessagePart(frame.id);
|
||||
break;
|
||||
}
|
||||
case 'text-delta': {
|
||||
const delta = typeof frame.delta === 'string' ? frame.delta : '';
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
fullText += delta;
|
||||
getOrCreateTextMessagePart(frame.id).text += delta;
|
||||
if (stream) {
|
||||
displayBuffer += delta;
|
||||
flushDisplayLines(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text-end': {
|
||||
currentTextMessagePart = undefined;
|
||||
break;
|
||||
}
|
||||
case 'tool-input-start':
|
||||
case 'tool-input-available': {
|
||||
const { toolCallId, toolName } = frame;
|
||||
if (!toolCallId || !toolName) {
|
||||
return;
|
||||
}
|
||||
const toolPart = getOrCreateToolMessagePart(toolCallId, toolName);
|
||||
if (frame.type === 'tool-input-available') {
|
||||
toolPart.state = 'input-available';
|
||||
toolPart.input = frame.input;
|
||||
delete toolPart.output;
|
||||
delete toolPart.errorText;
|
||||
} else if (frame.input !== undefined) {
|
||||
toolPart.input = frame.input;
|
||||
}
|
||||
announceTool(toolName);
|
||||
break;
|
||||
}
|
||||
case 'tool-input-error': {
|
||||
const { toolCallId, toolName } = frame;
|
||||
if (!toolCallId || !toolName || typeof frame.errorText !== 'string') {
|
||||
return;
|
||||
}
|
||||
const toolPart = getOrCreateToolMessagePart(toolCallId, toolName);
|
||||
toolPart.state = 'output-error';
|
||||
toolPart.input = frame.input;
|
||||
delete toolPart.output;
|
||||
toolPart.errorText = frame.errorText;
|
||||
announceTool(toolName);
|
||||
break;
|
||||
}
|
||||
case 'tool-output-available': {
|
||||
const { toolCallId } = frame;
|
||||
if (!toolCallId) {
|
||||
return;
|
||||
}
|
||||
const toolPart = toolMessagePartsById.get(toolCallId);
|
||||
if (toolPart) {
|
||||
toolPart.state = 'output-available';
|
||||
toolPart.output = frame.output;
|
||||
delete toolPart.errorText;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'tool-output-error': {
|
||||
const { toolCallId } = frame;
|
||||
if (!toolCallId || typeof frame.errorText !== 'string') {
|
||||
return;
|
||||
}
|
||||
const toolPart = toolMessagePartsById.get(toolCallId);
|
||||
if (toolPart) {
|
||||
toolPart.state = 'output-error';
|
||||
delete toolPart.output;
|
||||
toolPart.errorText = frame.errorText;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
if (typeof frame.errorText === 'string') {
|
||||
errorText = frame.errorText;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const decoder = new StringDecoder('utf8');
|
||||
try {
|
||||
for await (const chunk of response.body) {
|
||||
buffer += decoder.write(chunk as Buffer);
|
||||
let newlineIndex: number;
|
||||
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (!line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
const data = line.slice('data:'.length).trim();
|
||||
if (!data || data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
let frame: UIMessageStreamFrame;
|
||||
try {
|
||||
frame = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
handleFrame(frame);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (stream) {
|
||||
flushDisplayLines(true);
|
||||
}
|
||||
stopSpinner();
|
||||
}
|
||||
|
||||
if (errorText) {
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
const toolCalls: ChatToolCall[] = assistantMessageParts
|
||||
.filter((part): part is Extract<ChatMessagePart, { toolCallId: string }> =>
|
||||
part.type.startsWith('tool-')
|
||||
)
|
||||
.map(part => ({
|
||||
toolName: part.type.slice('tool-'.length),
|
||||
input: part.input,
|
||||
output: part.output,
|
||||
errorText: part.errorText,
|
||||
}));
|
||||
const assistantMessage = makeAssistantMessage(
|
||||
assistantMessageParts.filter(
|
||||
part =>
|
||||
part.type === 'text' ||
|
||||
part.type === 'step-start' ||
|
||||
part.state === 'output-available' ||
|
||||
part.state === 'output-error'
|
||||
)
|
||||
);
|
||||
|
||||
if (stream) {
|
||||
if (fullText.length > 0) {
|
||||
Log.newLine();
|
||||
} else {
|
||||
Log.log(
|
||||
chalk.dim(
|
||||
toolCalls.length > 0
|
||||
? 'The assistant looked up your data but did not return a text response. Try asking a more specific question.'
|
||||
: 'The assistant did not return a response.'
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { text: fullText, toolCalls, assistantMessage };
|
||||
}
|
||||
|
||||
function toFriendlyChatError(error: unknown): Error {
|
||||
if (error instanceof RequestError) {
|
||||
const status = error.response.status;
|
||||
if (status === 401) {
|
||||
return new Error('Your Expo session is not valid for chat. Run `eas login` and try again.');
|
||||
}
|
||||
if (status === 403) {
|
||||
return new Error(
|
||||
'Chat is not enabled for this account yet. It is still rolling out. If you think you should have access, reach out to Expo.'
|
||||
);
|
||||
}
|
||||
if (status === 429) {
|
||||
return new Error(
|
||||
'You have reached your chat usage limit. Try again later or upgrade your plan for a larger allowance.'
|
||||
);
|
||||
}
|
||||
return new Error(`The chat request failed (HTTP ${status}).`);
|
||||
}
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getConfigFilePaths } from '@expo/config';
|
||||
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { findProjectDirAndVerifyProjectSetupAsync } from '../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync';
|
||||
import { AppQuery } from '../graphql/queries/AppQuery';
|
||||
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
|
||||
|
||||
export async function detectCurrentProjectAsync(
|
||||
graphqlClient: ExpoGraphqlClient
|
||||
): Promise<{ accountName: string; label: string } | null> {
|
||||
let projectDir: string;
|
||||
try {
|
||||
projectDir = await findProjectDirAndVerifyProjectSetupAsync();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!projectDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configPaths = getConfigFilePaths(projectDir);
|
||||
if (!configPaths.staticConfigPath && !configPaths.dynamicConfigPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let projectId: unknown;
|
||||
try {
|
||||
const expoConfig = await getPrivateExpoConfigAsync(projectDir);
|
||||
projectId = expoConfig.extra?.eas?.projectId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof projectId !== 'string' || !projectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
|
||||
return { accountName: app.ownerAccount.name, label: app.fullName };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import chalk from 'chalk';
|
||||
import wrapAnsi from 'wrap-ansi';
|
||||
|
||||
export function wrapToWidth(text: string, width: number): string[] {
|
||||
if (width < 10) {
|
||||
return [text];
|
||||
}
|
||||
return wrapAnsi(text, width, { hard: true, trim: false }).split('\n');
|
||||
}
|
||||
|
||||
export type MarkdownRenderState = { inCodeBlock: boolean };
|
||||
|
||||
export function createMarkdownRenderState(): MarkdownRenderState {
|
||||
return { inCodeBlock: false };
|
||||
}
|
||||
|
||||
const FENCE_REGEX = /^\s*```/;
|
||||
const BULLET_REGEX = /^(\s*)[-*+]\s+(.*)$/;
|
||||
const ORDERED_REGEX = /^(\s*)(\d+)\.\s+(.*)$/;
|
||||
const TERMINAL_CONTROL_CHARACTERS_REGEX = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
||||
|
||||
export function renderMarkdownLine(line: string, state: MarkdownRenderState): string | null {
|
||||
const sanitizedLine = line.replace(TERMINAL_CONTROL_CHARACTERS_REGEX, '');
|
||||
if (FENCE_REGEX.test(sanitizedLine)) {
|
||||
state.inCodeBlock = !state.inCodeBlock;
|
||||
return null;
|
||||
}
|
||||
if (state.inCodeBlock) {
|
||||
return chalk.gray(sanitizedLine);
|
||||
}
|
||||
|
||||
const bullet = sanitizedLine.match(BULLET_REGEX);
|
||||
if (bullet) {
|
||||
return `${bullet[1]}${chalk.dim('•')} ${renderInlineMarkdown(bullet[2])}`;
|
||||
}
|
||||
|
||||
const ordered = sanitizedLine.match(ORDERED_REGEX);
|
||||
if (ordered) {
|
||||
return `${ordered[1]}${chalk.dim(`${ordered[2]}.`)} ${renderInlineMarkdown(ordered[3])}`;
|
||||
}
|
||||
|
||||
return renderInlineMarkdown(sanitizedLine);
|
||||
}
|
||||
|
||||
export function renderInlineMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/`([^`]+)`/g, (_match, code) => chalk.cyan(code))
|
||||
.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
(_match, label, url) => `${chalk.underline(label)} ${chalk.dim(`(${url})`)}`
|
||||
)
|
||||
.replace(/(\*\*|__)(.+?)\1/g, (_match, _marker, content) => chalk.bold(content));
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import readline from 'node:readline';
|
||||
|
||||
export type ChatReplInput = {
|
||||
askAsync(prompt: string): Promise<string | null>;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
export function createChatReplInput(options?: {
|
||||
input?: NodeJS.ReadableStream;
|
||||
output?: NodeJS.WritableStream;
|
||||
terminal?: boolean;
|
||||
history?: string[];
|
||||
}): ChatReplInput {
|
||||
const inputStream = options?.input ?? process.stdin;
|
||||
const readlineInterface = readline.createInterface({
|
||||
input: inputStream,
|
||||
output: options?.output ?? process.stdout,
|
||||
terminal: options?.terminal ?? true,
|
||||
historySize: 100,
|
||||
history: options?.history,
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
readlineInterface.on('close', () => {
|
||||
closed = true;
|
||||
});
|
||||
readlineInterface.on('SIGINT', () => {
|
||||
readlineInterface.close();
|
||||
});
|
||||
|
||||
return {
|
||||
async askAsync(prompt: string): Promise<string | null> {
|
||||
if (closed) {
|
||||
return null;
|
||||
}
|
||||
inputStream.resume();
|
||||
return await new Promise<string | null>(resolve => {
|
||||
let settled = false;
|
||||
const onClose = (): void => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
readlineInterface.once('close', onClose);
|
||||
readlineInterface.question(prompt, answer => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
readlineInterface.removeListener('close', onClose);
|
||||
resolve(answer);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
close(): void {
|
||||
if (!closed) {
|
||||
readlineInterface.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { getMockOclifConfig } from '../../__tests__/commands/utils';
|
||||
import { ChatResult, streamChatResponseAsync } from '../../chat/chatClient';
|
||||
import { detectCurrentProjectAsync } from '../../chat/detectProject';
|
||||
import { ChatReplInput, createChatReplInput } from '../../chat/replInput';
|
||||
import * as flagsModule from '../../commandUtils/flags';
|
||||
import { AppQuery } from '../../graphql/queries/AppQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import Chat from '../chat';
|
||||
|
||||
jest.mock('../../chat/chatClient', () => ({
|
||||
...jest.requireActual('../../chat/chatClient'),
|
||||
streamChatResponseAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../chat/replInput');
|
||||
jest.mock('../../chat/detectProject');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../utils/json');
|
||||
jest.mock('../../graphql/queries/AppQuery', () => ({
|
||||
AppQuery: { byFullNameAsync: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockStreamChatResponseAsync = jest.mocked(streamChatResponseAsync);
|
||||
const mockCreateChatReplInput = jest.mocked(createChatReplInput);
|
||||
const mockDetectCurrentProjectAsync = jest.mocked(detectCurrentProjectAsync);
|
||||
const mockAppByFullNameAsync = jest.mocked(AppQuery.byFullNameAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeChatResult(text: string, toolCalls: ChatResult['toolCalls'] = []): ChatResult {
|
||||
return {
|
||||
text,
|
||||
toolCalls,
|
||||
assistantMessage: {
|
||||
id: 'assistant-message',
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const emptyResult = makeChatResult('ok');
|
||||
|
||||
function mockReplInput(lines: (string | null)[]): { askAsync: jest.Mock; close: jest.Mock } {
|
||||
const askAsync = jest.fn();
|
||||
for (const line of lines) {
|
||||
askAsync.mockResolvedValueOnce(line);
|
||||
}
|
||||
askAsync.mockResolvedValue(null);
|
||||
const input = { askAsync, close: jest.fn() };
|
||||
mockCreateChatReplInput.mockReturnValue(input as ChatReplInput);
|
||||
return input;
|
||||
}
|
||||
|
||||
function forceInteractive(): void {
|
||||
jest
|
||||
.spyOn(flagsModule, 'resolveNonInteractiveAndJsonFlags')
|
||||
.mockReturnValue({ json: false, nonInteractive: false });
|
||||
}
|
||||
|
||||
describe(Chat, () => {
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
mockStreamChatResponseAsync.mockResolvedValue(emptyResult);
|
||||
mockDetectCurrentProjectAsync.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
function createCommand(
|
||||
argv: string[],
|
||||
{
|
||||
sessionSecret = '{"id":"session-id","version":"1"}',
|
||||
primaryAccountName = 'my-account',
|
||||
}: { sessionSecret?: string | null; primaryAccountName?: string } = {}
|
||||
): Chat {
|
||||
const command = new Chat(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: {
|
||||
actor: {
|
||||
__typename: 'User',
|
||||
id: 'user-id',
|
||||
primaryAccount: { id: 'account-id', name: primaryAccountName },
|
||||
accounts: [{ id: 'account-id', name: primaryAccountName }],
|
||||
},
|
||||
graphqlClient: {},
|
||||
authenticationInfo: sessionSecret
|
||||
? { accessToken: null, sessionSecret }
|
||||
: { accessToken: 'token', sessionSecret: null },
|
||||
},
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('sends the message for the primary account and does not prompt in non-interactive mode', async () => {
|
||||
const command = createCommand(['how are my builds?', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
|
||||
const call = mockStreamChatResponseAsync.mock.calls[0][0];
|
||||
expect(call.accountName).toBe('my-account');
|
||||
expect(call.sessionSecret).toBe('{"id":"session-id","version":"1"}');
|
||||
expect(call.stream).toBe(true);
|
||||
expect(call.messages).toHaveLength(1);
|
||||
expect(call.messages[0].parts[0]).toEqual({ type: 'text', text: 'how are my builds?' });
|
||||
expect(mockCreateChatReplInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefers the --account flag over the primary account and skips project auto-detection', async () => {
|
||||
const command = createCommand(['hi', '--account', 'other-account', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync.mock.calls[0][0].accountName).toBe('other-account');
|
||||
expect(mockDetectCurrentProjectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('auto-scopes to the current directory project when no scope flags are given', async () => {
|
||||
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
|
||||
|
||||
const command = createCommand(['is my build ok?', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
const call = mockStreamChatResponseAsync.mock.calls[0][0];
|
||||
expect(call.accountName).toBe('acme');
|
||||
expect(call.messages[0].parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not auto-detect when --project is given', async () => {
|
||||
mockAppByFullNameAsync.mockResolvedValue({
|
||||
id: 'app1',
|
||||
fullName: '@acme/mobile',
|
||||
slug: 'mobile',
|
||||
ownerAccount: { id: 'acc', name: 'acme' },
|
||||
} as any);
|
||||
|
||||
const command = createCommand(['hi', '--project', 'acme/mobile', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockDetectCurrentProjectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves --project to its owner account and frames the message', async () => {
|
||||
mockAppByFullNameAsync.mockResolvedValue({
|
||||
id: 'app1',
|
||||
fullName: '@acme/mobile',
|
||||
slug: 'mobile',
|
||||
ownerAccount: { id: 'acc', name: 'acme' },
|
||||
} as any);
|
||||
|
||||
const command = createCommand([
|
||||
'is my build ok?',
|
||||
'--project',
|
||||
'acme/mobile',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockAppByFullNameAsync).toHaveBeenCalledWith(expect.anything(), '@acme/mobile');
|
||||
const call = mockStreamChatResponseAsync.mock.calls[0][0];
|
||||
expect(call.accountName).toBe('acme');
|
||||
expect(call.messages[0].parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws a friendly error when --project cannot be found', async () => {
|
||||
mockAppByFullNameAsync.mockRejectedValue(new Error('not found'));
|
||||
const command = createCommand(['hi', '--project', 'acme/ghost', '--non-interactive']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/@acme\/ghost was not found/);
|
||||
});
|
||||
|
||||
it('continues the conversation with follow-up replies until the user types /exit', async () => {
|
||||
forceInteractive();
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
const input = mockReplInput(['and my updates?', '/exit']);
|
||||
|
||||
const command = createCommand(['how are my builds?']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
|
||||
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
|
||||
expect(secondCall.messages).toHaveLength(3);
|
||||
expect(secondCall.messages[1].role).toBe('assistant');
|
||||
expect(secondCall.messages[2].role).toBe('user');
|
||||
expect(secondCall.messages[2].parts[0]).toEqual({ type: 'text', text: 'and my updates?' });
|
||||
expect(input.close).toHaveBeenCalled();
|
||||
expect(mockCreateChatReplInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ history: ['how are my builds?'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('retains tool results in the conversation history for follow-up questions', async () => {
|
||||
forceInteractive();
|
||||
const result = makeChatResult('The latest build passed.', [
|
||||
{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: ['b1'] } },
|
||||
]);
|
||||
result.assistantMessage.parts = [
|
||||
{ type: 'step-start' },
|
||||
{
|
||||
type: 'tool-get_latest_builds',
|
||||
toolCallId: 't1',
|
||||
state: 'output-available',
|
||||
input: { limit: 1 },
|
||||
output: { builds: ['b1'] },
|
||||
},
|
||||
{ type: 'step-start' },
|
||||
{ type: 'text', text: result.text },
|
||||
];
|
||||
mockStreamChatResponseAsync.mockResolvedValue(result);
|
||||
mockReplInput(['what SDK did it use?', '/exit']);
|
||||
|
||||
const command = createCommand(['how are my builds?']);
|
||||
await command.runAsync();
|
||||
|
||||
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
|
||||
expect(secondCall.messages[1]).toEqual(result.assistantMessage);
|
||||
});
|
||||
|
||||
it('exits when the input stream closes (Ctrl-D)', async () => {
|
||||
forceInteractive();
|
||||
mockReplInput([null]);
|
||||
|
||||
const command = createCommand(['how are my builds?']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the interactive session alive when a request fails and re-prompts', async () => {
|
||||
forceInteractive();
|
||||
mockStreamChatResponseAsync
|
||||
.mockRejectedValueOnce(new Error('You have reached your chat usage limit.'))
|
||||
.mockResolvedValue(makeChatResult('answer'));
|
||||
const input = mockReplInput(['try again', '/exit']);
|
||||
|
||||
const command = createCommand(['how are my builds?']);
|
||||
await expect(command.runAsync()).resolves.toBeUndefined();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
|
||||
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
|
||||
expect(secondCall.messages).toHaveLength(1);
|
||||
expect(secondCall.messages[0].parts[0]).toEqual({ type: 'text', text: 'try again' });
|
||||
expect(input.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates a request failure in non-interactive mode without retrying', async () => {
|
||||
mockStreamChatResponseAsync.mockRejectedValue(new Error('boom'));
|
||||
|
||||
const command = createCommand(['hi', '--non-interactive']);
|
||||
await expect(command.runAsync()).rejects.toThrow('boom');
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('starts a new conversation with /clear', async () => {
|
||||
forceInteractive();
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
mockReplInput(['/clear', 'fresh question', '/exit']);
|
||||
|
||||
const command = createCommand(['first question']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
|
||||
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
|
||||
expect(secondCall.messages).toHaveLength(1);
|
||||
expect(secondCall.messages[0].parts[0]).toEqual({ type: 'text', text: 'fresh question' });
|
||||
});
|
||||
|
||||
it('restores project scope after /clear', async () => {
|
||||
forceInteractive();
|
||||
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
mockReplInput(['/clear', 'fresh question', '/exit']);
|
||||
|
||||
const command = createCommand(['first question']);
|
||||
await command.runAsync();
|
||||
|
||||
const secondCall = mockStreamChatResponseAsync.mock.calls[1][0];
|
||||
expect(secondCall.messages).toHaveLength(1);
|
||||
expect(secondCall.messages[0].parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Regarding the EAS project @acme/mobile: fresh question',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores unknown slash commands and keeps prompting', async () => {
|
||||
forceInteractive();
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
mockReplInput(['/bogus', 'a real question', '/exit']);
|
||||
|
||||
const command = createCommand(['first question']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mockStreamChatResponseAsync.mock.calls[1][0].messages[2].parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'a real question',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits structured JSON and does not stream or prompt when --json is passed', async () => {
|
||||
mockStreamChatResponseAsync.mockResolvedValue(
|
||||
makeChatResult('Your latest build passed.', [
|
||||
{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: [] } },
|
||||
])
|
||||
);
|
||||
|
||||
const command = createCommand(['how are my builds?', '--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockStreamChatResponseAsync.mock.calls[0][0].stream).toBe(false);
|
||||
expect(mockCreateChatReplInput).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
message: 'how are my builds?',
|
||||
account: 'my-account',
|
||||
project: null,
|
||||
response: 'Your latest build passed.',
|
||||
toolCalls: [{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: [] } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when authenticated with an access token instead of a session secret', async () => {
|
||||
const command = createCommand(['hi', '--non-interactive'], { sessionSecret: null });
|
||||
await expect(command.runAsync()).rejects.toThrow(/requires an interactive login/);
|
||||
expect(mockStreamChatResponseAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires a message argument', async () => {
|
||||
const command = createCommand(['--non-interactive']);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('opens an interactive prompt when no message is given', async () => {
|
||||
forceInteractive();
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
const input = mockReplInput(['how are my builds?', '/exit']);
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateChatReplInput).toHaveBeenCalledWith(expect.objectContaining({ history: [] }));
|
||||
expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1);
|
||||
const call = mockStreamChatResponseAsync.mock.calls[0][0];
|
||||
expect(call.messages).toHaveLength(1);
|
||||
expect(call.messages[0].parts[0]).toEqual({ type: 'text', text: 'how are my builds?' });
|
||||
expect(input.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('frames the first typed message with the auto-detected project when no message is given', async () => {
|
||||
forceInteractive();
|
||||
mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' });
|
||||
mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer'));
|
||||
mockReplInput(['is my build ok?', '/exit']);
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
const call = mockStreamChatResponseAsync.mock.calls[0][0];
|
||||
expect(call.accountName).toBe('acme');
|
||||
expect(call.messages[0].parts[0]).toEqual({
|
||||
type: 'text',
|
||||
text: 'Regarding the EAS project @acme/mobile: is my build ok?',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
ChatMessage,
|
||||
ChatResult,
|
||||
makeUserMessage,
|
||||
streamChatResponseAsync,
|
||||
} from '../chat/chatClient';
|
||||
import { detectCurrentProjectAsync } from '../chat/detectProject';
|
||||
import { ChatReplInput, createChatReplInput } from '../chat/replInput';
|
||||
import EasCommand from '../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../commandUtils/flags';
|
||||
import { AppQuery } from '../graphql/queries/AppQuery';
|
||||
import Log from '../log';
|
||||
import { Actor } from '../user/User';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json';
|
||||
|
||||
const EXIT_WORDS = new Set(['exit', 'quit', 'q']);
|
||||
const USER_LABEL = `${chalk.bold.cyan('Chat')}${chalk.dim(' > ')}`;
|
||||
const CHAT_HELP = [
|
||||
'Commands:',
|
||||
' /help Show this help',
|
||||
' /clear Start a new conversation',
|
||||
' /exit Quit',
|
||||
].join('\n');
|
||||
|
||||
export default class Chat extends EasCommand {
|
||||
static override description = 'ask an AI assistant about your Expo account and EAS projects';
|
||||
|
||||
static override hidden = true;
|
||||
|
||||
static override args = {
|
||||
message: Args.string({
|
||||
required: false,
|
||||
description: 'Message to send to the assistant (omit to open an interactive prompt)',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
account: Flags.string({
|
||||
char: 'a',
|
||||
description: 'Account to scope the conversation to (defaults to your primary account)',
|
||||
}),
|
||||
project: Flags.string({
|
||||
char: 'p',
|
||||
description:
|
||||
'Project to focus the conversation on, as @account-name/project-slug or a project slug',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(Chat);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (!args.message && (json || nonInteractive)) {
|
||||
throw new Error('Provide a message, e.g. `eas chat "how are my builds?"`.');
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { actor, authenticationInfo, graphqlClient },
|
||||
} = await this.getContextAsync(Chat, { nonInteractive });
|
||||
|
||||
if (!authenticationInfo.sessionSecret) {
|
||||
throw new Error(
|
||||
'`eas chat` requires an interactive login. Run `eas login` first. Chat does not support EXPO_TOKEN access tokens yet.'
|
||||
);
|
||||
}
|
||||
const { sessionSecret } = authenticationInfo;
|
||||
|
||||
let accountName = flags.account ?? getDefaultAccountName(actor);
|
||||
let projectLabel: string | undefined;
|
||||
let projectDetectedFromDirectory = false;
|
||||
if (flags.project) {
|
||||
const resolved = await resolveProjectAsync(graphqlClient, flags.project, accountName);
|
||||
accountName = resolved.accountName;
|
||||
projectLabel = resolved.label;
|
||||
} else if (!flags.account) {
|
||||
const detected = await detectCurrentProjectAsync(graphqlClient);
|
||||
if (detected) {
|
||||
accountName = detected.accountName;
|
||||
projectLabel = detected.label;
|
||||
projectDetectedFromDirectory = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (json) {
|
||||
const messages: ChatMessage[] = [
|
||||
makeUserMessage(scopeMessageToProject(args.message as string, projectLabel)),
|
||||
];
|
||||
const result = await streamChatResponseAsync({
|
||||
messages: [...messages],
|
||||
accountName,
|
||||
sessionSecret,
|
||||
stream: false,
|
||||
});
|
||||
printJsonOnlyOutput({
|
||||
message: args.message,
|
||||
account: accountName ?? null,
|
||||
project: projectLabel ?? null,
|
||||
response: result.text,
|
||||
toolCalls: result.toolCalls,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (projectLabel) {
|
||||
Log.log(
|
||||
chalk.dim(
|
||||
projectDetectedFromDirectory
|
||||
? `Project: ${projectLabel} (from current directory; use --account to widen)`
|
||||
: `Project: ${projectLabel}`
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!nonInteractive) {
|
||||
Log.log(chalk.dim('Use /help for commands, or /exit to quit.'));
|
||||
}
|
||||
|
||||
const input = nonInteractive
|
||||
? undefined
|
||||
: createChatReplInput({ history: args.message ? [args.message] : [] });
|
||||
|
||||
const messages: ChatMessage[] = [];
|
||||
if (args.message) {
|
||||
Log.log(`${USER_LABEL}${args.message}`);
|
||||
Log.newLine();
|
||||
messages.push(makeUserMessage(scopeMessageToProject(args.message, projectLabel)));
|
||||
}
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (!lastMessage || lastMessage.role === 'assistant') {
|
||||
if (!input) {
|
||||
break;
|
||||
}
|
||||
const scopeNextMessage = messages.length === 0;
|
||||
const nextMessage = await readNextUserMessageAsync(
|
||||
input,
|
||||
messages,
|
||||
projectLabel,
|
||||
scopeNextMessage
|
||||
);
|
||||
if (nextMessage === null) {
|
||||
Log.log(chalk.dim('Ending chat.'));
|
||||
break;
|
||||
}
|
||||
messages.push(makeUserMessage(nextMessage));
|
||||
}
|
||||
|
||||
let result: ChatResult;
|
||||
try {
|
||||
result = await streamChatResponseAsync({
|
||||
messages: [...messages],
|
||||
accountName,
|
||||
sessionSecret,
|
||||
stream: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!input) {
|
||||
throw error;
|
||||
}
|
||||
Log.error(error instanceof Error ? error.message : String(error));
|
||||
messages.pop();
|
||||
continue;
|
||||
}
|
||||
messages.push(result.assistantMessage);
|
||||
}
|
||||
} finally {
|
||||
input?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readNextUserMessageAsync(
|
||||
input: ChatReplInput,
|
||||
messages: ChatMessage[],
|
||||
projectLabel: string | undefined,
|
||||
scopeFirstMessage = false
|
||||
): Promise<string | null> {
|
||||
let shouldRestoreProjectScope = scopeFirstMessage;
|
||||
for (;;) {
|
||||
Log.newLine();
|
||||
const line = await input.askAsync(USER_LABEL);
|
||||
if (line === null) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (EXIT_WORDS.has(trimmed.toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('/')) {
|
||||
const command = trimmed.slice(1).split(/\s+/)[0].toLowerCase();
|
||||
if (['exit', 'quit', 'q'].includes(command)) {
|
||||
return null;
|
||||
}
|
||||
if (['clear', 'new', 'reset'].includes(command)) {
|
||||
messages.length = 0;
|
||||
shouldRestoreProjectScope = true;
|
||||
Log.log(chalk.dim('Started a new conversation.'));
|
||||
continue;
|
||||
}
|
||||
if (['help', 'h', '?'].includes(command)) {
|
||||
Log.log(chalk.dim(CHAT_HELP));
|
||||
continue;
|
||||
}
|
||||
Log.warn(`Unknown command "/${command}". Type /help for commands.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
return shouldRestoreProjectScope ? scopeMessageToProject(trimmed, projectLabel) : trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function scopeMessageToProject(message: string, projectLabel: string | undefined): string {
|
||||
return projectLabel ? `Regarding the EAS project ${projectLabel}: ${message}` : message;
|
||||
}
|
||||
|
||||
function getDefaultAccountName(actor: Actor): string | undefined {
|
||||
if ('primaryAccount' in actor && actor.primaryAccount) {
|
||||
return actor.primaryAccount.name;
|
||||
}
|
||||
return actor.accounts[0]?.name ?? undefined;
|
||||
}
|
||||
|
||||
async function resolveProjectAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectReference: string,
|
||||
fallbackAccountName: string | undefined
|
||||
): Promise<{ accountName: string; label: string }> {
|
||||
const parsed = parseProjectReference(projectReference);
|
||||
const accountForLookup = parsed.account ?? fallbackAccountName;
|
||||
if (!accountForLookup) {
|
||||
throw new Error(
|
||||
`Could not determine which account owns project "${projectReference}". Pass it as @account-name/project-slug.`
|
||||
);
|
||||
}
|
||||
|
||||
const fullName = `@${accountForLookup}/${parsed.slug}`;
|
||||
let app;
|
||||
try {
|
||||
app = await AppQuery.byFullNameAsync(graphqlClient, fullName);
|
||||
} catch {
|
||||
throw new Error(`Project ${fullName} was not found or you do not have access to it.`);
|
||||
}
|
||||
return { accountName: app.ownerAccount.name, label: app.fullName };
|
||||
}
|
||||
|
||||
function parseProjectReference(reference: string): { account?: string; slug: string } {
|
||||
const cleaned = reference.startsWith('@') ? reference.slice(1) : reference;
|
||||
const parts = cleaned.split('/');
|
||||
if (parts.length === 2 && parts[0] && parts[1]) {
|
||||
return { account: parts[0], slug: parts[1] };
|
||||
}
|
||||
return { slug: cleaned };
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { getMockAppFragment, getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../../graphql/generated';
|
||||
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import {
|
||||
parseVisibility,
|
||||
promptVariableEnvironmentAsync,
|
||||
promptVariableNameAsync,
|
||||
promptVariableTypeAsync,
|
||||
promptVariableValueAsync,
|
||||
promptVariableVisibilityAsync,
|
||||
} from '../../../utils/prompts';
|
||||
import EnvSet from '../set';
|
||||
|
||||
jest.mock('../../../graphql/mutations/EnvironmentVariableMutation');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../utils/prompts');
|
||||
jest.mock('../../../utils/json');
|
||||
jest.mock('fs-extra');
|
||||
|
||||
describe(EnvSet, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const variableId = 'testId';
|
||||
const testAccountId = 'test-account-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest
|
||||
.mocked(parseVisibility)
|
||||
.mockImplementation(jest.requireActual('../../../utils/prompts').parseVisibility);
|
||||
|
||||
jest.mocked(AppQuery.byIdAsync).mockImplementation(async () => getMockAppFragment());
|
||||
jest
|
||||
.mocked(EnvironmentVariableMutation.createForAppAsync)
|
||||
.mockImplementation(async (_client, input, _appId) => ({
|
||||
...input,
|
||||
id: variableId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
type: EnvironmentSecretType.String,
|
||||
}));
|
||||
jest
|
||||
.mocked(EnvironmentVariableMutation.createSharedVariableAsync)
|
||||
.mockImplementation(async (_client, input, _appId) => ({
|
||||
...input,
|
||||
id: variableId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
type: EnvironmentSecretType.String,
|
||||
}));
|
||||
jest
|
||||
.mocked(EnvironmentVariableMutation.updateAsync)
|
||||
.mockImplementation(async (_client, input) => ({
|
||||
...input,
|
||||
id: variableId,
|
||||
name: 'VarName',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
type: EnvironmentSecretType.String,
|
||||
}));
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockImplementation(async () => []);
|
||||
jest.mocked(EnvironmentVariablesQuery.sharedAsync).mockImplementation(async () => []);
|
||||
});
|
||||
|
||||
it('creates a project variable when none exists', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'secret',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Secret,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
testProjectId
|
||||
);
|
||||
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts --variable-name and --variable-value aliases', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--variable-name',
|
||||
'VarName',
|
||||
'--variable-value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'secret',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Secret,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
testProjectId
|
||||
);
|
||||
});
|
||||
|
||||
it('updates an existing project variable in the same environment without --force', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'secret',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
const otherVariableId = 'otherId';
|
||||
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
},
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariablesQuery.byAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
filterNames: ['VarName'],
|
||||
});
|
||||
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Secret,
|
||||
type: undefined,
|
||||
fileName: undefined,
|
||||
});
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves visibility when updating non-interactively without --visibility', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--non-interactive',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
const otherVariableId = 'otherId';
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
type: EnvironmentSecretType.String,
|
||||
visibility: EnvironmentVariableVisibility.Sensitive,
|
||||
},
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(promptVariableVisibilityAsync).not.toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Sensitive,
|
||||
type: EnvironmentSecretType.String,
|
||||
fileName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves other environments when updating an existing project variable', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'secret',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
const otherVariableId = 'otherId';
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
environments: [DefaultEnvironment.Production, DefaultEnvironment.Preview],
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
},
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production, DefaultEnvironment.Preview],
|
||||
visibility: EnvironmentVariableVisibility.Secret,
|
||||
type: undefined,
|
||||
fileName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates an account-wide variable when none exists', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--scope',
|
||||
'account',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.createSharedVariableAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
testAccountId
|
||||
);
|
||||
});
|
||||
|
||||
it('updates an existing account-wide variable without --force', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--scope',
|
||||
'account',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
const otherVariableId = 'otherId';
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.sharedAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
},
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: undefined,
|
||||
});
|
||||
expect(EnvironmentVariableMutation.createSharedVariableAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves other environments when updating an existing account-wide variable', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--scope',
|
||||
'account',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
const otherVariableId = 'otherId';
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.sharedAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
environments: [DefaultEnvironment.Production, DefaultEnvironment.Preview],
|
||||
scope: EnvironmentVariableScope.Shared,
|
||||
},
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production, DefaultEnvironment.Preview],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts the environment as a positional argument', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'development',
|
||||
'--name',
|
||||
'TEST_VAR',
|
||||
'--value',
|
||||
'test-value',
|
||||
'--visibility',
|
||||
'plaintext',
|
||||
'--scope',
|
||||
'project',
|
||||
'--non-interactive',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
name: 'TEST_VAR',
|
||||
value: 'test-value',
|
||||
environments: [DefaultEnvironment.Development],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
testProjectId
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts for missing arguments', async () => {
|
||||
const command = new EnvSet([], mockConfig);
|
||||
|
||||
jest.mocked(promptVariableNameAsync).mockImplementation(async () => 'VarName');
|
||||
jest.mocked(promptVariableValueAsync).mockImplementation(async () => 'VarValue');
|
||||
jest
|
||||
.mocked(promptVariableTypeAsync)
|
||||
.mockImplementation(async () => EnvironmentSecretType.String);
|
||||
|
||||
jest
|
||||
.mocked(promptVariableEnvironmentAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [DefaultEnvironment.Production]);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(promptVariableNameAsync).toHaveBeenCalled();
|
||||
expect(promptVariableValueAsync).toHaveBeenCalled();
|
||||
expect(promptVariableEnvironmentAsync).toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
testProjectId
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the file type when updating a file variable with --value but no --type', async () => {
|
||||
const testFilePath = '/path/to/creds.json';
|
||||
const testFileBase64 = 'dGVzdCBmaWxlIGNvbnRlbnQ=';
|
||||
const testFileName = 'creds.json';
|
||||
const otherVariableId = 'otherId';
|
||||
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
},
|
||||
]);
|
||||
|
||||
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(true));
|
||||
jest.mocked(fs.readFile).mockImplementation(() => Promise.resolve(testFileBase64));
|
||||
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
testFilePath,
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'secret',
|
||||
'--non-interactive',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(fs.pathExists).toHaveBeenCalledWith(testFilePath);
|
||||
expect(fs.readFile).toHaveBeenCalledWith(testFilePath, 'base64');
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
value: testFileBase64,
|
||||
visibility: EnvironmentVariableVisibility.Secret,
|
||||
environments: [DefaultEnvironment.Production],
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
fileName: testFileName,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when updating a file variable with a value that is not a valid file path and no --type', async () => {
|
||||
const plainStringValue = 'some-new-value';
|
||||
const otherVariableId = 'otherId';
|
||||
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
|
||||
// @ts-expect-error
|
||||
.mockImplementation(async () => [
|
||||
{
|
||||
id: otherVariableId,
|
||||
name: 'VarName',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
type: EnvironmentSecretType.FileBase64,
|
||||
},
|
||||
]);
|
||||
|
||||
jest.mocked(fs.pathExists).mockImplementation(() => Promise.resolve(false));
|
||||
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
plainStringValue,
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'secret',
|
||||
'--non-interactive',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
`Variable "VarName" is a file type, but "${plainStringValue}" does not exist as a file. If you want to convert it to a string, pass --type string.`
|
||||
);
|
||||
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('outputs the created variable as JSON with --json and does not print a tick', async () => {
|
||||
const command = new EnvSet(
|
||||
[
|
||||
'--name',
|
||||
'VarName',
|
||||
'--value',
|
||||
'VarValue',
|
||||
'--environment',
|
||||
'production',
|
||||
'--visibility',
|
||||
'plaintext',
|
||||
'--json',
|
||||
],
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectId: testProjectId,
|
||||
});
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(enableJsonOutput).toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
environments: [DefaultEnvironment.Production],
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
testProjectId
|
||||
);
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: variableId,
|
||||
name: 'VarName',
|
||||
value: 'VarValue',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+6
-1
@@ -57,7 +57,9 @@ interface CreateFlags {
|
||||
}
|
||||
|
||||
export default class EnvCreate extends EasCommand {
|
||||
static override description = 'create an environment variable for the current project or account';
|
||||
static override description =
|
||||
'create an environment variable for the current project or account (deprecated, use eas env:set)';
|
||||
static override hidden = true;
|
||||
|
||||
static override args = {
|
||||
environment: Args.string({
|
||||
@@ -95,6 +97,9 @@ export default class EnvCreate extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
Log.warn('This command is deprecated. Use eas env:set instead.');
|
||||
Log.newLine();
|
||||
|
||||
const { args, flags } = await this.parse(EnvCreate);
|
||||
|
||||
const validatedFlags = this.sanitizeFlags(flags);
|
||||
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EASEnvironmentVariableScopeFlag,
|
||||
EASEnvironmentVariableScopeFlagValue,
|
||||
EASMultiEnvironmentFlag,
|
||||
EASVariableVisibilityFlag,
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableFragment,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
} from '../../graphql/generated';
|
||||
import { EnvironmentVariableMutation } from '../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { EnvironmentVariablesQuery } from '../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
getDisplayNameForProjectIdAsync,
|
||||
getOwnerAccountForProjectIdAsync,
|
||||
} from '../../project/projectUtils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import {
|
||||
parseVisibility,
|
||||
promptVariableEnvironmentAsync,
|
||||
promptVariableNameAsync,
|
||||
promptVariableTypeAsync,
|
||||
promptVariableValueAsync,
|
||||
promptVariableVisibilityAsync,
|
||||
} from '../../utils/prompts';
|
||||
|
||||
interface RawSetFlags {
|
||||
name?: string;
|
||||
value?: string;
|
||||
type?: 'string' | 'file';
|
||||
visibility?: 'plaintext' | 'sensitive' | 'secret';
|
||||
scope: EASEnvironmentVariableScopeFlagValue;
|
||||
environment?: string[];
|
||||
'non-interactive': boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface SetFlags {
|
||||
name?: string;
|
||||
value?: string;
|
||||
type?: 'string' | 'file';
|
||||
visibility?: 'plaintext' | 'sensitive' | 'secret';
|
||||
scope: EnvironmentVariableScope;
|
||||
environment?: string[];
|
||||
'non-interactive': boolean;
|
||||
json: boolean;
|
||||
}
|
||||
|
||||
interface ResolvedVariableDetails {
|
||||
value: string;
|
||||
visibility: EnvironmentVariableVisibility;
|
||||
type: EnvironmentSecretType | undefined;
|
||||
fileName: string | undefined;
|
||||
}
|
||||
|
||||
export default class EnvSet extends EasCommand {
|
||||
static override description =
|
||||
'set (create or update) an environment variable on the current project or account';
|
||||
|
||||
static override args = {
|
||||
environment: Args.string({
|
||||
description:
|
||||
"Environment to set the variable in. Default environments are 'production', 'preview', and 'development'.",
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
name: Flags.string({
|
||||
description: 'Name of the variable',
|
||||
aliases: ['variable-name'],
|
||||
}),
|
||||
value: Flags.string({
|
||||
description: 'Text value of the variable',
|
||||
aliases: ['variable-value'],
|
||||
}),
|
||||
type: Flags.option({
|
||||
description: 'The type of variable',
|
||||
options: ['string', 'file'] as const,
|
||||
})(),
|
||||
...EASVariableVisibilityFlag,
|
||||
...EASEnvironmentVariableScopeFlag,
|
||||
...EASMultiEnvironmentFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.Analytics,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(EnvSet);
|
||||
|
||||
const {
|
||||
name: nameFlag,
|
||||
value,
|
||||
type,
|
||||
visibility,
|
||||
scope,
|
||||
environment,
|
||||
} = this.sanitizeFlags(flags);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(EnvSet, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const name = nameFlag ?? (await promptVariableNameAsync(nonInteractive));
|
||||
|
||||
const environments = await this.resolveEnvironmentsAsync(environment, args.environment, {
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
|
||||
const existingVariable = await this.findExistingVariableAsync(graphqlClient, {
|
||||
scope,
|
||||
projectId,
|
||||
name,
|
||||
environments,
|
||||
});
|
||||
const environmentsToSet = existingVariable
|
||||
? [...new Set([...(existingVariable.environments ?? []), ...environments])]
|
||||
: environments;
|
||||
|
||||
const {
|
||||
value: resolvedValue,
|
||||
visibility: resolvedVisibility,
|
||||
type: resolvedType,
|
||||
fileName,
|
||||
} = await this.resolveVariableDetailsAsync(
|
||||
{ name, value, type, visibility, nonInteractive },
|
||||
existingVariable
|
||||
);
|
||||
|
||||
const [projectDisplayName, ownerAccount] = await Promise.all([
|
||||
getDisplayNameForProjectIdAsync(graphqlClient, projectId),
|
||||
getOwnerAccountForProjectIdAsync(graphqlClient, projectId),
|
||||
]);
|
||||
|
||||
if (scope === EnvironmentVariableScope.Project) {
|
||||
const variable = existingVariable
|
||||
? await EnvironmentVariableMutation.updateAsync(graphqlClient, {
|
||||
id: existingVariable.id,
|
||||
name,
|
||||
value: resolvedValue,
|
||||
visibility: resolvedVisibility,
|
||||
environments: environmentsToSet,
|
||||
type: resolvedType,
|
||||
fileName,
|
||||
})
|
||||
: await EnvironmentVariableMutation.createForAppAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
name,
|
||||
value: resolvedValue,
|
||||
environments,
|
||||
visibility: resolvedVisibility,
|
||||
type: resolvedType ?? EnvironmentSecretType.String,
|
||||
fileName,
|
||||
},
|
||||
projectId
|
||||
);
|
||||
|
||||
if (!variable) {
|
||||
throw new Error(
|
||||
`Could not set variable with name ${name} on project ${projectDisplayName}`
|
||||
);
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(variable);
|
||||
} else {
|
||||
Log.withTick(
|
||||
`${existingVariable ? 'Updated' : 'Created'} variable ${chalk.bold(name)} on project ${chalk.bold(
|
||||
projectDisplayName
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
} else if (scope === EnvironmentVariableScope.Shared) {
|
||||
const variable = existingVariable
|
||||
? await EnvironmentVariableMutation.updateAsync(graphqlClient, {
|
||||
id: existingVariable.id,
|
||||
name,
|
||||
value: resolvedValue,
|
||||
visibility: resolvedVisibility,
|
||||
environments: environmentsToSet,
|
||||
type: resolvedType,
|
||||
})
|
||||
: await EnvironmentVariableMutation.createSharedVariableAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
name,
|
||||
value: resolvedValue,
|
||||
visibility: resolvedVisibility,
|
||||
environments,
|
||||
type: resolvedType ?? EnvironmentSecretType.String,
|
||||
},
|
||||
ownerAccount.id
|
||||
);
|
||||
|
||||
if (!variable) {
|
||||
throw new Error(`Could not set variable with name ${name} on account ${ownerAccount.name}`);
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(variable);
|
||||
} else {
|
||||
Log.withTick(
|
||||
`${existingVariable ? 'Updated' : 'Created'} variable ${chalk.bold(name)} on account ${chalk.bold(
|
||||
ownerAccount.name
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveEnvironmentsAsync(
|
||||
environmentFlag: string[] | undefined,
|
||||
environmentArg: string | undefined,
|
||||
{
|
||||
nonInteractive,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
}: { nonInteractive: boolean; graphqlClient: ExpoGraphqlClient; projectId: string }
|
||||
): Promise<string[]> {
|
||||
const environments = environmentFlag ?? (environmentArg ? [environmentArg] : undefined);
|
||||
if (environments && environments.length > 0) {
|
||||
return environments;
|
||||
}
|
||||
|
||||
const promptedEnvironments = await promptVariableEnvironmentAsync({
|
||||
nonInteractive,
|
||||
multiple: true,
|
||||
canEnterCustomEnvironment: true,
|
||||
graphqlClient,
|
||||
projectId,
|
||||
});
|
||||
|
||||
if (!promptedEnvironments || promptedEnvironments.length === 0) {
|
||||
throw new Error('No environments selected');
|
||||
}
|
||||
|
||||
return promptedEnvironments;
|
||||
}
|
||||
|
||||
private async findExistingVariableAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
scope,
|
||||
projectId,
|
||||
name,
|
||||
environments,
|
||||
}: {
|
||||
scope: EnvironmentVariableScope;
|
||||
projectId: string;
|
||||
name: string;
|
||||
environments: string[];
|
||||
}
|
||||
): Promise<EnvironmentVariableFragment | undefined> {
|
||||
if (scope === EnvironmentVariableScope.Project) {
|
||||
const existingVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
filterNames: [name],
|
||||
});
|
||||
|
||||
return existingVariables.find(
|
||||
variable =>
|
||||
variable.scope === EnvironmentVariableScope.Project &&
|
||||
variable.environments?.some(env => environments.includes(env))
|
||||
);
|
||||
}
|
||||
|
||||
const existingVariables = await EnvironmentVariablesQuery.sharedAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
filterNames: [name],
|
||||
});
|
||||
|
||||
return existingVariables.find(variable =>
|
||||
variable.environments?.some(env => environments.includes(env))
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveVariableDetailsAsync(
|
||||
{
|
||||
name,
|
||||
value,
|
||||
type,
|
||||
visibility,
|
||||
nonInteractive,
|
||||
}: {
|
||||
name: string;
|
||||
value?: string;
|
||||
type?: 'string' | 'file';
|
||||
visibility?: 'plaintext' | 'sensitive' | 'secret';
|
||||
nonInteractive: boolean;
|
||||
},
|
||||
existingVariable: EnvironmentVariableFragment | undefined
|
||||
): Promise<ResolvedVariableDetails> {
|
||||
let newType: EnvironmentSecretType | undefined;
|
||||
if (type === 'file') {
|
||||
newType = EnvironmentSecretType.FileBase64;
|
||||
} else if (type === 'string') {
|
||||
newType = EnvironmentSecretType.String;
|
||||
}
|
||||
|
||||
let newVisibility = visibility ? parseVisibility(visibility) : undefined;
|
||||
|
||||
if (!type && !value && !nonInteractive) {
|
||||
newType = await promptVariableTypeAsync(nonInteractive, existingVariable?.type);
|
||||
}
|
||||
|
||||
if (!newVisibility && !nonInteractive) {
|
||||
newVisibility = await promptVariableVisibilityAsync(
|
||||
nonInteractive,
|
||||
existingVariable?.visibility
|
||||
);
|
||||
}
|
||||
|
||||
if (value && !newType) {
|
||||
newType = existingVariable?.type;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
value = await promptVariableValueAsync({
|
||||
nonInteractive,
|
||||
hidden: newVisibility !== EnvironmentVariableVisibility.Public,
|
||||
filePath: (newType ?? existingVariable?.type) === EnvironmentSecretType.FileBase64,
|
||||
});
|
||||
}
|
||||
|
||||
newVisibility =
|
||||
newVisibility ?? existingVariable?.visibility ?? EnvironmentVariableVisibility.Public;
|
||||
|
||||
let fileName: string | undefined;
|
||||
if (newType === EnvironmentSecretType.FileBase64) {
|
||||
const environmentFilePath = path.resolve(value);
|
||||
if (!(await fs.pathExists(environmentFilePath))) {
|
||||
if (type === 'file') {
|
||||
throw new Error(`File "${value}" does not exist`);
|
||||
}
|
||||
throw new Error(
|
||||
`Variable "${name}" is a file type, but "${value}" does not exist as a file. If you want to convert it to a string, pass --type string.`
|
||||
);
|
||||
}
|
||||
fileName = path.basename(environmentFilePath);
|
||||
value = await fs.readFile(environmentFilePath, 'base64');
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
visibility: newVisibility,
|
||||
type: newType,
|
||||
fileName,
|
||||
};
|
||||
}
|
||||
|
||||
private sanitizeFlags(flags: RawSetFlags): SetFlags {
|
||||
return {
|
||||
...flags,
|
||||
scope:
|
||||
flags.scope === 'account'
|
||||
? EnvironmentVariableScope.Shared
|
||||
: EnvironmentVariableScope.Project,
|
||||
};
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -63,7 +63,9 @@ interface UpdateFlags {
|
||||
}
|
||||
|
||||
export default class EnvUpdate extends EasCommand {
|
||||
static override description = 'update an environment variable on the current project or account';
|
||||
static override description =
|
||||
'update an environment variable on the current project or account (deprecated, use eas env:set)';
|
||||
static override hidden = true;
|
||||
|
||||
static override flags = {
|
||||
'variable-name': Flags.string({
|
||||
@@ -104,6 +106,9 @@ export default class EnvUpdate extends EasCommand {
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
Log.warn('This command is deprecated. Use eas env:set instead.');
|
||||
Log.newLine();
|
||||
|
||||
const { args, flags } = await this.parse(EnvUpdate);
|
||||
const {
|
||||
name,
|
||||
|
||||
@@ -264,6 +264,14 @@ describe(ObserveMetrics, () => {
|
||||
'metric argument is required in non-interactive mode'
|
||||
);
|
||||
});
|
||||
|
||||
it('treats --json as non-interactive when no metric is provided', async () => {
|
||||
const command = createCommand(['--json']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'metric argument is required in non-interactive mode'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveOrderBy, () => {
|
||||
|
||||
@@ -1,27 +1,99 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { fetchObserveSessionEventsAsync } from '../../../observe/fetchSessions';
|
||||
import { ObserveQuery } from '../../../graphql/queries/ObserveQuery';
|
||||
import {
|
||||
fetchObserveSessionEventsAsync,
|
||||
fetchSessionLogCandidatesAsync,
|
||||
fetchSessionMetricCandidatesAsync,
|
||||
} from '../../../observe/fetchSessions';
|
||||
import {
|
||||
buildObserveSessionEventsJson,
|
||||
buildObserveSessionEventsTable,
|
||||
} from '../../../observe/formatSessions';
|
||||
import { selectAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveSession from '../session';
|
||||
|
||||
jest.mock('../../../observe/fetchSessions');
|
||||
jest.mock('../../../observe/formatSessions', () => ({
|
||||
buildObserveSessionEventsTable: jest.fn().mockReturnValue('events-table'),
|
||||
buildObserveSessionEventsJson: jest.fn().mockReturnValue({}),
|
||||
jest.mock('../../../observe/formatSessions', () => {
|
||||
const actual = jest.requireActual('../../../observe/formatSessions');
|
||||
return {
|
||||
...actual,
|
||||
buildObserveSessionEventsTable: jest.fn().mockReturnValue('events-table'),
|
||||
buildObserveSessionEventsJson: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../graphql/queries/ObserveQuery', () => ({
|
||||
ObserveQuery: {
|
||||
customEventNamesAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../../prompts', () => {
|
||||
const actual = jest.requireActual('../../../prompts');
|
||||
return {
|
||||
...actual,
|
||||
selectAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveSessionEventsAsync = jest.mocked(fetchObserveSessionEventsAsync);
|
||||
const mockFetchSessionMetricCandidatesAsync = jest.mocked(fetchSessionMetricCandidatesAsync);
|
||||
const mockFetchSessionLogCandidatesAsync = jest.mocked(fetchSessionLogCandidatesAsync);
|
||||
const mockCustomEventNamesAsync = jest.mocked(ObserveQuery.customEventNamesAsync);
|
||||
const mockSelectAsync = jest.mocked(selectAsync);
|
||||
const mockBuildObserveSessionEventsTable = jest.mocked(buildObserveSessionEventsTable);
|
||||
const mockBuildObserveSessionEventsJson = jest.mocked(buildObserveSessionEventsJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeMetricEvent(overrides: any = {}): any {
|
||||
return {
|
||||
__typename: 'AppObserveEvent',
|
||||
id: 'evt-m-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-metric-1',
|
||||
easClientId: 'client-1',
|
||||
customParams: null,
|
||||
routeName: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCustomEvent(overrides: any = {}): any {
|
||||
return {
|
||||
__typename: 'AppObserveCustomEvent',
|
||||
id: 'evt-c-1',
|
||||
eventName: 'login_pressed',
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
sessionId: 'session-log-1',
|
||||
severityNumber: null,
|
||||
severityText: null,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
appEasBuildId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
environment: 'production',
|
||||
easClientId: 'client-1',
|
||||
countryCode: 'US',
|
||||
properties: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(ObserveSession, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
@@ -35,6 +107,9 @@ describe(ObserveSession, () => {
|
||||
hasMoreMetricEvents: false,
|
||||
hasMoreLogEvents: false,
|
||||
});
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([]);
|
||||
mockFetchSessionLogCandidatesAsync.mockResolvedValue([]);
|
||||
mockCustomEventNamesAsync.mockResolvedValue({ names: [], isTruncated: false });
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveSession {
|
||||
@@ -47,11 +122,7 @@ describe(ObserveSession, () => {
|
||||
return command;
|
||||
}
|
||||
|
||||
it('requires a session ID argument', async () => {
|
||||
const command = createCommand([]);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
// ---- existing single-session behavior ----
|
||||
|
||||
it('passes the session ID argument to the fetcher', async () => {
|
||||
const command = createCommand(['session-abc']);
|
||||
@@ -76,7 +147,7 @@ describe(ObserveSession, () => {
|
||||
expect(mockBuildObserveSessionEventsJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits JSON output when --json is set', async () => {
|
||||
it('emits JSON output when --json is set with a session ID', async () => {
|
||||
const command = createCommand(['session-abc', '--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
@@ -90,4 +161,212 @@ describe(ObserveSession, () => {
|
||||
const command = createCommand(['session-abc', '--platform', 'ios']);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ---- new picker-mode behavior ----
|
||||
|
||||
it('errors when no session ID is provided in non-interactive mode', async () => {
|
||||
const command = createCommand(['--non-interactive']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/session ID argument is required/);
|
||||
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats --json as non-interactive (requires a session ID)', async () => {
|
||||
const command = createCommand(['--json']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/session ID argument is required/);
|
||||
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects picker flags when a session ID is also provided', async () => {
|
||||
const command = createCommand(['session-abc', '--event-name', 'tti']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
|
||||
});
|
||||
|
||||
it('rejects --days when a session ID is also provided', async () => {
|
||||
const command = createCommand(['session-abc', '--days', '7']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
|
||||
});
|
||||
|
||||
it('picker mode with --event-name tti fetches metric events and threads selected sessionId', async () => {
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
|
||||
makeMetricEvent({ sessionId: 'picked-session-1' }),
|
||||
]);
|
||||
// 1) sort prompt (no --sort flag), 2) event picker
|
||||
mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('picked-session-1');
|
||||
|
||||
const command = createCommand(['--event-name', 'tti']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchSessionMetricCandidatesAsync).toHaveBeenCalledTimes(1);
|
||||
const options = mockFetchSessionMetricCandidatesAsync.mock.calls[0][2];
|
||||
expect(options.metricName).toBe('expo.app_startup.tti');
|
||||
expect(options.limit).toBe(25);
|
||||
|
||||
expect(mockFetchSessionLogCandidatesAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchObserveSessionEventsAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
expect.objectContaining({ sessionId: 'picked-session-1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('picker mode with an unknown --event-name treats it as a log event and fetches custom events', async () => {
|
||||
mockFetchSessionLogCandidatesAsync.mockResolvedValue([
|
||||
makeCustomEvent({ sessionId: 'picked-log-session-1' }),
|
||||
]);
|
||||
// 1) sort prompt (no --sort flag), 2) event picker
|
||||
mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('picked-log-session-1');
|
||||
|
||||
const command = createCommand(['--event-name', 'login_pressed']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchSessionLogCandidatesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchSessionLogCandidatesAsync.mock.calls[0][2].eventName).toBe('login_pressed');
|
||||
expect(mockFetchSessionMetricCandidatesAsync).not.toHaveBeenCalled();
|
||||
expect(mockFetchObserveSessionEventsAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
expect.objectContaining({ sessionId: 'picked-log-session-1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('picker mode without --event-name or --sort prompts for event name, then sort, then event', async () => {
|
||||
mockCustomEventNamesAsync.mockResolvedValue({
|
||||
names: [
|
||||
{ __typename: 'AppObserveCustomEventName', eventName: 'login_pressed', count: 12 } as any,
|
||||
],
|
||||
isTruncated: false,
|
||||
});
|
||||
// 1) event-name picker → metric choice
|
||||
// 2) sort picker → newest
|
||||
// 3) event picker → sessionId
|
||||
mockSelectAsync
|
||||
.mockResolvedValueOnce({ name: 'expo.app_startup.tti', isMetric: true } as any)
|
||||
.mockResolvedValueOnce('newest')
|
||||
.mockResolvedValueOnce('picked-session-2');
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
|
||||
makeMetricEvent({ sessionId: 'picked-session-2' }),
|
||||
]);
|
||||
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCustomEventNamesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockSelectAsync).toHaveBeenCalledTimes(3);
|
||||
expect(mockFetchSessionMetricCandidatesAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchSessionMetricCandidatesAsync.mock.calls[0][2].metricName).toBe(
|
||||
'expo.app_startup.tti'
|
||||
);
|
||||
expect(mockFetchObserveSessionEventsAsync.mock.calls[0][2].sessionId).toBe('picked-session-2');
|
||||
});
|
||||
|
||||
it('prompts for sort order when --sort is not provided and offers metric-only options for metric events', async () => {
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
|
||||
makeMetricEvent({ sessionId: 'picked-session-3' }),
|
||||
]);
|
||||
mockSelectAsync.mockResolvedValueOnce('slowest').mockResolvedValueOnce('picked-session-3');
|
||||
|
||||
const command = createCommand(['--event-name', 'tti']);
|
||||
await command.runAsync();
|
||||
|
||||
// First selectAsync is the sort picker; verify choice values
|
||||
const sortChoices = mockSelectAsync.mock.calls[0][1];
|
||||
expect(sortChoices.map((c: any) => c.value)).toEqual([
|
||||
'newest',
|
||||
'oldest',
|
||||
'slowest',
|
||||
'fastest',
|
||||
]);
|
||||
});
|
||||
|
||||
it('prompts for sort order without fastest/slowest for log events', async () => {
|
||||
mockFetchSessionLogCandidatesAsync.mockResolvedValue([
|
||||
makeCustomEvent({ sessionId: 'picked-log-session-2' }),
|
||||
]);
|
||||
mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('picked-log-session-2');
|
||||
|
||||
const command = createCommand(['--event-name', 'login_pressed']);
|
||||
await command.runAsync();
|
||||
|
||||
const sortChoices = mockSelectAsync.mock.calls[0][1];
|
||||
expect(sortChoices.map((c: any) => c.value)).toEqual(['newest', 'oldest']);
|
||||
});
|
||||
|
||||
it('skips the sort prompt when --sort is provided explicitly', async () => {
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
|
||||
makeMetricEvent({ sessionId: 'picked-session-4' }),
|
||||
]);
|
||||
mockSelectAsync.mockResolvedValueOnce('picked-session-4');
|
||||
|
||||
const command = createCommand(['--event-name', 'tti', '--sort', 'oldest']);
|
||||
await command.runAsync();
|
||||
|
||||
// Only the event picker was called, not the sort picker
|
||||
expect(mockSelectAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('forwards --sort=slowest to the metric candidate fetcher', async () => {
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([makeMetricEvent()]);
|
||||
mockSelectAsync.mockResolvedValueOnce('session-metric-1');
|
||||
|
||||
const command = createCommand(['--event-name', 'tti', '--sort', 'slowest']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchSessionMetricCandidatesAsync.mock.calls[0][2].sort).toBe('slowest');
|
||||
});
|
||||
|
||||
it('throws when --sort=slowest is combined with a log event name', async () => {
|
||||
const command = createCommand(['--event-name', 'login_pressed', '--sort', 'slowest']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/only supported for metric events/);
|
||||
expect(mockFetchSessionLogCandidatesAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes orderAscending=true to the log candidate fetcher for --sort=oldest', async () => {
|
||||
mockFetchSessionLogCandidatesAsync.mockResolvedValue([makeCustomEvent()]);
|
||||
mockSelectAsync.mockResolvedValueOnce('session-log-1');
|
||||
|
||||
const command = createCommand(['--event-name', 'login_pressed', '--sort', 'oldest']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchSessionLogCandidatesAsync.mock.calls[0][2].orderAscending).toBe(true);
|
||||
});
|
||||
|
||||
it('passes orderAscending=false to the log candidate fetcher for --sort=newest', async () => {
|
||||
mockFetchSessionLogCandidatesAsync.mockResolvedValue([makeCustomEvent()]);
|
||||
mockSelectAsync.mockResolvedValueOnce('session-log-1');
|
||||
|
||||
const command = createCommand(['--event-name', 'login_pressed', '--sort', 'newest']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchSessionLogCandidatesAsync.mock.calls[0][2].orderAscending).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when the metric picker returns no candidate events', async () => {
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([]);
|
||||
mockSelectAsync.mockResolvedValueOnce('newest');
|
||||
const command = createCommand(['--event-name', 'tti']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/No events found/);
|
||||
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers each returned candidate as a choice in the event picker', async () => {
|
||||
mockFetchSessionMetricCandidatesAsync.mockResolvedValue([
|
||||
makeMetricEvent({ sessionId: 'session-a' }),
|
||||
makeMetricEvent({ id: 'evt-m-2', sessionId: 'session-b' }),
|
||||
]);
|
||||
// 1) sort prompt, 2) event picker
|
||||
mockSelectAsync.mockResolvedValueOnce('newest').mockResolvedValueOnce('session-a');
|
||||
|
||||
const command = createCommand(['--event-name', 'tti']);
|
||||
await command.runAsync();
|
||||
|
||||
// Second selectAsync call is the event picker
|
||||
const eventChoices = mockSelectAsync.mock.calls[1][1];
|
||||
expect(eventChoices).toHaveLength(2);
|
||||
expect(eventChoices.map((c: any) => c.value)).toEqual(['session-a', 'session-b']);
|
||||
});
|
||||
|
||||
it('rejects --sort when a session ID is also provided', async () => {
|
||||
const command = createCommand(['session-abc', '--sort', 'slowest']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/picker flags/);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import Log from '../../log';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
@@ -73,6 +76,7 @@ export default class ObserveEvents extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveEvents);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (args.eventName && flags['all-events']) {
|
||||
throw new Error(
|
||||
@@ -85,10 +89,10 @@ export default class ObserveEvents extends EasCommand {
|
||||
commandClass: ObserveEvents,
|
||||
loggedInOnlyContextDefinition: ObserveEvents.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
@@ -104,7 +108,7 @@ export default class ObserveEvents extends EasCommand {
|
||||
platform,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveCustomEventNamesJson(names, isTruncated));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
@@ -140,7 +144,7 @@ export default class ObserveEvents extends EasCommand {
|
||||
platform,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(
|
||||
buildObserveCustomEventsEmptyWithSuggestionsJson(args.eventName, names, isTruncated)
|
||||
);
|
||||
@@ -158,7 +162,7 @@ export default class ObserveEvents extends EasCommand {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveCustomEventsJson(events, pageInfo));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
|
||||
import {
|
||||
@@ -73,16 +76,17 @@ export default class ObserveMetricsSummary extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveMetricsSummary);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveMetricsSummary,
|
||||
loggedInOnlyContextDefinition: ObserveMetricsSummary.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
@@ -108,7 +112,7 @@ export default class ObserveMetricsSummary extends EasCommand {
|
||||
? Array.from(new Set(flags.stat.map(resolveStatKey)))
|
||||
: undefined;
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON;
|
||||
printJsonOnlyOutput(
|
||||
buildObserveMetricsJson(
|
||||
|
||||
@@ -2,7 +2,10 @@ import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
@@ -71,23 +74,24 @@ export default class ObserveMetrics extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveMetrics);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveMetrics,
|
||||
loggedInOnlyContextDefinition: ObserveMetrics.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
let metricName: string;
|
||||
if (args.metric) {
|
||||
metricName = resolveMetricName(args.metric);
|
||||
} else if (flags['non-interactive']) {
|
||||
} else if (nonInteractive) {
|
||||
throw new EasCommandError(
|
||||
'A metric argument is required in non-interactive mode. Available metrics: ' +
|
||||
Object.keys(METRIC_ALIASES).join(', ')
|
||||
@@ -128,7 +132,7 @@ export default class ObserveMetrics extends EasCommand {
|
||||
),
|
||||
]);
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveEventsJson(events, pageInfo));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveNavigationRoutesAsync } from '../../observe/fetchNavigationRoutes';
|
||||
@@ -81,16 +84,17 @@ export default class ObserveRoutes extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveRoutes);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveRoutes,
|
||||
loggedInOnlyContextDefinition: ObserveRoutes.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
@@ -125,7 +129,7 @@ export default class ObserveRoutes extends EasCommand {
|
||||
}
|
||||
);
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
const stats = argumentsStat ?? DEFAULT_STATS_JSON;
|
||||
printJsonOnlyOutput(
|
||||
buildObserveNavigationRoutesJson(routes, metricNames, stats, pageInfoByPlatform)
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
import { Args } from '@oclif/core';
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { EasCommandError } from '../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveSessionEventsAsync } from '../../observe/fetchSessions';
|
||||
import { ObserveProjectIdFlag } from '../../observe/flags';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { EventsOrderPreset } from '../../observe/fetchEvents';
|
||||
import {
|
||||
fetchObserveSessionEventsAsync,
|
||||
fetchSessionLogCandidatesAsync,
|
||||
fetchSessionMetricCandidatesAsync,
|
||||
} from '../../observe/fetchSessions';
|
||||
import { ObserveProjectIdFlag, ObserveTimeRangeFlags } from '../../observe/flags';
|
||||
import {
|
||||
buildObserveSessionEventsJson,
|
||||
buildObserveSessionEventsTable,
|
||||
formatLogCandidateTitle,
|
||||
formatMetricCandidateTitle,
|
||||
} from '../../observe/formatSessions';
|
||||
import {
|
||||
METRIC_SHORT_NAMES,
|
||||
isKnownMetricName,
|
||||
resolveMetricName,
|
||||
} from '../../observe/metricNames';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { ExpoChoice, selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
// Fixed at 100 — the maximum page size accepted by the underlying events and
|
||||
@@ -17,18 +37,33 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
// command pulls one page of each and merges client-side.
|
||||
const SESSION_PAGE_SIZE = 100;
|
||||
|
||||
// How many candidate events to present in the picker. Small enough to browse;
|
||||
// users narrow further with --days / --sort.
|
||||
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';
|
||||
|
||||
static override args = {
|
||||
sessionId: Args.string({
|
||||
description: 'Session ID to inspect',
|
||||
required: true,
|
||||
description: 'Session ID to inspect (omit in interactive mode to pick one from a list)',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
sort: Flags.option({
|
||||
description:
|
||||
'Sort order for candidate events when picking a session (if omitted in interactive mode, you will be prompted)',
|
||||
options: Object.values(EventsOrderPreset).map(s => s.toLowerCase()),
|
||||
required: false,
|
||||
})(),
|
||||
'event-name': Flags.string({
|
||||
description:
|
||||
'Metric or log event name to pick candidate sessions by (e.g. tti, cold_launch, login_pressed). If omitted in interactive mode, you will be prompted.',
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -44,30 +79,59 @@ export default class ObserveSession extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveSession);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveSession,
|
||||
loggedInOnlyContextDefinition: ObserveSession.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
let sessionId: string;
|
||||
if (args.sessionId) {
|
||||
const pickerFlagsProvided =
|
||||
flags['event-name'] !== undefined ||
|
||||
flags.sort !== undefined ||
|
||||
flags.days !== undefined ||
|
||||
flags.start !== undefined ||
|
||||
flags.end !== undefined;
|
||||
if (pickerFlagsProvided) {
|
||||
throw new EasCommandError(
|
||||
'The picker flags (--event-name, --sort, --days, --start, --end) describe how to find a session and cannot be combined with a session ID argument.'
|
||||
);
|
||||
}
|
||||
sessionId = args.sessionId;
|
||||
} else if (nonInteractive) {
|
||||
throw new EasCommandError(
|
||||
'A session ID argument is required in non-interactive mode. In interactive mode, you can omit the session ID to pick one from a list of events.'
|
||||
);
|
||||
} else {
|
||||
sessionId = await pickSessionIdInteractivelyAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
eventNameFlag: flags['event-name'],
|
||||
sort: flags.sort,
|
||||
timeRangeFlags: { days: flags.days, start: flags.start, end: flags.end },
|
||||
});
|
||||
}
|
||||
|
||||
const { entries, metadata, hasMoreMetricEvents, hasMoreLogEvents } =
|
||||
await fetchObserveSessionEventsAsync(graphqlClient, projectId, {
|
||||
sessionId: args.sessionId,
|
||||
sessionId,
|
||||
limit: SESSION_PAGE_SIZE,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(
|
||||
buildObserveSessionEventsJson(
|
||||
entries,
|
||||
args.sessionId,
|
||||
sessionId,
|
||||
metadata,
|
||||
hasMoreMetricEvents,
|
||||
hasMoreLogEvents
|
||||
@@ -85,3 +149,145 @@ export default class ObserveSession extends EasCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface EventNameChoice {
|
||||
name: string;
|
||||
isMetric: boolean;
|
||||
}
|
||||
|
||||
async function pickSessionIdInteractivelyAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
eventNameFlag,
|
||||
sort,
|
||||
timeRangeFlags,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
eventNameFlag: string | undefined;
|
||||
sort: string | undefined;
|
||||
timeRangeFlags: { days: number | undefined; start: string | undefined; end: string | undefined };
|
||||
}): Promise<string> {
|
||||
const { startTime, endTime } = resolveTimeRange(timeRangeFlags);
|
||||
|
||||
const eventNameChoice: EventNameChoice = eventNameFlag
|
||||
? { name: eventNameFlag, isMetric: isKnownMetricName(eventNameFlag) }
|
||||
: await promptForEventNameAsync({ graphqlClient, projectId, startTime, endTime });
|
||||
|
||||
let sortValue: string;
|
||||
if (sort) {
|
||||
const preset = sort.toUpperCase() as EventsOrderPreset;
|
||||
const sortIsValueBased =
|
||||
preset === EventsOrderPreset.Slowest || preset === EventsOrderPreset.Fastest;
|
||||
if (!eventNameChoice.isMetric && sortIsValueBased) {
|
||||
throw new EasCommandError(
|
||||
`--sort=${sort} is only supported for metric events. Use newest or oldest for log events.`
|
||||
);
|
||||
}
|
||||
sortValue = sort;
|
||||
} else {
|
||||
sortValue = await promptForSortOrderAsync(eventNameChoice.isMetric);
|
||||
}
|
||||
|
||||
const candidates: CandidateEvent[] = eventNameChoice.isMetric
|
||||
? (
|
||||
await fetchSessionMetricCandidatesAsync(graphqlClient, projectId, {
|
||||
metricName: resolveMetricName(eventNameChoice.name),
|
||||
sort: sortValue,
|
||||
startTime,
|
||||
endTime,
|
||||
limit: PICKER_CANDIDATE_LIMIT,
|
||||
})
|
||||
).map(event => ({ sessionId: event.sessionId, title: formatMetricCandidateTitle(event) }))
|
||||
: (
|
||||
await fetchSessionLogCandidatesAsync(graphqlClient, projectId, {
|
||||
eventName: eventNameChoice.name,
|
||||
orderAscending: sortValue.toUpperCase() === EventsOrderPreset.Oldest,
|
||||
startTime,
|
||||
endTime,
|
||||
limit: PICKER_CANDIDATE_LIMIT,
|
||||
})
|
||||
).map(event => ({ sessionId: event.sessionId, title: formatLogCandidateTitle(event) }));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new EasCommandError(
|
||||
`No events found for "${eventNameChoice.name}" in the selected time range. Try widening the window with --days or picking a different --event-name.`
|
||||
);
|
||||
}
|
||||
|
||||
return await promptForSessionIdAsync(candidates);
|
||||
}
|
||||
|
||||
async function promptForSortOrderAsync(isMetric: boolean): Promise<string> {
|
||||
const choices: ExpoChoice<string>[] = [
|
||||
{ title: 'Newest first', value: EventsOrderPreset.Newest.valueOf().toLowerCase() },
|
||||
{ title: 'Oldest first', value: EventsOrderPreset.Oldest.valueOf().toLowerCase() },
|
||||
...(isMetric
|
||||
? [
|
||||
{
|
||||
title: 'Slowest first (highest metric value)',
|
||||
value: EventsOrderPreset.Slowest.valueOf().toLowerCase(),
|
||||
},
|
||||
{
|
||||
title: 'Fastest first (lowest metric value)',
|
||||
value: EventsOrderPreset.Fastest.valueOf().toLowerCase(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return await selectAsync('Sort candidate events by', choices);
|
||||
}
|
||||
|
||||
async function promptForEventNameAsync({
|
||||
graphqlClient,
|
||||
projectId,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
projectId: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}): Promise<EventNameChoice> {
|
||||
const { names: customEventNames } = await ObserveQuery.customEventNamesAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
|
||||
const metricChoices: ExpoChoice<EventNameChoice>[] = Object.entries(METRIC_SHORT_NAMES).map(
|
||||
([fullName, displayName]) => ({
|
||||
title: `${displayName} (metric)`,
|
||||
value: { name: fullName, isMetric: true },
|
||||
})
|
||||
);
|
||||
|
||||
const logChoices: ExpoChoice<EventNameChoice>[] = customEventNames.map(
|
||||
({ eventName, count }) => ({
|
||||
title: `${eventName} (${count} log event${count === 1 ? '' : 's'})`,
|
||||
value: { name: eventName, isMetric: false },
|
||||
})
|
||||
);
|
||||
|
||||
const choices = [...metricChoices, ...logChoices];
|
||||
if (choices.length === 0) {
|
||||
throw new EasCommandError(
|
||||
'No metric or log events found for the selected time range. Widen the window with --days or wait for data to arrive.'
|
||||
);
|
||||
}
|
||||
|
||||
return await selectAsync('Select an event name', choices);
|
||||
}
|
||||
|
||||
interface CandidateEvent {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
async function promptForSessionIdAsync(candidates: CandidateEvent[]): Promise<string> {
|
||||
const choices: ExpoChoice<string>[] = candidates.map(c => ({
|
||||
title: c.title,
|
||||
value: c.sessionId,
|
||||
}));
|
||||
return await selectAsync('Select an event (its session will be shown)', choices);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
|
||||
import {
|
||||
@@ -34,16 +37,17 @@ export default class ObserveVersions extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveVersions);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveVersions,
|
||||
loggedInOnlyContextDefinition: ObserveVersions.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
@@ -59,7 +63,7 @@ export default class ObserveVersions extends EasCommand {
|
||||
endTime
|
||||
);
|
||||
|
||||
if (flags.json) {
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildObserveVersionsJson(results));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
|
||||
+106
@@ -1563,6 +1563,10 @@ export type App = Project & {
|
||||
workerDeploymentsCrashes?: Maybe<WorkerDeploymentCrashes>;
|
||||
workerDeploymentsRequest: WorkerDeploymentRequestEdge;
|
||||
workerDeploymentsRequests?: Maybe<WorkerDeploymentRequests>;
|
||||
/** Caches associated with this app, ordered by last access time (most recent first). */
|
||||
workflowCachesPaginated: AppWorkflowCachesConnection;
|
||||
/** Per-type caching configuration for this app. */
|
||||
workflowCachingConfig: WorkflowCachingConfig;
|
||||
workflowDeviceTestCaseHistory: WorkflowDeviceTestCaseHistory;
|
||||
workflowDeviceTestCaseInsights: WorkflowDeviceTestCaseInsights;
|
||||
workflowRunGitBranchesPaginated: AppWorkflowRunGitBranchesConnection;
|
||||
@@ -1892,6 +1896,15 @@ export type AppWorkerDeploymentsRequestsArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppWorkflowCachesPaginatedArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppWorkflowDeviceTestCaseHistoryArgs = {
|
||||
filters?: InputMaybe<WorkflowDeviceTestCaseHistoryFiltersInput>;
|
||||
@@ -2096,6 +2109,8 @@ export type AppMutation = {
|
||||
setPushSecurityEnabled: App;
|
||||
/** Set resource class experiment for app */
|
||||
setResourceClassExperiment: App;
|
||||
/** Set per-type caching configuration for the app. */
|
||||
setWorkflowCachingConfig: App;
|
||||
};
|
||||
|
||||
|
||||
@@ -2131,6 +2146,12 @@ export type AppMutationSetResourceClassExperimentArgs = {
|
||||
resourceClassExperiment?: InputMaybe<ResourceClassExperiment>;
|
||||
};
|
||||
|
||||
|
||||
export type AppMutationSetWorkflowCachingConfigArgs = {
|
||||
appId: Scalars['ID']['input'];
|
||||
config: WorkflowCachingConfigInput;
|
||||
};
|
||||
|
||||
/** App-level notification preference */
|
||||
export type AppNotificationPreference = NotificationPreference & {
|
||||
__typename?: 'AppNotificationPreference';
|
||||
@@ -3361,6 +3382,18 @@ export type AppWithGithubRepositoryInput = {
|
||||
projectName: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type AppWorkflowCacheEdge = {
|
||||
__typename?: 'AppWorkflowCacheEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: WorkflowCache;
|
||||
};
|
||||
|
||||
export type AppWorkflowCachesConnection = {
|
||||
__typename?: 'AppWorkflowCachesConnection';
|
||||
edges: Array<AppWorkflowCacheEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type AppWorkflowFilterInput = {
|
||||
requestedGitRef?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
@@ -9051,6 +9084,7 @@ export type RootMutation = {
|
||||
webhook: WebhookMutation;
|
||||
/** Mutations that modify a websiteNotification */
|
||||
websiteNotifications: WebsiteNotificationMutation;
|
||||
workflowCache: WorkflowCacheMutation;
|
||||
workflowDeviceTestCaseResult: WorkflowDeviceTestCaseResultMutation;
|
||||
workflowJobAppleDeviceRegistrationRequest: WorkflowJobAppleDeviceRegistrationRequestMutation;
|
||||
workflowJobApproval: WorkflowJobApprovalMutation;
|
||||
@@ -10589,6 +10623,11 @@ export type UpdatesTimelineFilter = {
|
||||
channel?: InputMaybe<Scalars['String']['input']>;
|
||||
platform?: InputMaybe<AppPlatform>;
|
||||
runtimeVersions?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
/**
|
||||
* Case-insensitive substring match on update group message and branch name.
|
||||
* Embedded updates are matched on channel and runtime version.
|
||||
*/
|
||||
searchTerm?: InputMaybe<Scalars['String']['input']>;
|
||||
types?: InputMaybe<Array<UpdatesTimelineItemType>>;
|
||||
};
|
||||
|
||||
@@ -12013,6 +12052,72 @@ export enum WorkflowArtifactStorageType {
|
||||
R2 = 'R2'
|
||||
}
|
||||
|
||||
export type WorkflowCache = {
|
||||
__typename?: 'WorkflowCache';
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
/** Actor who created this cache. Null if the creating user no longer exists. */
|
||||
creatingActor?: Maybe<Actor>;
|
||||
/** Git branch name the cache was created from. Null for user-scoped caches. */
|
||||
gitBranchName?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
* Build that created this cache. Null if the cache was created by a job run
|
||||
* or the build no longer exists.
|
||||
*/
|
||||
hydratingBuild?: Maybe<Build>;
|
||||
/**
|
||||
* Job run that created this cache. Null if the cache was created by a build
|
||||
* or the job run no longer exists.
|
||||
*/
|
||||
hydratingJobRun?: Maybe<JobRun>;
|
||||
id: Scalars['ID']['output'];
|
||||
/** Cache key used to identify the cache entry. */
|
||||
key: Scalars['String']['output'];
|
||||
/**
|
||||
* Time the cache was last used to restore, or the creation time if it has
|
||||
* never been restored.
|
||||
*/
|
||||
lastAccessedAt: Scalars['DateTime']['output'];
|
||||
/** Size of the cache archive in bytes. Null if the cache has not been hydrated yet. */
|
||||
sizeBytes?: Maybe<Scalars['Float']['output']>;
|
||||
};
|
||||
|
||||
export type WorkflowCacheMutation = {
|
||||
__typename?: 'WorkflowCacheMutation';
|
||||
deleteWorkflowCache: WorkflowCache;
|
||||
};
|
||||
|
||||
|
||||
export type WorkflowCacheMutationDeleteWorkflowCacheArgs = {
|
||||
cacheId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type WorkflowCachingConfig = {
|
||||
__typename?: 'WorkflowCachingConfig';
|
||||
/**
|
||||
* Whether the ccache compiler cache is enabled. Null if not explicitly
|
||||
* configured, in which case the account-level default applies.
|
||||
*/
|
||||
ccacheEnabled?: Maybe<Scalars['Boolean']['output']>;
|
||||
/**
|
||||
* Whether the Gradle build cache is enabled. Null if not explicitly
|
||||
* configured, in which case it is disabled.
|
||||
*/
|
||||
gradleCacheEnabled?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type WorkflowCachingConfigInput = {
|
||||
/**
|
||||
* Pass true or false to explicitly enable or disable, null to reset to the account-level
|
||||
* default, or omit the field to leave it unchanged.
|
||||
*/
|
||||
ccacheEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
/**
|
||||
* Pass true or false to explicitly enable or disable, null to reset to the default
|
||||
* (disabled), or omit the field to leave it unchanged.
|
||||
*/
|
||||
gradleCacheEnabled?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Grouping key from the [shard N] prefix-stripped error_message. `count` is
|
||||
* computed with uniqExact(test_case_result_id) so RMT pre-merge duplicates do
|
||||
@@ -14511,6 +14616,7 @@ export type AppObserveCustomEventListQueryVariables = Exact<{
|
||||
filter?: InputMaybe<AppObserveCustomEventListFilter>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
orderBy?: InputMaybe<AppObserveCustomEventListOrderBy>;
|
||||
}>;
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AppObserveAppVersion,
|
||||
AppObserveCustomEvent,
|
||||
AppObserveCustomEventListFilter,
|
||||
AppObserveCustomEventListOrderBy,
|
||||
AppObserveCustomEventName,
|
||||
AppObserveEvent,
|
||||
AppObserveEventsFilter,
|
||||
@@ -112,6 +113,7 @@ type AppObserveCustomEventListQueryVariables = {
|
||||
filter?: AppObserveCustomEventListFilter;
|
||||
first?: number;
|
||||
after?: string;
|
||||
orderBy?: AppObserveCustomEventListOrderBy;
|
||||
};
|
||||
|
||||
type AppObserveCustomEventNamesQuery = {
|
||||
@@ -328,12 +330,18 @@ export const ObserveQuery = {
|
||||
$filter: AppObserveCustomEventListFilter
|
||||
$first: Int
|
||||
$after: String
|
||||
$orderBy: AppObserveCustomEventListOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
customEventList(filter: $filter, first: $first, after: $after) {
|
||||
customEventList(
|
||||
filter: $filter
|
||||
first: $first
|
||||
after: $after
|
||||
orderBy: $orderBy
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import {
|
||||
AppObserveCustomEvent,
|
||||
AppObserveCustomEventListOrderByField,
|
||||
AppObserveEvent,
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
} from '../../graphql/generated';
|
||||
import { fetchObserveCustomEventsAsync } from '../fetchCustomEvents';
|
||||
import { fetchObserveEventsAsync } from '../fetchEvents';
|
||||
import { fetchObserveSessionEventsAsync } from '../fetchSessions';
|
||||
import {
|
||||
fetchObserveSessionEventsAsync,
|
||||
fetchSessionLogCandidatesAsync,
|
||||
fetchSessionMetricCandidatesAsync,
|
||||
} from '../fetchSessions';
|
||||
|
||||
jest.mock('../fetchCustomEvents');
|
||||
jest.mock('../fetchEvents');
|
||||
jest.mock('../fetchEvents', () => {
|
||||
const actual = jest.requireActual('../fetchEvents');
|
||||
return {
|
||||
...actual,
|
||||
fetchObserveEventsAsync: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
|
||||
const mockFetchObserveCustomEventsAsync = jest.mocked(fetchObserveCustomEventsAsync);
|
||||
@@ -191,3 +202,149 @@ describe('fetchObserveSessionEventsAsync', () => {
|
||||
expect(result.hasMoreLogEvents).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchSessionMetricCandidatesAsync', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards metricName, sort, window, and limit into fetchObserveEventsAsync', async () => {
|
||||
await fetchSessionMetricCandidatesAsync({} as any, 'project-1', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
sort: 'slowest',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-02-01T00:00:00.000Z',
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
const options = mockFetchObserveEventsAsync.mock.calls[0][2];
|
||||
expect(options.metricName).toBe('expo.app_startup.tti');
|
||||
expect(options.limit).toBe(25);
|
||||
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
expect(options.orderBy).toEqual({
|
||||
field: AppObserveEventsOrderByField.MetricValue,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters out events without a sessionId', async () => {
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: [
|
||||
makeMetricEvent({ id: 'evt-1', sessionId: 'session-a' }),
|
||||
makeMetricEvent({ id: 'evt-2', sessionId: null }),
|
||||
makeMetricEvent({ id: 'evt-3', sessionId: 'session-b' }),
|
||||
],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
const result = await fetchSessionMetricCandidatesAsync({} as any, 'project-1', {
|
||||
metricName: 'expo.app_startup.tti',
|
||||
sort: 'newest',
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-02-01T00:00:00.000Z',
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
expect(result.map(e => e.id)).toEqual(['evt-1', 'evt-3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchSessionLogCandidatesAsync', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards eventName, window, and limit into fetchObserveCustomEventsAsync', async () => {
|
||||
await fetchSessionLogCandidatesAsync({} as any, 'project-1', {
|
||||
eventName: 'login_pressed',
|
||||
orderAscending: false,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-02-01T00:00:00.000Z',
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
const options = mockFetchObserveCustomEventsAsync.mock.calls[0][2];
|
||||
expect(options.eventName).toBe('login_pressed');
|
||||
expect(options.limit).toBe(25);
|
||||
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('requests descending timestamp order when orderAscending is false', async () => {
|
||||
await fetchSessionLogCandidatesAsync({} as any, 'project-1', {
|
||||
eventName: 'login_pressed',
|
||||
orderAscending: false,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-02-01T00:00:00.000Z',
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
expect(mockFetchObserveCustomEventsAsync.mock.calls[0][2].orderBy).toEqual({
|
||||
field: AppObserveCustomEventListOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('requests ascending timestamp order when orderAscending is true', async () => {
|
||||
await fetchSessionLogCandidatesAsync({} as any, 'project-1', {
|
||||
eventName: 'login_pressed',
|
||||
orderAscending: true,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-02-01T00:00:00.000Z',
|
||||
limit: 25,
|
||||
});
|
||||
|
||||
expect(mockFetchObserveCustomEventsAsync.mock.calls[0][2].orderBy).toEqual({
|
||||
field: AppObserveCustomEventListOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the server-provided order (no client-side re-sorting)', async () => {
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [
|
||||
makeCustomEvent({ id: 'c', timestamp: '2025-01-15T10:10:00.000Z' }),
|
||||
makeCustomEvent({ id: 'b', timestamp: '2025-01-15T10:05:00.000Z' }),
|
||||
makeCustomEvent({ id: 'a', timestamp: '2025-01-15T10:00:00.000Z' }),
|
||||
],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
const result = await fetchSessionLogCandidatesAsync({} as any, 'project-1', {
|
||||
eventName: 'login_pressed',
|
||||
orderAscending: false,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-02-01T00:00:00.000Z',
|
||||
limit: 25,
|
||||
});
|
||||
expect(result.map(e => e.id)).toEqual(['c', 'b', 'a']);
|
||||
});
|
||||
|
||||
it('filters out events without a sessionId', async () => {
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [
|
||||
makeCustomEvent({ id: 'a', sessionId: 'session-a' }),
|
||||
makeCustomEvent({ id: 'b', sessionId: null }),
|
||||
],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
const result = await fetchSessionLogCandidatesAsync({} as any, 'project-1', {
|
||||
eventName: 'login_pressed',
|
||||
orderAscending: false,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-02-01T00:00:00.000Z',
|
||||
limit: 25,
|
||||
});
|
||||
expect(result.map(e => e.id)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { AppObserveCustomEvent, AppObserveEvent } from '../../graphql/generated';
|
||||
import { SessionEventEntry, SessionMetadata } from '../fetchSessions';
|
||||
import { buildObserveSessionEventsJson, buildObserveSessionEventsTable } from '../formatSessions';
|
||||
import {
|
||||
buildObserveSessionEventsJson,
|
||||
buildObserveSessionEventsTable,
|
||||
formatLogCandidateTitle,
|
||||
formatMetricCandidateTitle,
|
||||
shortSessionId,
|
||||
} from '../formatSessions';
|
||||
|
||||
function makeMetricEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventEntry {
|
||||
return {
|
||||
@@ -240,3 +247,91 @@ describe(buildObserveSessionEventsJson, () => {
|
||||
expect(result.metadata).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(shortSessionId, () => {
|
||||
it('returns the full id when it is 12 characters or fewer', () => {
|
||||
expect(shortSessionId('abc')).toBe('abc');
|
||||
expect(shortSessionId('abcdefghijkl')).toBe('abcdefghijkl');
|
||||
});
|
||||
|
||||
it('collapses long ids to <first-8>…<last-4>', () => {
|
||||
expect(shortSessionId('abcdefgh1234567890zyxw')).toBe('abcdefgh…zyxw');
|
||||
});
|
||||
});
|
||||
|
||||
function makeMetricEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
|
||||
return {
|
||||
__typename: 'AppObserveEvent' as const,
|
||||
id: 'evt-m-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 1.23,
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-abcdefgh1234567890',
|
||||
easClientId: 'client-1',
|
||||
customParams: null,
|
||||
routeName: null,
|
||||
...overrides,
|
||||
} as AppObserveEvent;
|
||||
}
|
||||
|
||||
function makeCustomEventEvt(overrides: Partial<AppObserveCustomEvent> = {}): AppObserveCustomEvent {
|
||||
return {
|
||||
__typename: 'AppObserveCustomEvent' as const,
|
||||
id: 'evt-c-1',
|
||||
eventName: 'login_pressed',
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
sessionId: 'session-abcdefgh1234567890',
|
||||
severityNumber: null,
|
||||
severityText: null,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
appEasBuildId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
environment: 'production',
|
||||
easClientId: 'client-1',
|
||||
countryCode: 'US',
|
||||
properties: [],
|
||||
...overrides,
|
||||
} as AppObserveCustomEvent;
|
||||
}
|
||||
|
||||
describe(formatMetricCandidateTitle, () => {
|
||||
it('includes timestamp, display name, value, version, device, and truncated session id', () => {
|
||||
const title = formatMetricCandidateTitle(makeMetricEvent());
|
||||
expect(title).toContain('Startup TTI 1.23s');
|
||||
expect(title).toContain('1.0.0');
|
||||
expect(title).toContain('iOS 17.0');
|
||||
expect(title).toContain('session session-…7890');
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatLogCandidateTitle, () => {
|
||||
it('includes timestamp, event name, and severity (using severityText when set)', () => {
|
||||
const title = formatLogCandidateTitle(makeCustomEventEvt({ severityText: 'WARN' }));
|
||||
expect(title).toContain('login_pressed');
|
||||
expect(title).toContain('WARN');
|
||||
expect(title).toContain('1.0.0');
|
||||
});
|
||||
|
||||
it('falls back to severityNumber when severityText is null', () => {
|
||||
const title = formatLogCandidateTitle(
|
||||
makeCustomEventEvt({ severityText: null, severityNumber: 13 })
|
||||
);
|
||||
expect(title).toContain('13');
|
||||
});
|
||||
|
||||
it('shows "-" for severity when both fields are null', () => {
|
||||
const title = formatLogCandidateTitle(makeCustomEventEvt());
|
||||
expect(title).toContain('login_pressed · -');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
getMetricDisplayName,
|
||||
isKnownMetricName,
|
||||
resolveMetricName,
|
||||
resolveNavigationMetricName,
|
||||
} from '../metricNames';
|
||||
@@ -110,3 +111,31 @@ describe(getMetricDisplayName, () => {
|
||||
expect(getMetricDisplayName('custom.metric.name')).toBe('custom.metric.name');
|
||||
});
|
||||
});
|
||||
|
||||
describe(isKnownMetricName, () => {
|
||||
it('returns true for app-startup short aliases', () => {
|
||||
expect(isKnownMetricName('tti')).toBe(true);
|
||||
expect(isKnownMetricName('cold_launch')).toBe(true);
|
||||
expect(isKnownMetricName('bundle_load')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for navigation short aliases', () => {
|
||||
expect(isKnownMetricName('nav_cold_ttr')).toBe(true);
|
||||
expect(isKnownMetricName('nav_warm_ttr')).toBe(true);
|
||||
expect(isKnownMetricName('nav_tti')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for full metric names', () => {
|
||||
expect(isKnownMetricName('expo.app_startup.tti')).toBe(true);
|
||||
expect(isKnownMetricName('expo.navigation.cold_ttr')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for unknown names (including custom event names)', () => {
|
||||
expect(isKnownMetricName('login_pressed')).toBe(false);
|
||||
expect(isKnownMetricName('some_random_event')).toBe(false);
|
||||
expect(isKnownMetricName('')).toBe(false);
|
||||
// A dotted name that isn't in the known-full set is still not "known"
|
||||
// for picker purposes; the picker treats it as a log event.
|
||||
expect(isKnownMetricName('custom.metric.name')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGr
|
||||
import {
|
||||
AppObserveCustomEvent,
|
||||
AppObserveCustomEventListFilter,
|
||||
AppObserveCustomEventListOrderBy,
|
||||
AppObservePlatform,
|
||||
PageInfo,
|
||||
} from '../graphql/generated';
|
||||
@@ -17,6 +18,7 @@ interface FetchCustomEventsOptions {
|
||||
appVersion?: string;
|
||||
updateId?: string;
|
||||
sessionId?: string;
|
||||
orderBy?: AppObserveCustomEventListOrderBy;
|
||||
}
|
||||
|
||||
interface FetchCustomEventsResult {
|
||||
@@ -44,5 +46,6 @@ export async function fetchObserveCustomEventsAsync(
|
||||
filter,
|
||||
first: options.limit,
|
||||
...(options.after && { after: options.after }),
|
||||
...(options.orderBy && { orderBy: options.orderBy }),
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppObserveCustomEvent,
|
||||
AppObserveCustomEventListOrderByField,
|
||||
AppObserveEvent,
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
} from '../graphql/generated';
|
||||
import { fetchObserveCustomEventsAsync } from './fetchCustomEvents';
|
||||
import { fetchObserveEventsAsync } from './fetchEvents';
|
||||
import { fetchObserveEventsAsync, resolveOrderBy } from './fetchEvents';
|
||||
|
||||
export interface SessionEventEntry {
|
||||
source: 'metric' | 'log';
|
||||
@@ -146,3 +147,77 @@ export async function fetchObserveSessionEventsAsync(
|
||||
hasMoreLogEvents: logResult.pageInfo.hasNextPage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A metric event that is guaranteed to belong to a session — used as a
|
||||
* candidate when picking a session to inspect via `observe:session`.
|
||||
*/
|
||||
export type SessionMetricCandidate = AppObserveEvent & { sessionId: string };
|
||||
|
||||
export interface FetchSessionMetricCandidatesOptions {
|
||||
metricName: string;
|
||||
/** One of EventsOrderPreset (case-insensitive). */
|
||||
sort: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a page of metric events for the given metricName + window, ordered
|
||||
* per `sort`, and filtered to events that have a sessionId. The events query
|
||||
* supports server-side ordering, so `sort` is passed straight through.
|
||||
*/
|
||||
export async function fetchSessionMetricCandidatesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
options: FetchSessionMetricCandidatesOptions
|
||||
): Promise<SessionMetricCandidate[]> {
|
||||
const { events } = await fetchObserveEventsAsync(graphqlClient, appId, {
|
||||
metricName: options.metricName,
|
||||
orderBy: resolveOrderBy(options.sort),
|
||||
limit: options.limit,
|
||||
startTime: options.startTime,
|
||||
endTime: options.endTime,
|
||||
});
|
||||
return events.filter((e): e is SessionMetricCandidate => !!e.sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom log event that is guaranteed to belong to a session — used as a
|
||||
* candidate when picking a session to inspect via `observe:session`.
|
||||
*/
|
||||
export type SessionLogCandidate = AppObserveCustomEvent & { sessionId: string };
|
||||
|
||||
export interface FetchSessionLogCandidatesOptions {
|
||||
eventName: string;
|
||||
/** True → oldest-first (ascending timestamp); false → newest-first. */
|
||||
orderAscending: boolean;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a page of custom log events for the given eventName + window, ordered
|
||||
* by timestamp server-side, and filtered to events that have a sessionId.
|
||||
*/
|
||||
export async function fetchSessionLogCandidatesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
options: FetchSessionLogCandidatesOptions
|
||||
): Promise<SessionLogCandidate[]> {
|
||||
const { events } = await fetchObserveCustomEventsAsync(graphqlClient, appId, {
|
||||
eventName: options.eventName,
|
||||
limit: options.limit,
|
||||
startTime: options.startTime,
|
||||
endTime: options.endTime,
|
||||
orderBy: {
|
||||
field: AppObserveCustomEventListOrderByField.Timestamp,
|
||||
direction: options.orderAscending
|
||||
? AppObserveEventsOrderByDirection.Asc
|
||||
: AppObserveEventsOrderByDirection.Desc,
|
||||
},
|
||||
});
|
||||
return events.filter((e): e is SessionLogCandidate => !!e.sessionId);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { AppObserveCustomEvent, AppObserveEvent } from '../graphql/generated';
|
||||
import { SessionEventEntry, SessionMetadata } from './fetchSessions';
|
||||
import { formatLogTimestamp } from './formatUtils';
|
||||
import { getMetricDisplayName } from './metricNames';
|
||||
@@ -142,3 +143,40 @@ export function buildObserveSessionEventsJson(
|
||||
hasMoreLogEvents,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact session ID for display in candidate lists — long UUIDs are
|
||||
* truncated to `<first-8>…<last-4>` so a row stays scannable.
|
||||
*/
|
||||
export function shortSessionId(sessionId: string): string {
|
||||
if (sessionId.length <= 12) {
|
||||
return sessionId;
|
||||
}
|
||||
return `${sessionId.slice(0, 8)}…${sessionId.slice(-4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line title for a metric event shown in the observe:session candidate
|
||||
* picker, e.g. `Jan 15, 10:00:00.000 AM · Startup TTI 1.23s · 1.0.0 · iOS 17.0 · session abc…1234`.
|
||||
*/
|
||||
export function formatMetricCandidateTitle(event: AppObserveEvent): string {
|
||||
const displayName = getMetricDisplayName(event.metricName);
|
||||
const value = `${event.metricValue.toFixed(2)}s`;
|
||||
const timestamp = formatLogTimestamp(event.timestamp);
|
||||
const shortSession = shortSessionId(event.sessionId ?? '');
|
||||
const device = `${event.deviceOs} ${event.deviceOsVersion}`;
|
||||
return `${timestamp} · ${displayName} ${value} · ${event.appVersion} · ${device} · session ${shortSession}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line title for a custom log event shown in the observe:session
|
||||
* candidate picker.
|
||||
*/
|
||||
export function formatLogCandidateTitle(event: AppObserveCustomEvent): string {
|
||||
const timestamp = formatLogTimestamp(event.timestamp);
|
||||
const severity =
|
||||
event.severityText ?? (event.severityNumber != null ? String(event.severityNumber) : '-');
|
||||
const shortSession = shortSessionId(event.sessionId ?? '');
|
||||
const device = `${event.deviceOs} ${event.deviceOsVersion}`;
|
||||
return `${timestamp} · ${event.eventName} · ${severity} · ${event.appVersion} · ${device} · session ${shortSession}`;
|
||||
}
|
||||
@@ -60,3 +60,18 @@ export function resolveNavigationMetricName(input: string): string {
|
||||
export function getMetricDisplayName(metricName: string): string {
|
||||
return METRIC_SHORT_NAMES[metricName] ?? metricName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-throwing predicate: true when `input` is a known metric alias (either
|
||||
* app-startup or navigation) or a known full metric name. Used to decide
|
||||
* whether an event name should be treated as a metric or as a custom log
|
||||
* event without forcing the caller into a try/catch around `resolveMetricName`.
|
||||
*/
|
||||
export function isKnownMetricName(input: string): boolean {
|
||||
return (
|
||||
input in METRIC_ALIASES ||
|
||||
input in NAVIGATION_METRIC_ALIASES ||
|
||||
KNOWN_FULL_NAMES.has(input) ||
|
||||
KNOWN_FULL_NAVIGATION_NAMES.has(input)
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "21.0.2",
|
||||
"version": "21.0.3",
|
||||
"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": "21.0.2",
|
||||
"@expo/eas-build-job": "21.0.3",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "21.0.2",
|
||||
"version": "21.0.3",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,8 +26,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "21.0.2",
|
||||
"@expo/eas-build-job": "21.0.2",
|
||||
"@expo/build-tools": "21.0.3",
|
||||
"@expo/eas-build-job": "21.0.3",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "21.0.2",
|
||||
"version": "21.0.3",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,7 +27,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "21.0.2",
|
||||
"@expo/eas-build-job": "21.0.3",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
|
||||
@@ -17,8 +17,6 @@ jest.mock('node:fs', () => jest.requireMock('fs'));
|
||||
jest.mock('@expo/build-tools', () => {
|
||||
return {
|
||||
...jest.requireActual('@expo/build-tools'),
|
||||
GCS: {
|
||||
uploadWithSignedUrl: jest.fn(),
|
||||
},
|
||||
uploadWithSignedUrl: jest.fn(),
|
||||
};
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "21.0.2",
|
||||
"version": "21.0.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,16 +19,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "21.0.2",
|
||||
"@expo/build-tools": "21.0.3",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "21.0.0",
|
||||
"@expo/eas-build-job": "21.0.2",
|
||||
"@expo/eas-build-job": "21.0.3",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.0.2",
|
||||
"@expo/steps": "21.0.3",
|
||||
"@expo/template-file": "21.0.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BuildContext, CacheManager, GCS } from '@expo/build-tools';
|
||||
import { BuildContext, CacheManager, uploadWithSignedUrl } from '@expo/build-tools';
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { BuildJob } from '@expo/eas-build-job';
|
||||
import filesize from 'filesize';
|
||||
@@ -59,7 +59,7 @@ export class GCSCacheManager implements CacheManager {
|
||||
}
|
||||
|
||||
try {
|
||||
await GCS.uploadWithSignedUrl({
|
||||
await uploadWithSignedUrl({
|
||||
signedUrl: config.buildCache.gcsSignedUploadUrlForBuildCache,
|
||||
srcGeneratorAsync: async () => {
|
||||
return fs.createReadStream(archivePath);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GCS } from '@expo/build-tools';
|
||||
import { uploadWithSignedUrl } from '@expo/build-tools';
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { Cache, Platform } from '@expo/eas-build-job';
|
||||
import { randomUUID } from 'crypto';
|
||||
@@ -41,7 +41,7 @@ describe(GCSCacheManager, () => {
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
await fs.rm(outsideDir, { recursive: true, force: true });
|
||||
jest.mocked(GCS.uploadWithSignedUrl).mockReset();
|
||||
jest.mocked(uploadWithSignedUrl).mockReset();
|
||||
jest.mocked(downloadFile).mockReset();
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ describe(GCSCacheManager, () => {
|
||||
},
|
||||
} as any;
|
||||
let tarReadStream: Readable;
|
||||
jest.mocked(GCS.uploadWithSignedUrl).mockImplementation(async ({ srcGeneratorAsync }) => {
|
||||
jest.mocked(uploadWithSignedUrl).mockImplementation(async ({ srcGeneratorAsync }) => {
|
||||
const result = await srcGeneratorAsync();
|
||||
tarReadStream = result;
|
||||
return randomUUID();
|
||||
|
||||
@@ -8,16 +8,16 @@ jest.mock('@expo/build-tools', () => {
|
||||
const actual = jest.requireActual('@expo/build-tools');
|
||||
const { Writable } = jest.requireActual('node:stream');
|
||||
|
||||
class MockGCSLoggerStream extends Writable {
|
||||
public static readonly CompressionMethod = actual.GCSLoggerStream.CompressionMethod;
|
||||
public static readonly instances: MockGCSLoggerStream[] = [];
|
||||
class MockRemoteLoggerStream extends Writable {
|
||||
public static readonly CompressionMethod = actual.RemoteLoggerStream.CompressionMethod;
|
||||
public static readonly instances: MockRemoteLoggerStream[] = [];
|
||||
|
||||
public readonly writes: any[] = [];
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super({ objectMode: true });
|
||||
void args;
|
||||
MockGCSLoggerStream.instances.push(this);
|
||||
MockRemoteLoggerStream.instances.push(this);
|
||||
}
|
||||
|
||||
public async init(): Promise<string> {
|
||||
@@ -38,10 +38,8 @@ jest.mock('@expo/build-tools', () => {
|
||||
|
||||
return {
|
||||
...actual,
|
||||
GCS: {
|
||||
uploadWithSignedUrl: jest.fn(),
|
||||
},
|
||||
GCSLoggerStream: MockGCSLoggerStream,
|
||||
uploadWithSignedUrl: jest.fn(),
|
||||
RemoteLoggerStream: MockRemoteLoggerStream,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -65,8 +63,8 @@ async function waitForStreamFlush(): Promise<void> {
|
||||
}
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
const { GCSLoggerStream } = jest.requireMock('@expo/build-tools') as {
|
||||
GCSLoggerStream: {
|
||||
const { RemoteLoggerStream } = jest.requireMock('@expo/build-tools') as {
|
||||
RemoteLoggerStream: {
|
||||
instances: Array<{
|
||||
writes: any[];
|
||||
}>;
|
||||
@@ -81,7 +79,7 @@ describe('logger', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue(new Response('', { status: 200, statusText: 'OK' }));
|
||||
GCSLoggerStream.instances.length = 0;
|
||||
RemoteLoggerStream.instances.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -297,13 +295,13 @@ describe('logger', () => {
|
||||
await cleanUp();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(GCSLoggerStream.instances).toHaveLength(1);
|
||||
expect(RemoteLoggerStream.instances).toHaveLength(1);
|
||||
|
||||
const gcsLogs = GCSLoggerStream.instances[0].writes;
|
||||
expect(gcsLogs).toEqual(
|
||||
const remoteLogs = RemoteLoggerStream.instances[0].writes;
|
||||
expect(remoteLogs).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ msg: 'Actual build log' })])
|
||||
);
|
||||
expect(gcsLogs).not.toEqual(
|
||||
expect(remoteLogs).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ msg: 'Failed to send logs batch over HTTP' }),
|
||||
])
|
||||
|
||||
@@ -14,7 +14,7 @@ jest.mock('@expo/build-tools', () => {
|
||||
const actual = jest.requireActual('@expo/build-tools');
|
||||
return {
|
||||
...actual,
|
||||
GCS: { uploadWithSignedUrl: jest.fn() },
|
||||
uploadWithSignedUrl: jest.fn(),
|
||||
Datadog: {
|
||||
setup: jest.fn(),
|
||||
flushAsync: jest.fn(async () => {}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BuildContext, GCS } from '@expo/build-tools';
|
||||
import { BuildContext, uploadWithSignedUrl } from '@expo/build-tools';
|
||||
import { Job, errors } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
@@ -143,7 +143,7 @@ describe(uploadApplicationArchiveAsync.name, () => {
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
|
||||
expect(uploadWithSignedUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signedUrl: {
|
||||
url: uploadUrl,
|
||||
@@ -225,7 +225,7 @@ describe(uploadApplicationArchiveAsync.name, () => {
|
||||
}),
|
||||
} as Response;
|
||||
});
|
||||
jest.mocked(GCS.uploadWithSignedUrl).mockRejectedValueOnce(uploadError);
|
||||
jest.mocked(uploadWithSignedUrl).mockRejectedValueOnce(uploadError);
|
||||
|
||||
let thrownError: unknown;
|
||||
try {
|
||||
@@ -354,8 +354,8 @@ describe(uploadBuildArtifactsAsync.name, () => {
|
||||
buildId: randomUUID(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledTimes(1);
|
||||
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
|
||||
expect(uploadWithSignedUrl).toHaveBeenCalledTimes(1);
|
||||
expect(uploadWithSignedUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signedUrl: {
|
||||
url: uploadUrl,
|
||||
@@ -433,7 +433,7 @@ describe(uploadBuildArtifactsAsync.name, () => {
|
||||
}),
|
||||
} as Response;
|
||||
});
|
||||
jest.mocked(GCS.uploadWithSignedUrl).mockRejectedValueOnce(uploadError);
|
||||
jest.mocked(uploadWithSignedUrl).mockRejectedValueOnce(uploadError);
|
||||
|
||||
let thrownError: unknown;
|
||||
try {
|
||||
@@ -525,7 +525,7 @@ describe('with signed upload url provided via www', () => {
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
|
||||
expect(uploadWithSignedUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signedUrl: {
|
||||
url: uploadUrl,
|
||||
@@ -624,8 +624,8 @@ describe(uploadWorkflowArtifactAsync.name, () => {
|
||||
logger: ctx.logger,
|
||||
});
|
||||
expect(artifactId).toBe(expectedArtifactId);
|
||||
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledTimes(1);
|
||||
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
|
||||
expect(uploadWithSignedUrl).toHaveBeenCalledTimes(1);
|
||||
expect(uploadWithSignedUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signedUrl: {
|
||||
url: uploadUrl,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GCS, GCSLoggerStream } from '@expo/build-tools';
|
||||
import { RemoteLoggerStream, type SignedUrl } from '@expo/build-tools';
|
||||
import path from 'path';
|
||||
|
||||
import { Environment } from './constants';
|
||||
@@ -34,12 +34,12 @@ export default {
|
||||
},
|
||||
gcs: {
|
||||
uploadIntervalMs: env('LOGGER_INTERVAL_MS', { defaultValue: 10000, transform: Number }),
|
||||
compressionMethod: GCSLoggerStream.CompressionMethod.BR,
|
||||
signedUploadUrlForLogs: env<GCS.SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
compressionMethod: RemoteLoggerStream.CompressionMethod.BR,
|
||||
signedUploadUrlForLogs: env<SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('gcsSignedUploadUrlForLogs'),
|
||||
defaultValue: null,
|
||||
}),
|
||||
signedUploadUrlForXcodeBuildLogs: env<GCS.SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
signedUploadUrlForXcodeBuildLogs: env<SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('gcsSignedUploadUrlForXcodeBuildLogs'),
|
||||
defaultValue: null,
|
||||
}),
|
||||
@@ -54,7 +54,7 @@ export default {
|
||||
},
|
||||
},
|
||||
buildCache: {
|
||||
gcsSignedUploadUrlForBuildCache: env<GCS.SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
gcsSignedUploadUrlForBuildCache: env<SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('gcsSignedUploadUrlForBuildCache'),
|
||||
defaultValue: null,
|
||||
}),
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
import { GCS } from '@expo/build-tools';
|
||||
import { type SignedUrl } from '@expo/build-tools';
|
||||
import { BuildJob, Generic, Metadata, errors } from '@expo/eas-build-job';
|
||||
|
||||
export const androidImagesWithJavaVersionLowerThen11 = [
|
||||
@@ -22,7 +22,7 @@ export namespace Worker {
|
||||
}
|
||||
|
||||
type JobRunWorkerRuntimeConfig = {
|
||||
gcsSignedUploadUrlForLogs: GCS.SignedUrl;
|
||||
gcsSignedUploadUrlForLogs: SignedUrl;
|
||||
|
||||
runMetricsServer: boolean;
|
||||
|
||||
@@ -31,9 +31,9 @@ export namespace Worker {
|
||||
};
|
||||
|
||||
type BuildWorkerRuntimeConfig = {
|
||||
gcsSignedUploadUrlForLogs: GCS.SignedUrl;
|
||||
gcsSignedUploadUrlForXcodeBuildLogs?: GCS.SignedUrl;
|
||||
gcsSignedUploadUrlForBuildCache?: GCS.SignedUrl;
|
||||
gcsSignedUploadUrlForLogs: SignedUrl;
|
||||
gcsSignedUploadUrlForXcodeBuildLogs?: SignedUrl;
|
||||
gcsSignedUploadUrlForBuildCache?: SignedUrl;
|
||||
gcsSignedBuildCacheDownloadUrl?: string;
|
||||
|
||||
runMetricsServer: boolean;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GCS } from '@expo/build-tools';
|
||||
import { uploadWithSignedUrl } from '@expo/build-tools';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
@@ -14,7 +14,7 @@ export async function uploadXcodeBuildLogs(logger: bunyan, logsPath: string): Pr
|
||||
if (!config.loggers.gcs.signedUploadUrlForXcodeBuildLogs) {
|
||||
logger.warn('GCS Presigned URL for Xcode logs was not specified, skipping upload');
|
||||
} else {
|
||||
await GCS.uploadWithSignedUrl({
|
||||
await uploadWithSignedUrl({
|
||||
signedUrl: config.loggers.gcs.signedUploadUrlForXcodeBuildLogs,
|
||||
srcGeneratorAsync: async () => {
|
||||
const stat = await fs.stat(logsPath);
|
||||
@@ -28,7 +28,7 @@ export async function uploadXcodeBuildLogs(logger: bunyan, logsPath: string): Pr
|
||||
}
|
||||
}
|
||||
|
||||
// HACK(Mike): Copypasta of a fragment of functionality from GCSLoggerStream.
|
||||
// HACK(Mike): Copypasta of a fragment of functionality from RemoteLoggerStream.
|
||||
async function compress(
|
||||
src: { stream: Readable; length: number },
|
||||
method: 'gzip' | 'br' | null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GCSLoggerStream, LogBuffer } from '@expo/build-tools';
|
||||
import { LogBuffer, RemoteLoggerStream } from '@expo/build-tools';
|
||||
import { BuildPhase, EnvironmentSecret, Job } from '@expo/eas-build-job';
|
||||
import { LoggerLevel, bunyan, createLogger } from '@expo/logger';
|
||||
import { Readable, Transform, TransformCallback, Writable } from 'stream';
|
||||
@@ -123,9 +123,9 @@ export async function createBuildLoggerWithSecretsFilter(secrets: Job['secrets']
|
||||
level: buildLogger.level(),
|
||||
});
|
||||
|
||||
let gcsLoggerStream: GCSLoggerStream | null = null;
|
||||
let remoteLoggerStream: RemoteLoggerStream | null = null;
|
||||
if (config.loggers.gcs.signedUploadUrlForLogs) {
|
||||
gcsLoggerStream = new GCSLoggerStream({
|
||||
remoteLoggerStream = new RemoteLoggerStream({
|
||||
uploadMethod: { signedUrl: config.loggers.gcs.signedUploadUrlForLogs },
|
||||
options: {
|
||||
uploadIntervalMs: config.loggers.gcs.uploadIntervalMs,
|
||||
@@ -133,8 +133,8 @@ export async function createBuildLoggerWithSecretsFilter(secrets: Job['secrets']
|
||||
},
|
||||
logger: defaultLogger,
|
||||
});
|
||||
transformStream.pipe(gcsLoggerStream);
|
||||
await gcsLoggerStream.init();
|
||||
transformStream.pipe(remoteLoggerStream);
|
||||
await remoteLoggerStream.init();
|
||||
}
|
||||
|
||||
const logBuffer = new WorkerLogBuffer(MAX_LINES_IN_BUFFER);
|
||||
@@ -163,7 +163,7 @@ export async function createBuildLoggerWithSecretsFilter(secrets: Job['secrets']
|
||||
return {
|
||||
logger: buildLogger,
|
||||
cleanUp: async () => {
|
||||
await Promise.all([gcsLoggerStream?.cleanUp?.(), httpLogStream?.cleanUp?.()]);
|
||||
await Promise.all([remoteLoggerStream?.cleanUp?.(), httpLogStream?.cleanUp?.()]);
|
||||
},
|
||||
outputStream: transformStream,
|
||||
logBuffer,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BuildContext, GCS } from '@expo/build-tools';
|
||||
import { BuildContext, type SignedUrl, uploadWithSignedUrl } from '@expo/build-tools';
|
||||
import { ArchiveSourceType, errors } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
@@ -42,7 +42,7 @@ export async function uploadApplicationArchiveAsync(
|
||||
const { localPath, suffix, size } = await prepareArtifactsForUploadAsync(logger, artifactPaths);
|
||||
const filename = `application-${buildId}${suffix}`;
|
||||
|
||||
let uploadSession: GCS.SignedUrl | null = null;
|
||||
let uploadSession: SignedUrl | null = null;
|
||||
|
||||
try {
|
||||
// Try to upload to the upload session first.
|
||||
@@ -54,7 +54,7 @@ export async function uploadApplicationArchiveAsync(
|
||||
|
||||
uploadSession = signedUrl;
|
||||
|
||||
await GCS.uploadWithSignedUrl({
|
||||
await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => {
|
||||
return fs.createReadStream(localPath);
|
||||
@@ -98,7 +98,7 @@ export async function uploadBuildArtifactsAsync(
|
||||
const { localPath, suffix, size } = await prepareArtifactsForUploadAsync(logger, artifactPaths);
|
||||
const filename = `artifacts-${buildId}${suffix}`;
|
||||
|
||||
let uploadSession: GCS.SignedUrl | null = null;
|
||||
let uploadSession: SignedUrl | null = null;
|
||||
|
||||
try {
|
||||
// Try to upload to the upload session first.
|
||||
@@ -110,7 +110,7 @@ export async function uploadBuildArtifactsAsync(
|
||||
|
||||
uploadSession = signedUrl;
|
||||
|
||||
await GCS.uploadWithSignedUrl({
|
||||
await uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => {
|
||||
return fs.createReadStream(localPath);
|
||||
@@ -162,7 +162,7 @@ export async function uploadWorkflowArtifactAsync(
|
||||
metadata,
|
||||
});
|
||||
|
||||
await GCS.uploadWithSignedUrl({
|
||||
await uploadWithSignedUrl({
|
||||
signedUrl: uploadSession,
|
||||
srcGeneratorAsync: async () => {
|
||||
return fs.createReadStream(localPath);
|
||||
@@ -249,7 +249,7 @@ async function createUploadSessionAsync(
|
||||
}: { filename: string; name: string; size: number; metadata?: Record<string, unknown> }
|
||||
): Promise<{
|
||||
bucketKey: string;
|
||||
signedUrl: GCS.SignedUrl;
|
||||
signedUrl: SignedUrl;
|
||||
storageType: ArchiveSourceType;
|
||||
artifactId: string | null;
|
||||
}> {
|
||||
|
||||
@@ -2291,14 +2291,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:21.0.2, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:21.0.3, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/downloader": "npm:21.0.0"
|
||||
"@expo/eas-build-job": "npm:21.0.2"
|
||||
"@expo/eas-build-job": "npm:21.0.3"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:21.0.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -2306,7 +2306,7 @@ __metadata:
|
||||
"@expo/repack-app": "npm:~0.6.1"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:21.0.2"
|
||||
"@expo/steps": "npm:21.0.3"
|
||||
"@expo/template-file": "npm:21.0.2"
|
||||
"@expo/turtle-spawn": "npm:21.0.0"
|
||||
"@expo/xcpretty": "npm:^4.3.1"
|
||||
@@ -2547,7 +2547,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:21.0.2, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:21.0.3, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
|
||||
dependencies:
|
||||
@@ -2566,12 +2566,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:21.0.2, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:21.0.3, @expo/eas-json@workspace:packages/eas-json":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-json@workspace:packages/eas-json"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.23.5"
|
||||
"@expo/eas-build-job": "npm:21.0.2"
|
||||
"@expo/eas-build-job": "npm:21.0.3"
|
||||
"@tsconfig/node20": "npm:20.1.8"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -2884,11 +2884,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:21.0.2, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:21.0.3, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
"@expo/eas-build-job": "npm:21.0.2"
|
||||
"@expo/eas-build-job": "npm:21.0.3"
|
||||
"@expo/logger": "npm:21.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
@@ -2957,16 +2957,16 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:21.0.2"
|
||||
"@expo/build-tools": "npm:21.0.3"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/downloader": "npm:21.0.0"
|
||||
"@expo/eas-build-job": "npm:21.0.2"
|
||||
"@expo/eas-build-job": "npm:21.0.3"
|
||||
"@expo/logger": "npm:21.0.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:21.0.2"
|
||||
"@expo/steps": "npm:21.0.3"
|
||||
"@expo/template-file": "npm:21.0.2"
|
||||
"@expo/turtle-spawn": "npm:21.0.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
@@ -9722,7 +9722,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:21.0.2"
|
||||
"@expo/steps": "npm:21.0.3"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
@@ -10294,8 +10294,8 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:21.0.2"
|
||||
"@expo/eas-build-job": "npm:21.0.2"
|
||||
"@expo/build-tools": "npm:21.0.3"
|
||||
"@expo/eas-build-job": "npm:21.0.3"
|
||||
"@expo/logger": "npm:21.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:21.0.0"
|
||||
@@ -10352,8 +10352,8 @@ __metadata:
|
||||
"@expo/code-signing-certificates": "npm:0.0.5"
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/eas-build-job": "npm:21.0.2"
|
||||
"@expo/eas-json": "npm:21.0.2"
|
||||
"@expo/eas-build-job": "npm:21.0.3"
|
||||
"@expo/eas-json": "npm:21.0.3"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:21.0.0"
|
||||
@@ -10368,7 +10368,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:21.0.2"
|
||||
"@expo/steps": "npm:21.0.3"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:6.3.1"
|
||||
"@graphql-codegen/introspection": "npm:5.0.2"
|
||||
|
||||
Reference in new issue
Block a user