Files
hyperframes/packages/cli/src/server/telemetryIdentity.test.ts
T
James Russo 9c4d9e50a0 feat(telemetry): unify CLI and Studio PostHog identity (Layer 1) (#1829)
* feat(telemetry): unify CLI and Studio PostHog identity (Layer 1)

Seed the CLI's anonymous distinct_id into Studio at launch so a developer's
CLI and their Studio browser session resolve to the same PostHog person.
Also unifies Studio's two previously-independent anonymous ids into one
source of truth. Uses only the existing anonymous machine id (no new PII).

- cli: inject window.__HF_CLI_DISTINCT_ID into the served index.html <head>
  (mirrors the existing __HF_STUDIO_ENV__ injection) + add a fallback
  GET /api/telemetry-identity endpoint. Only seeds when CLI telemetry is
  enabled; empty/no-op otherwise.
- studio: new telemetry/distinctId.ts single source of truth; adopts the
  CLI-seeded id when present, else falls back to the existing per-browser
  localStorage id. Both Studio clients (studio:* and studio_*/render) now
  share this one id.

* fix(telemetry): keep Studio distinct_id resolver fail-silent on getItem

resolveStudioDistinctId read localStorage.getItem() outside a try/catch
while every other external access in the module is guarded. In a
storage-restricted context where the localStorage reference resolves but
getItem throws, the resolver threw — breaking the module's fail-silent
contract (telemetry must never break Studio). Guard the reads and treat a
throw as "no id". Also drop an unnecessary `as` cast in the test per the
repo CLAUDE.md convention (the optional global is already declared).

* refactor(telemetry): address review feedback on identity unification

- dedup safeLocalStorage/safeSessionStorage into utils/safeStorage.ts,
  used by both telemetry/config.ts and telemetry/distinctId.ts (Miga #6)
- replace redundant `??=` with `=` in the no-storage branch; cachedId is
  guaranteed null there (Miga #2)
- extract buildStudioHeadScripts() so the "identity script before env
  script" head-injection ordering is a pure, tested invariant (Miga #5)
- add tests: head-script ordering + telemetry-off passthrough, and a
  Studio memoization test proving an adopted CLI id survives a later
  window.__HF_CLI_DISTINCT_ID reassignment (Rames)
- clarify the XSS-escaping comment (both < and / escaped so no </script>
  sequence can form) (Miga #1)
2026-07-01 09:21:18 -07:00

105 lines
3.7 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from "vitest";
// CLI → Studio telemetry identity seeding (Layer 1). Verifies the server only
// hands the browser a distinct id when CLI telemetry is enabled, and passes
// through the anonymous machine id (no PII) otherwise.
const shouldTrack = vi.fn();
const readConfig = vi.fn();
vi.mock("../telemetry/client.js", () => ({
shouldTrack: (...args: unknown[]) => shouldTrack(...args),
}));
vi.mock("../telemetry/config.js", () => ({
readConfig: (...args: unknown[]) => readConfig(...args),
}));
const { resolveCliTelemetryDistinctId, buildCliIdentityScript, buildStudioHeadScripts } =
await import("./telemetryIdentity.js");
describe("resolveCliTelemetryDistinctId", () => {
beforeEach(() => {
shouldTrack.mockReset();
readConfig.mockReset();
});
it("returns the CLI anonymousId when telemetry is enabled", () => {
shouldTrack.mockReturnValue(true);
readConfig.mockReturnValue({ anonymousId: "machine-uuid" });
expect(resolveCliTelemetryDistinctId()).toBe("machine-uuid");
});
it("returns null when telemetry is disabled (opt-out / dev / CI)", () => {
shouldTrack.mockReturnValue(false);
readConfig.mockReturnValue({ anonymousId: "machine-uuid" });
expect(resolveCliTelemetryDistinctId()).toBeNull();
// Must not even read config when suppressed.
expect(readConfig).not.toHaveBeenCalled();
});
it("returns null when there is no anonymousId", () => {
shouldTrack.mockReturnValue(true);
readConfig.mockReturnValue({ anonymousId: "" });
expect(resolveCliTelemetryDistinctId()).toBeNull();
});
it("never throws — returns null if config reading fails", () => {
shouldTrack.mockReturnValue(true);
readConfig.mockImplementation(() => {
throw new Error("disk error");
});
expect(resolveCliTelemetryDistinctId()).toBeNull();
});
});
describe("buildCliIdentityScript", () => {
beforeEach(() => {
shouldTrack.mockReset();
readConfig.mockReset();
});
it("emits a script that sets window.__HF_CLI_DISTINCT_ID when telemetry is on", () => {
shouldTrack.mockReturnValue(true);
readConfig.mockReturnValue({ anonymousId: "machine-uuid" });
expect(buildCliIdentityScript()).toBe(
'<script>window.__HF_CLI_DISTINCT_ID="machine-uuid";</script>',
);
});
it("emits an empty string when telemetry is disabled (nothing to seed)", () => {
shouldTrack.mockReturnValue(false);
expect(buildCliIdentityScript()).toBe("");
});
it("JSON-encodes the id so it can't break out of the script literal", () => {
shouldTrack.mockReturnValue(true);
readConfig.mockReturnValue({ anonymousId: "</script><script>alert(1)" });
const script = buildCliIdentityScript();
// The raw closing tag must be escaped by JSON.stringify, not emitted literally.
expect(script).not.toContain("</script><script>alert(1)");
expect(script).toContain("window.__HF_CLI_DISTINCT_ID=");
});
});
describe("buildStudioHeadScripts", () => {
beforeEach(() => {
shouldTrack.mockReset();
readConfig.mockReset();
});
const ENV_SCRIPT = "<script>window.__HF_STUDIO_ENV__={};</script>";
it("places the CLI identity script before the env script so the global is set first", () => {
shouldTrack.mockReturnValue(true);
readConfig.mockReturnValue({ anonymousId: "machine-uuid" });
const head = buildStudioHeadScripts(ENV_SCRIPT);
expect(head.indexOf("__HF_CLI_DISTINCT_ID")).toBeGreaterThanOrEqual(0);
expect(head.indexOf("__HF_CLI_DISTINCT_ID")).toBeLessThan(head.indexOf("__HF_STUDIO_ENV__"));
});
it("returns just the env script when identity is suppressed (telemetry off)", () => {
shouldTrack.mockReturnValue(false);
expect(buildStudioHeadScripts(ENV_SCRIPT)).toBe(ENV_SCRIPT);
});
});