Skip to content

Commit 243a048

Browse files
committed
[eas-cli] Add integrations:posthog:dashboard
1 parent 553dccc commit 243a048

3 files changed

Lines changed: 242 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages.
1010

1111
- [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))
1212
- [eas-cli] Add `eas integrations:posthog:connect` command. ([#3836](https://github.com/expo/eas-cli/pull/3836) by [@gwdp](https://github.com/gwdp))
13+
- [eas-cli] Add `eas integrations:posthog:dashboard` command. ([#3837](https://github.com/expo/eas-cli/pull/3837) by [@gwdp](https://github.com/gwdp))
1314

1415
### 🐛 Bug fixes
1516

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import openBrowserAsync from 'better-opn';
2+
3+
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
4+
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
5+
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
6+
import { PostHogRegion } from '../../../../graphql/generated';
7+
import { PostHogQuery } from '../../../../graphql/queries/PostHogQuery';
8+
import {
9+
PostHogOrganizationConnectionData,
10+
PostHogProjectData,
11+
} from '../../../../graphql/types/PostHogConnection';
12+
import Log from '../../../../log';
13+
import { ora } from '../../../../ora';
14+
import { printJsonOnlyOutput } from '../../../../utils/json';
15+
import IntegrationsPostHogDashboard from '../dashboard';
16+
17+
jest.mock('better-opn');
18+
jest.mock('../../../../graphql/queries/PostHogQuery');
19+
jest.mock('../../../../log');
20+
jest.mock('../../../../utils/json');
21+
jest.mock('../../../../ora', () => ({
22+
ora: jest.fn(() => ({
23+
start: jest.fn().mockReturnThis(),
24+
succeed: jest.fn().mockReturnThis(),
25+
fail: jest.fn().mockReturnThis(),
26+
})),
27+
}));
28+
29+
describe(IntegrationsPostHogDashboard, () => {
30+
const graphqlClient = {} as ExpoGraphqlClient;
31+
const mockConfig = getMockOclifConfig();
32+
33+
const mockConnection: PostHogOrganizationConnectionData = {
34+
id: 'connection-1',
35+
posthogOrganizationIdentifier: 'org-123',
36+
posthogOrganizationName: 'Test Org',
37+
posthogRegion: PostHogRegion.Us,
38+
createdAt: '2024-01-01T00:00:00.000Z',
39+
updatedAt: '2024-01-01T00:00:00.000Z',
40+
};
41+
42+
const mockProject: PostHogProjectData = {
43+
id: 'project-1',
44+
posthogProjectIdentifier: 'res-123',
45+
posthogProjectName: 'Test Project',
46+
posthogProjectToken: 'phc_public_key',
47+
posthogHost: 'https://us.posthog.com',
48+
createdAt: '2024-01-01T00:00:00.000Z',
49+
updatedAt: '2024-01-01T00:00:00.000Z',
50+
posthogOrganizationConnection: mockConnection,
51+
};
52+
53+
function createCommand(argv: string[] = []): IntegrationsPostHogDashboard {
54+
const command = new IntegrationsPostHogDashboard(argv, mockConfig);
55+
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
56+
privateProjectConfig: {
57+
projectId: testProjectId,
58+
exp: { slug: 'testapp' },
59+
},
60+
loggedIn: { graphqlClient },
61+
} as never);
62+
return command;
63+
}
64+
65+
beforeEach(() => {
66+
jest.resetAllMocks();
67+
jest.spyOn(Log, 'warn').mockImplementation(() => {});
68+
jest.spyOn(Log, 'log').mockImplementation(() => {});
69+
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(mockProject);
70+
jest.mocked(openBrowserAsync).mockResolvedValue({} as never);
71+
jest.mocked(ora).mockReturnValue({
72+
start: jest.fn().mockReturnThis(),
73+
succeed: jest.fn().mockReturnThis(),
74+
fail: jest.fn().mockReturnThis(),
75+
} as any);
76+
});
77+
78+
it('opens the linked PostHog project dashboard', async () => {
79+
await createCommand().runAsync();
80+
81+
expect(openBrowserAsync).toHaveBeenCalledWith('https://us.posthog.com/project/res-123');
82+
});
83+
84+
it('normalizes a trailing-slash host and percent-encodes the project identifier', async () => {
85+
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue({
86+
...mockProject,
87+
posthogHost: 'https://us.posthog.com/',
88+
posthogProjectIdentifier: 'team a/b',
89+
});
90+
91+
await createCommand().runAsync();
92+
93+
expect(openBrowserAsync).toHaveBeenCalledWith('https://us.posthog.com/project/team%20a%2Fb');
94+
});
95+
96+
it('logs an empty state when no project link exists', async () => {
97+
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
98+
99+
await createCommand().runAsync();
100+
101+
expect(openBrowserAsync).not.toHaveBeenCalled();
102+
expect(Log.warn).toHaveBeenCalledWith(
103+
expect.stringContaining('No PostHog project is linked to Expo app')
104+
);
105+
});
106+
107+
it('fails the spinner when the browser cannot be opened', async () => {
108+
jest.mocked(openBrowserAsync).mockResolvedValue(false);
109+
const spinner = {
110+
start: jest.fn().mockReturnThis(),
111+
succeed: jest.fn().mockReturnThis(),
112+
fail: jest.fn().mockReturnThis(),
113+
};
114+
jest.mocked(ora).mockReturnValue(spinner as any);
115+
116+
await createCommand().runAsync();
117+
118+
expect(spinner.fail).toHaveBeenCalledWith(
119+
expect.stringContaining('Unable to open a web browser')
120+
);
121+
});
122+
123+
it('prints the dashboard URL in non-interactive mode without opening a browser', async () => {
124+
await createCommand(['--non-interactive']).runAsync();
125+
126+
expect(openBrowserAsync).not.toHaveBeenCalled();
127+
expect(Log.log).toHaveBeenCalledWith('https://us.posthog.com/project/res-123');
128+
});
129+
130+
it('emits the dashboard URL as JSON with --json', async () => {
131+
await createCommand(['--json']).runAsync();
132+
133+
expect(openBrowserAsync).not.toHaveBeenCalled();
134+
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
135+
dashboardUrl: 'https://us.posthog.com/project/res-123',
136+
});
137+
});
138+
139+
it('emits a null dashboardUrl as JSON when no project is linked', async () => {
140+
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
141+
142+
await createCommand(['--json']).runAsync();
143+
144+
expect(openBrowserAsync).not.toHaveBeenCalled();
145+
expect(printJsonOnlyOutput).toHaveBeenCalledWith({ dashboardUrl: null });
146+
});
147+
148+
it('fails the spinner and rethrows when opening the browser throws', async () => {
149+
jest.mocked(openBrowserAsync).mockRejectedValue(new Error('no browser'));
150+
const spinner = {
151+
start: jest.fn().mockReturnThis(),
152+
succeed: jest.fn().mockReturnThis(),
153+
fail: jest.fn().mockReturnThis(),
154+
};
155+
jest.mocked(ora).mockReturnValue(spinner as any);
156+
157+
await expect(createCommand().runAsync()).rejects.toThrow('no browser');
158+
expect(spinner.fail).toHaveBeenCalled();
159+
});
160+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import openBrowserAsync from 'better-opn';
2+
3+
import EasCommand from '../../../commandUtils/EasCommand';
4+
import {
5+
EasNonInteractiveAndJsonFlags,
6+
resolveNonInteractiveAndJsonFlags,
7+
} from '../../../commandUtils/flags';
8+
import { getPostHogProjectDashboardUrl, logNoPostHogProject } from '../../../commandUtils/posthog';
9+
import { PostHogQuery } from '../../../graphql/queries/PostHogQuery';
10+
import Log from '../../../log';
11+
import { ora } from '../../../ora';
12+
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
13+
14+
export default class IntegrationsPostHogDashboard extends EasCommand {
15+
static override description = 'open the PostHog dashboard for the linked PostHog project';
16+
17+
static override flags = {
18+
...EasNonInteractiveAndJsonFlags,
19+
};
20+
21+
static override contextDefinition = {
22+
...this.ContextOptions.ProjectConfig,
23+
};
24+
25+
async runAsync(): Promise<void> {
26+
const { flags } = await this.parse(IntegrationsPostHogDashboard);
27+
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
28+
if (jsonFlag) {
29+
enableJsonOutput();
30+
}
31+
32+
const {
33+
privateProjectConfig: { projectId, exp },
34+
loggedIn: { graphqlClient },
35+
} = await this.getContextAsync(IntegrationsPostHogDashboard, {
36+
nonInteractive,
37+
withServerSideEnvironment: null,
38+
});
39+
40+
const posthogProject = await PostHogQuery.getPostHogProjectByAppIdAsync(
41+
graphqlClient,
42+
projectId
43+
);
44+
45+
if (!posthogProject) {
46+
if (jsonFlag) {
47+
printJsonOnlyOutput({ dashboardUrl: null });
48+
} else {
49+
logNoPostHogProject(exp.slug);
50+
}
51+
return;
52+
}
53+
54+
const dashboardUrl = getPostHogProjectDashboardUrl(posthogProject);
55+
56+
if (jsonFlag) {
57+
printJsonOnlyOutput({ dashboardUrl });
58+
return;
59+
}
60+
61+
// Non-interactive (CI / agents): print the URL instead of launching a browser.
62+
if (nonInteractive) {
63+
Log.log(dashboardUrl);
64+
return;
65+
}
66+
67+
const failedMessage = `Unable to open a web browser. PostHog dashboard is available at: ${dashboardUrl}`;
68+
const spinner = ora(`Opening ${dashboardUrl}`).start();
69+
try {
70+
const opened = await openBrowserAsync(dashboardUrl);
71+
if (opened) {
72+
spinner.succeed(`Opened ${dashboardUrl}`);
73+
} else {
74+
spinner.fail(failedMessage);
75+
}
76+
} catch (error) {
77+
spinner.fail(failedMessage);
78+
throw error;
79+
}
80+
}
81+
}

0 commit comments

Comments
 (0)