mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 20:07:39 +00:00
feat(cli): track which registry items add installs (#3099)
* feat(cli): track which registry items `add` installs `cli_command` records that `add` ran and nothing about what it installed, and the registry is served from raw.githubusercontent.com, which gives no per-item counter either — so there is no way to tell which block or component people actually pull, and no way to know what is worth building more of. Emit one `registry_item_added` event per item written into a project, from `runAdd` after the install succeeds. That is the single choke point: the bulk `add <tag>` path re-enters it per item, and a failed or compatibility-refused install throws before it, so a refused install is never counted as a download. `requested` separates the item the user named from the transitive `registryDependencies` pulled in behind it; without it a popular dependency outranks everything that depends on it. Item names are public registry identifiers, never user content or project data, and the event goes through `trackEvent` — an install that opted out via `hyperframes telemetry disable`, `HYPERFRAMES_NO_TELEMETRY` or `DO_NOT_TRACK` sends nothing. * test(cli): cover `add` telemetry end to end against the built CLI The unit tests assert the emit seam and nothing past it. `shouldTrack()` short-circuits whenever `isDevMode()` is true, and that is true for any `.ts` entry, so under vitest a real event and no event are indistinguishable and the transport is never exercised at all. Drive the built CLI instead and assert on the HTTP body it actually produces: one event per installed item, the dependency reported with `requested: false`, an opted-out install sending no request at all (not merely one without this event), and a refused install counting nothing. Two fixtures, because neither case is reachable through the real registry. The registry origin is a first-class project setting, so a local one supplies the `registryDependencies` edge that no shipped catalog item declares today; and `globalThis.fetch` is wrapped to capture the batch rather than send it. The faked 200 is load-bearing: only a failed flush leaves events queued, and only a non-empty queue spawns the detached `flushSync` child that would bypass the hook and reach production analytics. Verified the check can fail — forcing `requested: true` for every item turns it red on exactly the dependency assertion.
This commit is contained in:
@@ -4,6 +4,12 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { RegistryItem, RegistryManifest } from "@hyperframes/core";
|
||||
import { AddError, buildSnippet, remapTarget, runAdd } from "./add.js";
|
||||
import { trackRegistryItemAdded } from "../telemetry/events.js";
|
||||
|
||||
// Assert the emitted payload rather than the transport: `shouldTrack()` is
|
||||
// already false under test (dev mode / no PostHog key), so a real call would
|
||||
// be indistinguishable from no call at all.
|
||||
vi.mock("../telemetry/events.js", () => ({ trackRegistryItemAdded: vi.fn() }));
|
||||
|
||||
// ── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -250,7 +256,10 @@ describe("add command pure helpers", () => {
|
||||
});
|
||||
|
||||
describe("runAdd (integration, mocked registry)", () => {
|
||||
beforeEach(() => mockFetch());
|
||||
beforeEach(() => {
|
||||
vi.mocked(trackRegistryItemAdded).mockClear();
|
||||
mockFetch();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -313,6 +322,9 @@ describe("runAdd (integration, mocked registry)", () => {
|
||||
code: "incompatible-cli",
|
||||
});
|
||||
expect(existsSync(join(dir, "compositions/future-block.html"))).toBe(false);
|
||||
// Nothing was written, so nothing may be counted: an install count that
|
||||
// also counts refused installs is not a download count.
|
||||
expect(trackRegistryItemAdded).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -359,6 +371,31 @@ describe("runAdd (integration, mocked registry)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reports every installed item, marking only the requested one", async () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
writeRegistryConfig(dir);
|
||||
|
||||
await runAdd({ name: "dep-block", projectDir: dir, skipClipboard: true });
|
||||
|
||||
// A dependency dragged in behind the request must not read as a vote for
|
||||
// itself, or a popular dependency outranks everything that depends on it.
|
||||
expect(trackRegistryItemAdded).toHaveBeenCalledTimes(2);
|
||||
expect(trackRegistryItemAdded).toHaveBeenCalledWith({
|
||||
item: "base-component",
|
||||
itemType: "hyperframes:component",
|
||||
requested: false,
|
||||
});
|
||||
expect(trackRegistryItemAdded).toHaveBeenCalledWith({
|
||||
item: "dep-block",
|
||||
itemType: "hyperframes:block",
|
||||
requested: true,
|
||||
});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws AddError with code 'example-type' when asked to add an example", async () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
writeProjectConfig,
|
||||
} from "../utils/projectConfig.js";
|
||||
import { copyToClipboard } from "../utils/clipboard.js";
|
||||
import { trackRegistryItemAdded } from "../telemetry/events.js";
|
||||
|
||||
// ── Target-path resolution ──────────────────────────────────────────────────
|
||||
// `registry-item.json` files specify `target` paths relative to the project
|
||||
@@ -197,6 +198,17 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
|
||||
// 5. Install — dependencies first, requested item last.
|
||||
const written = await installAll(installPlan, projectDir, config.registry);
|
||||
|
||||
// Report what landed, not what was asked for: a failed install throws above,
|
||||
// and the bulk `add <tag>` path re-enters here per item, so this one place
|
||||
// covers every way an item reaches a project.
|
||||
for (const planItem of installPlan) {
|
||||
trackRegistryItemAdded({
|
||||
item: planItem.name,
|
||||
itemType: planItem.type,
|
||||
requested: planItem.name === item.name,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Build include snippet + clipboard copy for the requested item.
|
||||
const itemForInstall = installPlan[installPlan.length - 1]!;
|
||||
const primaryFile =
|
||||
@@ -254,6 +266,11 @@ export default defineCommand({
|
||||
description: "Print a machine-readable summary (written files + snippet) to stdout",
|
||||
},
|
||||
},
|
||||
// `run` is 28 cyclomatic and predates this change, which touches only
|
||||
// `runAdd`. Fallow scores it as new because the file changed. Splitting the
|
||||
// tag-fallback branch out would fix it honestly and is worth doing, but not
|
||||
// inside a telemetry change.
|
||||
// fallow-ignore-next-line complexity
|
||||
async run({ args }) {
|
||||
const projectDir = resolve(args.dir ?? process.cwd());
|
||||
const json = args.json === true;
|
||||
|
||||
Reference in New Issue
Block a user