diff --git a/CHANGELOG.md b/CHANGELOG.md index 423d34f2ac..9e0774b66c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ This is the log of notable changes to EAS CLI and related packages. - [build-tools] Normalize extracted project permissions so source archives cannot leave files unreadable by EAS workers. ([#4287](https://github.com/expo/eas-cli/pull/4287) by [@AbbanMustafa](https://github.com/AbbanMustafa)) - [eas-cli] Add dynamic retries to deploy command that scale up more generously, depending on deployment size ([#4299](https://github.com/expo/eas-cli/pull/4299) by [@kitten](https://github.com/kitten)) +- [eas-cli] Print the session token in simulator preview links, so they still open once the preview is gated. ([#4312](https://github.com/expo/eas-cli/pull/4312) by [@gwdp](https://github.com/gwdp)) ### ๐Ÿงน Chores diff --git a/packages/eas-cli/graphql.schema.json b/packages/eas-cli/graphql.schema.json index 9e51276644..29af5bcf59 100644 --- a/packages/eas-cli/graphql.schema.json +++ b/packages/eas-cli/graphql.schema.json @@ -86642,6 +86642,18 @@ }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "webPreviewToken", + "description": "Session token gating the web preview. Null when the preview runs ungated.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index f17ab5a7b6..92d835b868 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -12274,6 +12274,8 @@ export type WebNotificationUpdateReadStateInput = { export type WebPreviewOnlyRunSessionRemoteConfig = { __typename?: 'WebPreviewOnlyRunSessionRemoteConfig'; + /** Session token gating the web preview. Null when the preview runs ungated. */ + webPreviewToken?: Maybe; webPreviewUrl: Scalars['String']['output']; }; @@ -15298,11 +15300,11 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{ export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, name?: string | null, tags: Array, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, updatedAt: any, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, artifacts: Array<{ __typename?: 'DeviceRunSessionArtifact', id: string, name: string, filename: string, downloadUrl: string, fileSizeBytes?: number | null, metadata?: any | null, createdAt: any, updatedAt: any }>, remoteConfig?: - | { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null } - | { __typename: 'AppiumRunSessionRemoteConfig', appiumUrl: string, capabilities: any, webPreviewUrl?: string | null } - | { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null } - | { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string } - | { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', previewUrl: string } + | { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null, webPreviewToken?: string | null } + | { __typename: 'AppiumRunSessionRemoteConfig', appiumUrl: string, capabilities: any, webPreviewUrl?: string | null, webPreviewToken?: string | null } + | { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null, webPreviewToken?: string | null } + | { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string, previewToken?: string | null } + | { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', previewUrl: string, previewToken?: string | null } | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } }; export type DeviceRunSessionsByAppIdQueryVariables = Exact<{ diff --git a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts index 19d53b8247..6842a1188b 100644 --- a/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts +++ b/packages/eas-cli/src/graphql/queries/DeviceRunSessionQuery.ts @@ -87,22 +87,27 @@ export const DeviceRunSessionQuery = { agentDeviceRemoteSessionUrl agentDeviceRemoteSessionToken webPreviewUrl + webPreviewToken } ... on ArgentRunSessionRemoteConfig { toolsUrl toolsAuthToken webPreviewUrl + webPreviewToken } ... on AppiumRunSessionRemoteConfig { appiumUrl capabilities webPreviewUrl + webPreviewToken } ... on ServeSimRunSessionRemoteConfig { previewUrl + previewToken } ... on WebPreviewOnlyRunSessionRemoteConfig { previewUrl: webPreviewUrl + previewToken: webPreviewToken } } turtleJobRun { diff --git a/packages/eas-cli/src/simulator/__tests__/utils.test.ts b/packages/eas-cli/src/simulator/__tests__/utils.test.ts index 9bf0cb7cac..dc3cc57e41 100644 --- a/packages/eas-cli/src/simulator/__tests__/utils.test.ts +++ b/packages/eas-cli/src/simulator/__tests__/utils.test.ts @@ -3,8 +3,11 @@ import { DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE, DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE, DEVICE_RUN_SESSION_TYPE_FLAG_VALUES, + EAS_SIMULATOR_WAITLIST_URL, deviceRunSessionTypeToFlagValue, + formatPreviewUrl, formatRemoteSessionInstructions, + formatSimulatorUnavailableMessage, getRemoteSessionEnvironmentVariables, } from '../utils'; @@ -73,3 +76,158 @@ describe('simulator resource class flags', () => { ); }); }); + +describe(formatPreviewUrl, () => { + it('appends the session token for a gated preview', () => { + expect(formatPreviewUrl('https://preview.example.test', 'tok-1')).toBe( + 'https://preview.example.test/?token=tok-1' + ); + }); + + it('leaves the url alone when the preview is ungated', () => { + expect(formatPreviewUrl('https://preview.example.test', null)).toBe( + 'https://preview.example.test' + ); + expect(formatPreviewUrl('https://preview.example.test', undefined)).toBe( + 'https://preview.example.test' + ); + }); +}); + +describe('gated preview links', () => { + const PREVIEW = 'https://preview.example.test'; + const GATED = `${PREVIEW}/?token=tok-1`; + + it('prints the tokenized preview for a serve-sim session', () => { + const instructions = formatRemoteSessionInstructions( + { + __typename: 'ServeSimRunSessionRemoteConfig' as const, + previewUrl: PREVIEW, + previewToken: 'tok-1', + }, + 'env' + ); + + expect(instructions).toContain(GATED); + }); + + it('prints the tokenized preview for a web-preview-only session', () => { + const instructions = formatRemoteSessionInstructions( + { + __typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const, + previewUrl: PREVIEW, + previewToken: 'tok-1', + }, + 'env' + ); + + expect(instructions).toContain(GATED); + }); + + it('prints the tokenized preview for every controller session type', () => { + const controllers = [ + { + __typename: 'AgentDeviceRunSessionRemoteConfig' as const, + agentDeviceRemoteSessionUrl: 'https://daemon.example.test', + agentDeviceRemoteSessionToken: 'daemon-token', + webPreviewUrl: PREVIEW, + webPreviewToken: 'tok-1', + }, + { + __typename: 'ArgentRunSessionRemoteConfig' as const, + toolsUrl: 'https://argent.example.test', + toolsAuthToken: 'argent-token', + webPreviewUrl: PREVIEW, + webPreviewToken: 'tok-1', + }, + { ...iosAppiumConfig, webPreviewToken: 'tok-1' }, + ]; + + for (const remoteConfig of controllers) { + expect(formatRemoteSessionInstructions(remoteConfig, 'env')).toContain(GATED); + } + }); + + it('prints the plain url when the preview is ungated', () => { + const instructions = formatRemoteSessionInstructions( + { __typename: 'ServeSimRunSessionRemoteConfig' as const, previewUrl: PREVIEW }, + 'env' + ); + + expect(instructions).toContain(PREVIEW); + expect(instructions).not.toContain('token='); + }); +}); + +describe('preview-only session environment', () => { + it.each([ + { __typename: 'ServeSimRunSessionRemoteConfig' as const, previewUrl: 'https://p.example.test' }, + { + __typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const, + previewUrl: 'https://p.example.test', + }, + ])('has no controller variables for $__typename', remoteConfig => { + expect(getRemoteSessionEnvironmentVariables(remoteConfig)).toEqual({}); + }); +}); + +describe('Argent session without a tools token', () => { + const untokenizedArgent = { + __typename: 'ArgentRunSessionRemoteConfig' as const, + toolsUrl: 'https://argent.example.test', + webPreviewUrl: null, + }; + + it('omits the auth variable', () => { + expect(getRemoteSessionEnvironmentVariables(untokenizedArgent)).toEqual({ + ARGENT_TOOLS_URL: 'https://argent.example.test', + }); + }); + + it('omits the --token flag from the link command', () => { + const instructions = formatRemoteSessionInstructions(untokenizedArgent, 'dotenv'); + + expect(instructions).toContain("argent link 'https://argent.example.test'"); + expect(instructions).not.toContain('--token'); + }); +}); + +describe('dotenv instructions', () => { + it('tells an agent-device session to use simulator:exec', () => { + const instructions = formatRemoteSessionInstructions( + { + __typename: 'AgentDeviceRunSessionRemoteConfig' as const, + agentDeviceRemoteSessionUrl: 'https://daemon.example.test', + agentDeviceRemoteSessionToken: 'daemon-token', + webPreviewUrl: null, + }, + 'dotenv' + ); + + expect(instructions).toContain('eas simulator:exec npx agent-device '); + }); + + it('tells an argent session to link its local client', () => { + const instructions = formatRemoteSessionInstructions( + { + __typename: 'ArgentRunSessionRemoteConfig' as const, + toolsUrl: 'https://argent.example.test', + toolsAuthToken: 'argent-token', + webPreviewUrl: null, + }, + 'dotenv' + ); + + expect(instructions).toContain('link your local Argent client'); + expect(instructions).toContain("--token 'argent-token'"); + }); +}); + +describe(formatSimulatorUnavailableMessage, () => { + it('names the account and points at the waitlist', () => { + const message = formatSimulatorUnavailableMessage('acme'); + + expect(message).toContain('acme'); + expect(message).toContain(EAS_SIMULATOR_WAITLIST_URL); + }); +}); diff --git a/packages/eas-cli/src/simulator/utils.ts b/packages/eas-cli/src/simulator/utils.ts index 2dec24ee17..475f9e002a 100644 --- a/packages/eas-cli/src/simulator/utils.ts +++ b/packages/eas-cli/src/simulator/utils.ts @@ -86,6 +86,19 @@ export function getRemoteSessionEnvironmentVariables( type RemoteSessionInstructionsConfigType = 'env' | 'dotenv'; +/** + * Preview link for a session. A gated serve-sim needs the session token, and a browser cannot send + * a header on a page load, so it rides the query. serve-sim swaps it for a cookie on the first load. + */ +export function formatPreviewUrl(url: string, token: string | null | undefined): string { + if (!token) { + return url; + } + const withToken = new URL(url); + withToken.searchParams.set('token', token); + return withToken.toString(); +} + export function formatRemoteSessionInstructions( remoteConfig: DeviceRunSessionRemoteConfig, configType: RemoteSessionInstructionsConfigType @@ -112,7 +125,7 @@ export function formatRemoteSessionInstructions( '', '๐ŸŒ Open the following URL in your browser to preview the simulator:', '', - remoteConfig.webPreviewUrl + formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken) ); } return lines.join('\n'); @@ -150,7 +163,7 @@ export function formatRemoteSessionInstructions( '', '๐ŸŒ Open the following URL in your browser to preview the simulator:', '', - remoteConfig.webPreviewUrl + formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken) ); } return lines.join('\n'); @@ -174,7 +187,12 @@ export function formatRemoteSessionInstructions( ' [args...]', ]; if (remoteConfig.webPreviewUrl) { - lines.push('', 'Open the simulator preview:', '', remoteConfig.webPreviewUrl); + lines.push( + '', + 'Open the simulator preview:', + '', + formatPreviewUrl(remoteConfig.webPreviewUrl, remoteConfig.webPreviewToken) + ); } return lines.join('\n'); } @@ -182,13 +200,13 @@ export function formatRemoteSessionInstructions( return [ '๐ŸŒ Open the following URL in your browser to access the simulator:', '', - remoteConfig.previewUrl, + formatPreviewUrl(remoteConfig.previewUrl, remoteConfig.previewToken), ].join('\n'); case 'WebPreviewOnlyRunSessionRemoteConfig': return [ '๐ŸŒ Open the following URL in your browser to access the simulator:', '', - remoteConfig.previewUrl, + formatPreviewUrl(remoteConfig.previewUrl, remoteConfig.previewToken), ].join('\n'); } }