Skip to content
Closed
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
111 changes: 111 additions & 0 deletions src/programs/__tests__/api-key-login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { apiKeyCredentials, resolveApiKeyLogin } from '../api-key-login';
import { detectRegion } from '@utils/urls';
import { fetchProjectData, fetchUserData } from '@shared/api';

vi.mock('@utils/urls', () => ({
detectRegion: vi.fn(),
getHost: (r: string) => `https://${r}.posthog.com`,
getCloudUrl: (r: string) => `https://${r}.posthog.com`,
getUiHostFromHost: (host: string) => host,
resolveBaseUrl: (baseUrl?: string) => baseUrl,
}));
vi.mock('@shared/api', () => ({
fetchProjectData: vi.fn(),
fetchUserData: vi.fn(),
}));
vi.mock('@utils/analytics', () => ({
analytics: {
identifyUser: vi.fn(),
captureException: vi.fn(),
setTag: vi.fn(),
},
}));

const mockedDetect = detectRegion as unknown as ReturnType<typeof vi.fn>;
const mockedFetchProject = fetchProjectData as unknown as ReturnType<
typeof vi.fn
>;
const mockedFetchUser = fetchUserData as unknown as ReturnType<typeof vi.fn>;

const project = {
id: 123,
uuid: '00000000-0000-0000-0000-000000000000',
organization: '11111111-1111-1111-1111-111111111111',
api_token: 'phc_test',
name: 'Test Project',
};

describe('resolveApiKeyLogin CI region', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedFetchProject.mockResolvedValue(project);
mockedFetchUser.mockResolvedValue({
distinct_id: 'user-1',
role_at_organization: null,
});
});

it('uses the provided region and never probes @me for it', async () => {
const result = await resolveApiKeyLogin('phx_test', {
projectId: 123,
region: 'eu',
});

// The flaky region probe must not run when the region was handed in.
expect(mockedDetect).not.toHaveBeenCalled();
// And the project is fetched from the given region's cloud.
expect(mockedFetchProject).toHaveBeenCalledWith(
'phx_test',
123,
'https://eu.posthog.com',
);
expect(result.posthog.host.region).toBe('eu');
});

it('reports the CI login line once', async () => {
const onInfo = vi.fn();
await resolveApiKeyLogin('phx_test', {
projectId: 123,
region: 'eu',
onInfo,
});
expect(onInfo).toHaveBeenCalledOnce();
});

it('falls back to detection only when no region is provided', async () => {
mockedDetect.mockResolvedValue('us');

await resolveApiKeyLogin('phx_test', { projectId: 123 });

expect(mockedDetect).toHaveBeenCalledTimes(1);
});
});

describe('apiKeyCredentials', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('logs in once, and logs in again after a failed login', async () => {
mockedFetchUser.mockResolvedValue({
distinct_id: 'user-1',
role_at_organization: null,
});
mockedFetchProject
.mockRejectedValueOnce(new Error('503 transient'))
.mockResolvedValue(project);
const provider = apiKeyCredentials('phx_test', {
projectId: 123,
region: 'eu',
});
const context = { signal: new AbortController().signal };

await expect(provider.resolve('metrics', context)).rejects.toThrow(
'503 transient',
);
const login = await provider.resolve('metrics', context);
expect(login.posthog.projectId).toBe(123);
await provider.resolve('metrics', context);
expect(mockedFetchProject).toHaveBeenCalledTimes(2);
});
});
71 changes: 71 additions & 0 deletions src/programs/__tests__/login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
vi.mock(import('@utils/analytics'), () => ({
analytics: {
wizardCapture: vi.fn(),
setTag: vi.fn(),
identifyUser: vi.fn(),
setGroups: vi.fn(),
} as never,
groupsFromUser: vi.fn(() => ({})),
}));

import type { ApiUser } from '@shared/api';
import { HostResolution } from '@shared/host-resolution';
import { analytics } from '@utils/analytics';
import { logIn } from '../login';
import { SessionStore } from '../session/session-store';
import { buildSession } from '../session/wizard-session';

const apiUser = { role_at_organization: 'engineering' } as ApiUser;
const login = {
posthog: {
accessToken: 'phx_test',
projectApiKey: 'phc_test',
projectId: 7,
host: HostResolution.fromRegion('us'),
},
project: null,
apiUser,
};
const never = new AbortController().signal;

