Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages.

- [eas-cli] Non-interactive iOS App Store and Enterprise builds can now use the App Store Connect API key stored in EAS credentials as a submission key to validate and repair provisioning profiles on Apple servers, without requiring `EXPO_ASC_*` environment variables or an interactive Apple login. ([#3805](https://github.com/expo/eas-cli/pull/3805) by [@sswrk](https://github.com/sswrk))
- [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

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
81 changes: 81 additions & 0 deletions packages/eas-cli/src/commands/integrations/posthog/dashboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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<void> {
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;
}

// Non-interactive (CI / agents): print the URL instead of launching a browser.
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;
}
}
}
Loading