From 598f59e690a31f20ac36bb5c9e3cd3c9dbc3f5df Mon Sep 17 00:00:00 2001 From: Gabe Debes Date: Mon, 8 Jun 2026 13:58:47 -0700 Subject: [PATCH] [eas-cli] Add integrations:posthog:dashboard --- CHANGELOG.md | 1 + packages/eas-cli/src/commandUtils/posthog.ts | 7 + .../posthog/__tests__/dashboard.test.ts | 160 ++++++++++++++++++ .../integrations/posthog/dashboard.ts | 80 +++++++++ 4 files changed, 248 insertions(+) create mode 100644 packages/eas-cli/src/commands/integrations/posthog/__tests__/dashboard.test.ts create mode 100644 packages/eas-cli/src/commands/integrations/posthog/dashboard.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 758d09d632..fb350d096b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features - [eas-cli] Add `eas integrations:posthog:connect` command. ([#3836](https://github.com/expo/eas-cli/pull/3836) by [@gwdp](https://github.com/gwdp)) +- [eas-cli] Add `eas integrations:posthog:dashboard` command. ([#3837](https://github.com/expo/eas-cli/pull/3837) by [@gwdp](https://github.com/gwdp)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/src/commandUtils/posthog.ts b/packages/eas-cli/src/commandUtils/posthog.ts index 974964aee9..bfcfba49bd 100644 --- a/packages/eas-cli/src/commandUtils/posthog.ts +++ b/packages/eas-cli/src/commandUtils/posthog.ts @@ -1,6 +1,13 @@ +import chalk from 'chalk'; + import { PostHogProjectData } from '../graphql/types/PostHogConnection'; +import Log from '../log'; export function getPostHogProjectDashboardUrl(project: PostHogProjectData): string { const host = project.posthogHost.replace(/\/$/, ''); return `${host}/project/${encodeURIComponent(project.posthogProjectIdentifier)}`; } + +export function logNoPostHogProject(projectName: string): void { + Log.warn(`No PostHog project is linked to Expo app ${chalk.bold(projectName)} on EAS.`); +} diff --git a/packages/eas-cli/src/commands/integrations/posthog/__tests__/dashboard.test.ts b/packages/eas-cli/src/commands/integrations/posthog/__tests__/dashboard.test.ts new file mode 100644 index 0000000000..14e6742e02 --- /dev/null +++ b/packages/eas-cli/src/commands/integrations/posthog/__tests__/dashboard.test.ts @@ -0,0 +1,160 @@ +import openBrowserAsync from 'better-opn'; + +import { getMockOclifConfig } from '../../../../__tests__/commands/utils'; +import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient'; +import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants'; +import { PostHogRegion } from '../../../../graphql/generated'; +import { PostHogQuery } from '../../../../graphql/queries/PostHogQuery'; +import { + PostHogOrganizationConnectionData, + PostHogProjectData, +} from '../../../../graphql/types/PostHogConnection'; +import Log from '../../../../log'; +import { ora } from '../../../../ora'; +import { printJsonOnlyOutput } from '../../../../utils/json'; +import IntegrationsPostHogDashboard from '../dashboard'; + +jest.mock('better-opn'); +jest.mock('../../../../graphql/queries/PostHogQuery'); +jest.mock('../../../../log'); +jest.mock('../../../../utils/json'); +jest.mock('../../../../ora', () => ({ + ora: jest.fn(() => ({ + start: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + })), +})); + +describe(IntegrationsPostHogDashboard, () => { + const graphqlClient = {} as ExpoGraphqlClient; + const mockConfig = getMockOclifConfig(); + + const mockConnection: PostHogOrganizationConnectionData = { + id: 'connection-1', + posthogOrganizationIdentifier: 'org-123', + posthogOrganizationName: 'Test Org', + posthogRegion: PostHogRegion.Us, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + const mockProject: PostHogProjectData = { + id: 'project-1', + posthogProjectIdentifier: 'res-123', + posthogProjectName: 'Test Project', + posthogProjectToken: 'phc_public_key', + posthogHost: 'https://us.posthog.com', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + posthogOrganizationConnection: mockConnection, + }; + + function createCommand(argv: string[] = []): IntegrationsPostHogDashboard { + const command = new IntegrationsPostHogDashboard(argv, mockConfig); + jest.spyOn(command as any, 'getContextAsync').mockReturnValue({ + privateProjectConfig: { + projectId: testProjectId, + exp: { slug: 'testapp' }, + }, + loggedIn: { graphqlClient }, + } as never); + return command; + } + + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(Log, 'warn').mockImplementation(() => {}); + jest.spyOn(Log, 'log').mockImplementation(() => {}); + jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(mockProject); + jest.mocked(openBrowserAsync).mockResolvedValue({} as never); + jest.mocked(ora).mockReturnValue({ + start: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + } as any); + }); + + it('opens the linked PostHog project dashboard', async () => { + await createCommand().runAsync(); + + expect(openBrowserAsync).toHaveBeenCalledWith('https://us.posthog.com/project/res-123'); + }); + + it('normalizes a trailing-slash host and percent-encodes the project identifier', async () => { + jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue({ + ...mockProject, + posthogHost: 'https://us.posthog.com/', + posthogProjectIdentifier: 'team a/b', + }); + + await createCommand().runAsync(); + + expect(openBrowserAsync).toHaveBeenCalledWith('https://us.posthog.com/project/team%20a%2Fb'); + }); + + it('logs an empty state when no project link exists', async () => { + jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null); + + await createCommand().runAsync(); + + expect(openBrowserAsync).not.toHaveBeenCalled(); + expect(Log.warn).toHaveBeenCalledWith( + expect.stringContaining('No PostHog project is linked to Expo app') + ); + }); + + it('fails the spinner when the browser cannot be opened', async () => { + jest.mocked(openBrowserAsync).mockResolvedValue(false); + const spinner = { + start: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + }; + jest.mocked(ora).mockReturnValue(spinner as any); + + await createCommand().runAsync(); + + expect(spinner.fail).toHaveBeenCalledWith( + expect.stringContaining('Unable to open a web browser') + ); + }); + + it('prints the dashboard URL in non-interactive mode without opening a browser', async () => { + await createCommand(['--non-interactive']).runAsync(); + + expect(openBrowserAsync).not.toHaveBeenCalled(); + expect(Log.log).toHaveBeenCalledWith('https://us.posthog.com/project/res-123'); + }); + + it('emits the dashboard URL as JSON with --json', async () => { + await createCommand(['--json']).runAsync(); + + expect(openBrowserAsync).not.toHaveBeenCalled(); + expect(printJsonOnlyOutput).toHaveBeenCalledWith({ + dashboardUrl: 'https://us.posthog.com/project/res-123', + }); + }); + + it('emits a null dashboardUrl as JSON when no project is linked', async () => { + jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null); + + await createCommand(['--json']).runAsync(); + + expect(openBrowserAsync).not.toHaveBeenCalled(); + expect(printJsonOnlyOutput).toHaveBeenCalledWith({ dashboardUrl: null }); + }); + + it('fails the spinner and rethrows when opening the browser throws', async () => { + jest.mocked(openBrowserAsync).mockRejectedValue(new Error('no browser')); + const spinner = { + start: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis(), + }; + jest.mocked(ora).mockReturnValue(spinner as any); + + await expect(createCommand().runAsync()).rejects.toThrow('no browser'); + expect(spinner.fail).toHaveBeenCalled(); + }); +}); diff --git a/packages/eas-cli/src/commands/integrations/posthog/dashboard.ts b/packages/eas-cli/src/commands/integrations/posthog/dashboard.ts new file mode 100644 index 0000000000..9911de7873 --- /dev/null +++ b/packages/eas-cli/src/commands/integrations/posthog/dashboard.ts @@ -0,0 +1,80 @@ +import openBrowserAsync from 'better-opn'; + +import EasCommand from '../../../commandUtils/EasCommand'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../../../commandUtils/flags'; +import { getPostHogProjectDashboardUrl, logNoPostHogProject } from '../../../commandUtils/posthog'; +import { PostHogQuery } from '../../../graphql/queries/PostHogQuery'; +import Log from '../../../log'; +import { ora } from '../../../ora'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json'; + +export default class IntegrationsPostHogDashboard extends EasCommand { + static override description = 'open the PostHog dashboard for the linked PostHog project'; + + static override flags = { + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.ProjectConfig, + }; + + async runAsync(): Promise { + const { flags } = await this.parse(IntegrationsPostHogDashboard); + const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + if (jsonFlag) { + enableJsonOutput(); + } + + const { + privateProjectConfig: { projectId, exp }, + loggedIn: { graphqlClient }, + } = await this.getContextAsync(IntegrationsPostHogDashboard, { + nonInteractive, + withServerSideEnvironment: null, + }); + + const posthogProject = await PostHogQuery.getPostHogProjectByAppIdAsync( + graphqlClient, + projectId + ); + + if (!posthogProject) { + if (jsonFlag) { + printJsonOnlyOutput({ dashboardUrl: null }); + } else { + logNoPostHogProject(exp.slug); + } + return; + } + + const dashboardUrl = getPostHogProjectDashboardUrl(posthogProject); + + if (jsonFlag) { + printJsonOnlyOutput({ dashboardUrl }); + return; + } + + if (nonInteractive) { + Log.log(dashboardUrl); + return; + } + + const failedMessage = `Unable to open a web browser. PostHog dashboard is available at: ${dashboardUrl}`; + const spinner = ora(`Opening ${dashboardUrl}`).start(); + try { + const opened = await openBrowserAsync(dashboardUrl); + if (opened) { + spinner.succeed(`Opened ${dashboardUrl}`); + } else { + spinner.fail(failedMessage); + } + } catch (error) { + spinner.fail(failedMessage); + throw error; + } + } +}