describe('logIn', () => {
beforeEach(() => vi.clearAllMocks());

it("records a provider's login once, with no auth-complete event of its own", async () => {
const store = new SessionStore(buildSession({ installDir: '/project' }));
const resolve = vi.fn(() => Promise.resolve(login));
await logIn('metrics', store, { provider: { resolve }, signal: never });
expect(store.session.credentials).toEqual(login.posthog);
expect(store.session.apiUser).toBe(apiUser);
expect(store.session.roleAtOrganization).toBe('engineering');
// The OAuth login reports `auth complete`; a key login is not a user signing in.
expect(analytics.wizardCapture).not.toHaveBeenCalled();

// A second call in the same store reuses the login.
await logIn('metrics', store, { provider: { resolve }, signal: never });
expect(resolve).toHaveBeenCalledOnce();
});

it("puts a CI run's pre-issued gateway token on the login", async () => {
const store = new SessionStore(buildSession({ installDir: '/project' }));
const gateway = { token: 'gw_test', url: 'http://localhost:8080' };
store.update({ ciGateway: gateway });
const result = await logIn('metrics', store, {
credentials: login,
signal: never,
});
expect(result.posthog.gateway).toEqual(gateway);
expect(store.session.credentials?.gateway).toEqual(gateway);
});

it("keeps the store's role when the gateway token rewrites a held login", async () => {
const store = new SessionStore(buildSession({ installDir: '/project' }));
store.setLogin(login);
store.setRoleAtOrganization('product');
store.update({
ciGateway: { token: 'gw_test', url: 'http://localhost:8080' },
});
await logIn('metrics', store, { signal: never });
expect(store.session.roleAtOrganization).toBe('product');
});
});
19 changes: 17 additions & 2 deletions src/programs/__tests__/posthog-cli-preinstall.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ import {
resetPostHogCliPreinstallForTests,
} from '@programs/shared/posthog-cli-preinstall';
import { installOrUpdatePostHogCli } from '@shared/install-cli-steering';
import { Integration } from '@shared/constants';
import { analytics } from '@utils/analytics';
import { SYMBOL_UPLOAD_CLI_FRAMEWORKS } from '@programs/error-tracking';
import { VARIANTS_REQUIRING_POSTHOG_CLI } from '@programs/error-tracking-upload-source-maps';

