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
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { execSync } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { makeLoggerMock } from "@test/loggerMock";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("../../utils/logger.js", () => makeLoggerMock());

import type { AuthService } from "../auth/service";
import { EnrichmentService } from "./service";

const stubAuthService = {
getState: vi.fn(),
getValidAccessToken: vi.fn(),
} as unknown as AuthService;

async function writeFile(repoRoot: string, relPath: string, content: string) {
const abs = path.join(repoRoot, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, content);
}

describe("EnrichmentService.detectPosthogInstallState", () => {
let tmp: string;
let service: EnrichmentService;

beforeEach(async () => {
tmp = await fs.mkdtemp(path.join(os.tmpdir(), "posthog-detect-"));
// listAllFiles uses `git ls-files` + `git ls-files -o` under the hood, so
// the repo needs to be a git checkout.
execSync("git init -q", { cwd: tmp, stdio: "pipe" });
service = new EnrichmentService(stubAuthService);
});

afterEach(async () => {
await fs.rm(tmp, { recursive: true, force: true });
service.dispose();
});

it("returns not_installed for an empty repo", async () => {
expect(await service.detectPosthogInstallState(tmp)).toBe("not_installed");
});

it("returns installed_no_init when package.json declares posthog-js but no init call exists", async () => {
await writeFile(
tmp,
"package.json",
JSON.stringify({
name: "test-app",
dependencies: { "posthog-js": "^1.0.0" },
}),
);
expect(await service.detectPosthogInstallState(tmp)).toBe(
"installed_no_init",
);
});

it("returns initialized when an entry-point file calls posthog.init()", async () => {
await writeFile(
tmp,
"package.json",
JSON.stringify({
name: "test-app",
dependencies: { "posthog-js": "^1.0.0" },
}),
);
await writeFile(
tmp,
"pages/_app.tsx",
`import posthog from "posthog-js";\nposthog.init("phc_xxx", { api_host: "https://app.posthog.com" });\nexport default function App() { return null; }\n`,
);
expect(await service.detectPosthogInstallState(tmp)).toBe("initialized");
});

it("returns initialized when posthog is used in a non-standard path (apps/dashboard/src/bootstrap.ts)", async () => {
await writeFile(
tmp,
"package.json",
JSON.stringify({
name: "monorepo-root",
}),
);
await writeFile(
tmp,
"apps/dashboard/package.json",
JSON.stringify({
name: "dashboard",
dependencies: { "posthog-js": "^1.0.0" },
}),
);
await writeFile(
tmp,
"apps/dashboard/src/bootstrap.ts",
`import posthog from "posthog-js";\nposthog.init(import.meta.env.VITE_POSTHOG_KEY);\nposthog.capture("app_loaded");\n`,
);
expect(await service.detectPosthogInstallState(tmp)).toBe("initialized");
});

it("picks up monorepo manifests in subdirectories (apps/api/requirements.txt)", async () => {
await writeFile(tmp, "package.json", JSON.stringify({ name: "monorepo" }));
await writeFile(
tmp,
"apps/api/requirements.txt",
"django==5.0\nposthog==3.5.0\n",
);
expect(await service.detectPosthogInstallState(tmp)).toBe(
"installed_no_init",
);
});

it("returns initialized when a Python entry point uses posthog", async () => {
await writeFile(tmp, "requirements.txt", "posthog==3.5.0\n");
await writeFile(
tmp,
"src/myapp/main.py",
`import os\nimport posthog\n\nposthog.api_key = os.environ["POSTHOG_KEY"]\nposthog.host = "https://app.posthog.com"\nposthog.capture("user-id", "user_signed_up")\n`,
);
expect(await service.detectPosthogInstallState(tmp)).toBe("initialized");
});

it("returns installed_no_init for a Ruby repo with a Gemfile declaring posthog", async () => {
await writeFile(
tmp,
"Gemfile",
`source "https://rubygems.org"\ngem "posthog-ruby"\n`,
);
expect(await service.detectPosthogInstallState(tmp)).toBe(
"installed_no_init",
);
});

it("ignores files inside skip-paths like node_modules when scanning for init calls", async () => {
await writeFile(
tmp,
"package.json",
JSON.stringify({
name: "test-app",
dependencies: { "posthog-js": "^1.0.0" },
}),
);
// A package vendor file containing `posthog.init` should NOT promote the
// repo to "initialized" — only user code counts.
await writeFile(
tmp,
"node_modules/some-other-pkg/dist/index.js",
`posthog.init("phc_xxx");\n`,
);
expect(await service.detectPosthogInstallState(tmp)).toBe(
"installed_no_init",
);
});

// Documents the v1 limitation: detection answers "is PostHog *used*?"
// (any capture / flag / init-with-literal call). A file with init but
// zero usage falls through to `installed_no_init`, which surfaces the
// "Finish wiring" suggestion — appropriate guidance for that state.
it("treats init-only-with-env-var (no capture) as installed_no_init", async () => {
await writeFile(
tmp,
"package.json",
JSON.stringify({ dependencies: { "posthog-js": "^1.0.0" } }),
);
await writeFile(
tmp,
"src/bootstrap.ts",
`import posthog from "posthog-js";\nposthog.init(import.meta.env.VITE_POSTHOG_KEY);\n`,
);
expect(await service.detectPosthogInstallState(tmp)).toBe(
"installed_no_init",
);
});

it("returns not_installed for empty/missing repoPath", async () => {
expect(await service.detectPosthogInstallState("")).toBe("not_installed");
});

it("returns not_installed when the directory isn't a git repo", async () => {
const nonGitDir = await fs.mkdtemp(path.join(os.tmpdir(), "non-git-"));
try {
await writeFile(
nonGitDir,
"package.json",
JSON.stringify({ dependencies: { "posthog-js": "^1.0.0" } }),
);
// listAllFiles throws on non-git dirs; detection bails to not_installed.
expect(await service.detectPosthogInstallState(nonGitDir)).toBe(
"not_installed",
);
} finally {
await fs.rm(nonGitDir, { recursive: true, force: true });
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { execSync } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { makeLoggerMock } from "@test/loggerMock";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("../../utils/logger.js", () => makeLoggerMock());

const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);

import type { AuthService } from "../auth/service";
import { EnrichmentService } from "./service";

function authedStub(): AuthService {
return {
getState: vi.fn(() => ({
status: "authenticated",
projectId: 42,
cloudRegion: "us",
})),
getValidAccessToken: vi.fn(async () => ({
accessToken: "token-x",
apiHost: "https://us.posthog.com",
})),
} as unknown as AuthService;
}

function unauthedStub(): AuthService {
return {
getState: vi.fn(() => ({ status: "unauthenticated" })),
getValidAccessToken: vi.fn(),
} as unknown as AuthService;
}

async function writeFile(repoRoot: string, relPath: string, content: string) {
const abs = path.join(repoRoot, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, content);
}

function lastCalledResponse(rows: Array<[string, string]>) {
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ results: rows }),
};
}

describe("EnrichmentService.findStaleFlagSuggestions", () => {
let tmp: string;
let service: EnrichmentService;

beforeEach(async () => {
tmp = await fs.mkdtemp(path.join(os.tmpdir(), "posthog-stale-"));
execSync("git init -q", { cwd: tmp, stdio: "pipe" });
mockFetch.mockReset();
service = new EnrichmentService(authedStub());
});

afterEach(async () => {
await fs.rm(tmp, { recursive: true, force: true });
service.dispose();
});

it("returns [] when not authenticated", async () => {
service.dispose();
service = new EnrichmentService(unauthedStub());
const out = await service.findStaleFlagSuggestions(tmp);
expect(out).toEqual([]);
expect(mockFetch).not.toHaveBeenCalled();
});

it("returns [] and skips the API call when no flags are referenced in code", async () => {
await writeFile(tmp, "src/app.ts", `console.log("nothing here");\n`);
const out = await service.findStaleFlagSuggestions(tmp);
expect(out).toEqual([]);
expect(mockFetch).not.toHaveBeenCalled();
});

it("returns [] when every referenced flag has been called recently", async () => {
mockFetch.mockResolvedValueOnce(
lastCalledResponse([["some-flag", "2026-04-30T00:00:00Z"]]),
);
await writeFile(
tmp,
"src/app.ts",
`import posthog from "posthog-js";\nif (posthog.isFeatureEnabled("some-flag")) console.log("on");\n`,
);
const out = await service.findStaleFlagSuggestions(tmp);
expect(out).toEqual([]);
});

it("surfaces flags referenced in code but absent from the last-called response", async () => {
mockFetch.mockResolvedValueOnce(lastCalledResponse([]));
await writeFile(
tmp,
"src/checkout.ts",
`import posthog from "posthog-js";\nif (posthog.isFeatureEnabled("old-checkout-flow")) {\n legacyCheckout();\n}\n`,
);

const out = await service.findStaleFlagSuggestions(tmp);
expect(out).toHaveLength(1);
expect(out[0].flagKey).toBe("old-checkout-flow");
expect(out[0].references).toHaveLength(1);
expect(out[0].references[0].file).toBe("src/checkout.ts");
expect(out[0].references[0].method).toBe("isFeatureEnabled");
expect(out[0].referenceCount).toBe(1);
});

it("filters out flags that the API confirms were called recently", async () => {
mockFetch.mockResolvedValueOnce(
lastCalledResponse([["fresh-flag", "2026-04-30T00:00:00Z"]]),
);
await writeFile(
tmp,
"src/a.ts",
`import posthog from "posthog-js";\nif (posthog.isFeatureEnabled("fresh-flag")) {}\nif (posthog.isFeatureEnabled("dusty-flag")) {}\n`,
);

const out = await service.findStaleFlagSuggestions(tmp);
expect(out.map((s) => s.flagKey)).toEqual(["dusty-flag"]);
});

it("collects multiple references per flag and reports the total count", async () => {
mockFetch.mockResolvedValueOnce(lastCalledResponse([]));
await writeFile(
tmp,
"src/a.ts",
`import posthog from "posthog-js";\nif (posthog.isFeatureEnabled("noisy-flag")) {}\n`,
);
await writeFile(
tmp,
"src/b.ts",
`import posthog from "posthog-js";\nconst v = posthog.getFeatureFlag("noisy-flag");\n`,
);

const out = await service.findStaleFlagSuggestions(tmp);
expect(out).toHaveLength(1);
expect(out[0].referenceCount).toBe(2);
const files = out[0].references.map((r) => r.file).sort();
expect(files).toEqual(["src/a.ts", "src/b.ts"]);
});

it("posts a HogQL query for the referenced flag keys with the right auth", async () => {
mockFetch.mockResolvedValueOnce(lastCalledResponse([]));
await writeFile(
tmp,
"src/a.ts",
`import posthog from "posthog-js";\nif (posthog.isFeatureEnabled("flag-a")) {}\nif (posthog.isFeatureEnabled("flag-b")) {}\n`,
);

await service.findStaleFlagSuggestions(tmp);

expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toContain("/api/projects/42/query/");
expect(init.method).toBe("POST");
expect(init.headers.Authorization).toBe("Bearer token-x");
const body = JSON.parse(init.body);
expect(body.query.kind).toBe("HogQLQuery");
expect(body.query.values.flagKeys.sort()).toEqual(["flag-a", "flag-b"]);
});

it("returns [] when the directory isn't a git repo", async () => {
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "non-git-stale-"));
try {
const out = await service.findStaleFlagSuggestions(nonGit);
expect(out).toEqual([]);
expect(mockFetch).not.toHaveBeenCalled();
} finally {
await fs.rm(nonGit, { recursive: true, force: true });
}
});
});
Loading
Loading