vi.mock('@shared/install-cli-steering', () => ({
vi.mock(import('@shared/install-cli-steering'), () => ({
installOrUpdatePostHogCli: vi.fn(),
}));
vi.mock('@utils/analytics', () => ({
analytics: { wizardCapture: vi.fn(), captureException: vi.fn() },
}));
const log = { warn: vi.fn() };
const log = { info: vi.fn(), warn: vi.fn() };

describe('preinstallPostHogCliOnce', () => {
beforeEach(() => {
Expand Down Expand Up @@ -71,3 +74,15 @@ describe('preinstallPostHogCliOnce', () => {
expect(log.warn).not.toHaveBeenCalled();
});
});

describe('the two programs that pre-install posthog-cli', () => {
test('error tracking matches the source-maps program set, keyed by Integration', () => {
// Both programs pre-install the CLI for the same platforms. The source-maps
// program keys them by uploader variant, and only `ios` is spelled
// differently (`swift` in Integration).
const expected = [...VARIANTS_REQUIRING_POSTHOG_CLI]
.map((variant) => (variant === 'ios' ? Integration.swift : variant))
.sort();
expect([...SYMBOL_UPLOAD_CLI_FRAMEWORKS].sort()).toEqual(expected);
});
});
124 changes: 71 additions & 53 deletions src/programs/__tests__/program-registry.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
import {
PROGRAM_REGISTRY,
agentSkillConfig,
getCommandPath,
getLaunchablePrograms,
getProgramConfig,
getSubcommandPrograms,
} from '../program-registry';
import type { WizardSession } from '@programs/session/wizard-session';
import { testRunnerContext } from '../../../test/runner-context';
import { DEFAULT_BINDING } from '@agent';
import {
DEFAULT_AGENT_MODEL,
GPT5_6_SOL_MODEL,
GPT5_6_TERRA_MODEL,
Harness,
Sequence,
} from '@shared/constants';
import { config as agentSkill } from '@programs/agent-skill';
import { testRunnerContext } from '../shared/__tests__/runner-context.no-jest';
import type { WizardSession } from '../session/wizard-session';

describe('PROGRAM_REGISTRY', () => {
it('every entry has unique id, description, and non-empty steps', () => {
it('every entry has a unique id and a description', () => {
const ids = PROGRAM_REGISTRY.map((c) => c.id);
expect(new Set(ids).size).toBe(ids.length);

for (const config of PROGRAM_REGISTRY) {
expect(config.description).toBeTruthy();
expect(config.steps.length).toBeGreaterThan(0);
}
});
});
Expand All @@ -32,6 +38,54 @@ describe('getProgramConfig', () => {
});
});

// A binding sets a program's sequence, harness, model and effort, so an edit
// changes the cost and output of every run of it.
describe('program bindings', () => {
const linearOnSol = {
sequence: Sequence.linear,
harness: Harness.pi,
model: GPT5_6_SOL_MODEL,
thinkingLevel: 'medium',
};
const orchestratorOnPi = {
sequence: Sequence.orchestrator,
harness: Harness.pi,
model: DEFAULT_AGENT_MODEL,
};

it('routes every program, and the default for one with no binding', () => {
const routes = Object.fromEntries(
PROGRAM_REGISTRY.map((c) => [c.id, c.binding ?? DEFAULT_BINDING]),
);
expect(routes).toEqual({
'posthog-integration': linearOnSol,
'mcp-analytics': linearOnSol,
'replay-vision': {
sequence: Sequence.orchestrator,
harness: Harness.anthropic,
model: DEFAULT_AGENT_MODEL,
},
'ai-observability': {
sequence: Sequence.linear,
harness: Harness.pi,
model: GPT5_6_TERRA_MODEL,
thinkingLevel: 'high',
},
metrics: orchestratorOnPi,
audit: linearOnSol,
'events-audit': linearOnSol,
'web-analytics-doctor': linearOnSol,
migration: linearOnSol,
'revenue-analytics-setup': linearOnSol,
'warehouse-source': linearOnSol,
'self-driving': linearOnSol,
'error-tracking-upload-source-maps': linearOnSol,
'error-tracking': orchestratorOnPi,
'agent-skill': linearOnSol,
});
});
});

describe('getSubcommandPrograms', () => {
it('returns only programs that have a CLI command', () => {
const subcommands = getSubcommandPrograms();
Expand Down Expand Up @@ -60,36 +114,9 @@ describe('getCommandPath', () => {
'revenue-analytics',
);
});
});

describe('getLaunchablePrograms', () => {
// The list is curated, so an id that stops matching drops its row in silence.
it("offers the intro's programs, in order, all resolving", () => {
expect(getLaunchablePrograms().map((config) => config.id)).toEqual([
'self-driving',
'error-tracking-upload-source-maps',
'warehouse-source',
'audit',
'posthog-doctor',
'mcp-analytics',
'replay-vision',
'ai-observability',
'metrics',
'revenue-analytics-setup',
]);
});

// A row wider than the terminal stops the whole block from centering.
it('keeps every row inside an 80-column terminal', () => {
const COMMAND_COLUMN = 21;
const MARKER_PREFIX = 2;
const BUDGET = 80 - COMMAND_COLUMN - MARKER_PREFIX;

const tooLong = getLaunchablePrograms()
.filter((config) => config.description.length > BUDGET)
.map((config) => `${config.id} (${config.description.length})`);

expect(tooLong).toEqual([]);
it('keeps `metrics` a flat command', () => {
expect(getCommandPath(subcommand('metrics'))).toBe('metrics');
});
});

Expand Down Expand Up @@ -121,30 +148,21 @@ describe('parentCommand nesting', () => {
});
});

describe('agentSkillConfig run recipe', () => {
// Regression guard: `agentSkillConfig` backs `wizard skill <name>` and the
// narrow `audit` leaves. The runner skips the agent entirely when a config
// has no `run` (run-wizard.ts `skipAgent`), so a missing recipe means those
// commands silently no-op instead of running the skill.
it('defines a run recipe so the agent is not skipped', () => {
expect(agentSkillConfig.run).toBeDefined();
});

describe('agent-skill run recipe', () => {
// Regression guard: the agent-skill config backs `wizard skill <name>` and the
// narrow `audit` leaves. runProgram fails with "has no run configuration"
// when a config has no `run`, so a missing recipe means those commands fail
// instead of running the skill.
it('derives run metadata from the dispatched skillId', async () => {
expect(typeof agentSkillConfig.run).toBe('function');
expect(typeof agentSkill.run).toBe('function');
const session = { skillId: 'audit-events' } as unknown as WizardSession;
const run =
typeof agentSkillConfig.run === 'function'
? await agentSkillConfig.run(session, testRunnerContext())
: agentSkillConfig.run!;
typeof agentSkill.run === 'function'
? await agentSkill.run(session, testRunnerContext())
: agentSkill.run!;

expect(run.skillId).toBe('audit-events');
expect(run.integrationLabel).toBe('audit-events');
expect(run.reportFile).toContain('audit-events');
// Fields the runner relies on to render the run + outro.
expect(run.spinnerMessage).toBeTruthy();
expect(run.successMessage).toBeTruthy();
expect(run.docsUrl).toBeTruthy();
expect(run.estimatedDurationMinutes).toBeGreaterThan(0);
});
});
Loading
Loading