mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 16:32:48 +00:00
745 lines
21 KiB
TypeScript
745 lines
21 KiB
TypeScript
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
|
|
const trackEvent = vi.fn();
|
|
const flush = vi.fn(() => Promise.resolve());
|
|
const shouldTrack = vi.fn(() => true);
|
|
vi.mock("./client.js", () => ({
|
|
trackEvent: (...args: unknown[]) => trackEvent(...args),
|
|
flush: () => flush(),
|
|
shouldTrack: () => shouldTrack(),
|
|
}));
|
|
|
|
// Power state shells out to `pmset`; spy so tests can assert it is NOT
|
|
// sampled for opted-out installs (the fields are built at the call site,
|
|
// before trackEvent's own shouldTrack guard).
|
|
const getPowerState = vi.fn(() => ({ on_battery: true, low_power_mode: false }));
|
|
vi.mock("./system.js", async () => ({
|
|
...(await vi.importActual<typeof import("./system.js")>("./system.js")),
|
|
getPowerState: () => getPowerState(),
|
|
}));
|
|
|
|
// identifyUser reads the install anonymousId; pin it so the $identify alias is
|
|
// deterministic and the test never touches disk.
|
|
vi.mock("./config.js", () => ({
|
|
readConfig: () => ({ anonymousId: "anon-test-123", telemetryEnabled: true }),
|
|
}));
|
|
|
|
const {
|
|
trackCommand,
|
|
trackCommandResult,
|
|
trackCheckReport,
|
|
trackRenderComplete,
|
|
trackRenderError,
|
|
trackRenderObservation,
|
|
trackCommandFailure,
|
|
trackCliError,
|
|
trackFigmaImport,
|
|
trackRenderFeedback,
|
|
trackRenderPreflightRejected,
|
|
trackAuthLoginStarted,
|
|
trackAuthLoginCompleted,
|
|
trackAuthLoginFailed,
|
|
identifyUser,
|
|
} = await import("./events.js");
|
|
|
|
describe("command telemetry events", () => {
|
|
beforeEach(() => {
|
|
trackEvent.mockClear();
|
|
});
|
|
|
|
it("includes run_id in cli_command when a run ID is provided", () => {
|
|
trackCommand("check", "run-123");
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith("cli_command", {
|
|
command: "check",
|
|
run_id: "run-123",
|
|
});
|
|
});
|
|
|
|
it("omits run_id from cli_command when no run ID is provided", () => {
|
|
trackCommand("check");
|
|
|
|
const properties = trackEvent.mock.lastCall?.[1];
|
|
expect(properties).not.toHaveProperty("run_id");
|
|
});
|
|
|
|
it("includes run_id in cli_command_result when a run ID is provided", () => {
|
|
trackCommandResult({
|
|
command: "check",
|
|
success: true,
|
|
exitCode: 0,
|
|
durationMs: 42,
|
|
runId: "run-123",
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith("cli_command_result", {
|
|
command: "check",
|
|
success: true,
|
|
exit_code: 0,
|
|
duration_ms: 42,
|
|
run_id: "run-123",
|
|
});
|
|
});
|
|
|
|
it("omits run_id from cli_command_result when no run ID is provided", () => {
|
|
trackCommandResult({
|
|
command: "check",
|
|
success: false,
|
|
exitCode: 1,
|
|
durationMs: 42,
|
|
});
|
|
|
|
const properties = trackEvent.mock.lastCall?.[1];
|
|
expect(properties).not.toHaveProperty("run_id");
|
|
});
|
|
});
|
|
|
|
describe("trackCheckReport", () => {
|
|
beforeEach(() => {
|
|
trackEvent.mockClear();
|
|
});
|
|
|
|
it("emits the check breakdown with snake_case properties and a run ID", () => {
|
|
trackCheckReport({
|
|
contrastGate: true,
|
|
motionGate: false,
|
|
captionZoneGate: true,
|
|
frameCheckGate: false,
|
|
snapshotsGate: true,
|
|
lintErrors: 1,
|
|
lintWarnings: 2,
|
|
runtimeErrors: 3,
|
|
runtimeWarnings: 4,
|
|
layoutErrors: 5,
|
|
layoutWarnings: 6,
|
|
motionErrors: 7,
|
|
motionWarnings: 8,
|
|
contrastErrors: 9,
|
|
contrastWarnings: 10,
|
|
launchSettleMs: 11,
|
|
seekLoopMs: 12,
|
|
contrastMs: 13,
|
|
gridPoints: 14,
|
|
contrastPoints: 15,
|
|
ok: false,
|
|
exitCode: 1,
|
|
runId: "run-123",
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith("check_report", {
|
|
gate_contrast: true,
|
|
gate_motion: false,
|
|
gate_caption_zone: true,
|
|
gate_frame_check: false,
|
|
gate_snapshots: true,
|
|
lint_errors: 1,
|
|
lint_warnings: 2,
|
|
runtime_errors: 3,
|
|
runtime_warnings: 4,
|
|
layout_errors: 5,
|
|
layout_warnings: 6,
|
|
motion_errors: 7,
|
|
motion_warnings: 8,
|
|
contrast_errors: 9,
|
|
contrast_warnings: 10,
|
|
launch_settle_ms: 11,
|
|
seek_loop_ms: 12,
|
|
contrast_ms: 13,
|
|
grid_points: 14,
|
|
contrast_points: 15,
|
|
ok: false,
|
|
exit_code: 1,
|
|
run_id: "run-123",
|
|
});
|
|
});
|
|
|
|
it("omits run_id when no run ID is provided", () => {
|
|
trackCheckReport({
|
|
contrastGate: false,
|
|
motionGate: false,
|
|
captionZoneGate: false,
|
|
frameCheckGate: false,
|
|
snapshotsGate: false,
|
|
lintErrors: 0,
|
|
lintWarnings: 0,
|
|
runtimeErrors: 0,
|
|
runtimeWarnings: 0,
|
|
layoutErrors: 0,
|
|
layoutWarnings: 0,
|
|
motionErrors: 0,
|
|
motionWarnings: 0,
|
|
contrastErrors: 0,
|
|
contrastWarnings: 0,
|
|
launchSettleMs: 0,
|
|
seekLoopMs: 0,
|
|
contrastMs: 0,
|
|
gridPoints: 0,
|
|
contrastPoints: 0,
|
|
ok: true,
|
|
exitCode: 0,
|
|
});
|
|
|
|
const properties = trackEvent.mock.lastCall?.[1];
|
|
expect(properties).not.toHaveProperty("run_id");
|
|
});
|
|
});
|
|
|
|
describe("render telemetry events", () => {
|
|
beforeEach(() => {
|
|
trackEvent.mockClear();
|
|
flush.mockClear();
|
|
});
|
|
|
|
it("flushes immediately after render_complete and render_error (exit races the lazy flush)", () => {
|
|
trackRenderComplete({ durationMs: 1000, fps: 30, quality: "draft", docker: false, gpu: false });
|
|
expect(flush).toHaveBeenCalledTimes(1);
|
|
trackRenderError({ fps: 30, quality: "draft", docker: false });
|
|
expect(flush).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
// The enforcement decision for the advisory heap budget reads these fleet
|
|
// props (see computeWorkerSizing) — a silent drop in the summary→event hop
|
|
// would invalidate that decision without anyone noticing.
|
|
it("carries every worker-sizing provenance prop on render_complete", () => {
|
|
trackRenderComplete({
|
|
durationMs: 1000,
|
|
fps: 30,
|
|
quality: "high",
|
|
docker: false,
|
|
gpu: false,
|
|
workers: 6,
|
|
workersBoundBy: "max_workers",
|
|
workersCpuBased: 16,
|
|
workersMemoryBased: 8,
|
|
workersHeapBased: 4,
|
|
workersFrameBased: 24,
|
|
workersHeapLimitMb: 4096,
|
|
workersExceedHeapAdvisory: true,
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"render_complete",
|
|
expect.objectContaining({
|
|
workers: 6,
|
|
workers_bound_by: "max_workers",
|
|
workers_cpu_based: 16,
|
|
workers_memory_based: 8,
|
|
workers_heap_based: 4,
|
|
workers_frame_based: 24,
|
|
workers_heap_limit_mb: 4096,
|
|
workers_exceed_heap_advisory: true,
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("ties feedback to its report and recent renders via feedback_id + recent_render_ids", () => {
|
|
trackRenderFeedback({
|
|
rating: 3,
|
|
comment: "hook scene blank",
|
|
feedbackId: "feedback-uuid",
|
|
recentRenderIds: ["render-a", "render-b"],
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"cli_render_feedback",
|
|
expect.objectContaining({
|
|
feedback_id: "feedback-uuid",
|
|
recent_render_ids: "render-a,render-b",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("redacts paths and URL query strings from render error messages", () => {
|
|
trackRenderError({
|
|
fps: 30,
|
|
quality: "standard",
|
|
docker: false,
|
|
errorMessage:
|
|
"ENOENT: open '/home/ubuntu/project/media/video.mp4' https://example.com/video.mp4?token=secret",
|
|
observabilityCompositionHash: "abc123",
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"render_error",
|
|
expect.objectContaining({
|
|
error_message: "ENOENT: open '[path]' https://example.com/video.mp4?…",
|
|
observability_composition_hash: "abc123",
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("carries the DE parallel-router/inversion cohort on render_error (hard failure, not just self-verify revert)", () => {
|
|
trackRenderError({
|
|
fps: 30,
|
|
quality: "standard",
|
|
docker: false,
|
|
errorMessage: "worker crashed",
|
|
captureDeParallelRouter: "routed",
|
|
captureDePreRouterWorkers: 2,
|
|
captureWorkerCount: 3,
|
|
captureMemoryExhaustionDetected: true,
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"render_error",
|
|
expect.objectContaining({
|
|
de_parallel_router: "routed",
|
|
de_pre_router_workers: 2,
|
|
capture_worker_count: 3,
|
|
capture_memory_exhaustion_detected: true,
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("carries de_fallback_reason on render_error so a render that fails AFTER an OOM-triggered fallback attempt is distinguishable from one that never attempted a fallback", () => {
|
|
trackRenderError({
|
|
fps: 30,
|
|
quality: "standard",
|
|
docker: false,
|
|
errorMessage: "worker crashed again after fallback",
|
|
captureDeParallelRouter: "reverted",
|
|
captureDeSelfVerifyFallback: false,
|
|
captureDeFallbackReason: "oom",
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"render_error",
|
|
expect.objectContaining({
|
|
de_parallel_router: "reverted",
|
|
de_self_verify_fallback: false,
|
|
de_fallback_reason: "oom",
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("carries the failing dB, frame index, and threshold on render_error for a psnr fallback that failed hard afterward", () => {
|
|
trackRenderError({
|
|
fps: 30,
|
|
quality: "standard",
|
|
docker: false,
|
|
errorMessage: "worker crashed after a psnr fallback",
|
|
captureDeParallelRouter: "reverted",
|
|
captureDeSelfVerifyFallback: true,
|
|
captureDeFallbackReason: "psnr",
|
|
captureDeFallbackFailedDb: 28.4,
|
|
captureDeFallbackFrameIndex: 649,
|
|
captureDeFallbackThresholdDb: 32,
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"render_error",
|
|
expect.objectContaining({
|
|
de_fallback_reason: "psnr",
|
|
de_fallback_failed_db: 28.4,
|
|
de_fallback_frame_index: 649,
|
|
de_fallback_threshold_db: 32,
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("prefers the explicit perfSummary-sourced de_worker_inversion over the capture-observability fallback on render_complete", () => {
|
|
trackRenderComplete({
|
|
durationMs: 1000,
|
|
fps: 30,
|
|
quality: "standard",
|
|
docker: false,
|
|
gpu: false,
|
|
deWorkerInversion: "inverted",
|
|
// Simulates a stale/divergent capture-observability value — the explicit
|
|
// perfSummary field above must win, not this one.
|
|
captureDeWorkerInversion: "reverted",
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"render_complete",
|
|
expect.objectContaining({ de_worker_inversion: "inverted" }),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("carries the perfSummary-sourced failing dB, frame index, and threshold on render_complete", () => {
|
|
trackRenderComplete({
|
|
durationMs: 1000,
|
|
fps: 30,
|
|
quality: "standard",
|
|
docker: false,
|
|
gpu: false,
|
|
deParallelRouter: "reverted",
|
|
deFallbackReason: "psnr",
|
|
deFallbackFailedDb: 28.4,
|
|
deFallbackFrameIndex: 649,
|
|
deFallbackThresholdDb: 32,
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"render_complete",
|
|
expect.objectContaining({
|
|
de_fallback_reason: "psnr",
|
|
de_fallback_failed_db: 28.4,
|
|
de_fallback_frame_index: 649,
|
|
de_fallback_threshold_db: 32,
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("emits render_preflight_rejected with the low-cardinality issue kind", () => {
|
|
trackRenderPreflightRejected({ kind: "aspect-mismatch" });
|
|
expect(trackEvent).toHaveBeenCalledWith("render_preflight_rejected", {
|
|
kind: "aspect-mismatch",
|
|
});
|
|
});
|
|
|
|
it("forwards distinctId to trackEvent so studio renders attribute to the browser user", () => {
|
|
trackRenderError({
|
|
fps: 30,
|
|
quality: "standard",
|
|
docker: false,
|
|
source: "studio",
|
|
distinctId: "browser-user-123",
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"render_error",
|
|
expect.objectContaining({ source: "studio" }),
|
|
"browser-user-123",
|
|
);
|
|
});
|
|
|
|
it("sends split capture-stage timing fields on render_complete", () => {
|
|
trackRenderComplete({
|
|
durationMs: 6000,
|
|
fps: 30,
|
|
quality: "standard",
|
|
docker: false,
|
|
gpu: false,
|
|
stageCaptureMs: 5100,
|
|
stageCaptureSetupMs: 1860,
|
|
stageCaptureFrameMs: 3240,
|
|
captureAvgMs: 27,
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"render_complete",
|
|
expect.objectContaining({
|
|
stage_capture_ms: 5100,
|
|
stage_capture_setup_ms: 1860,
|
|
stage_capture_frame_ms: 3240,
|
|
capture_avg_ms: 27,
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("sends beginframe no-damage reuse counters on render_complete", () => {
|
|
trackRenderComplete({
|
|
durationMs: 6000,
|
|
fps: 30,
|
|
quality: "standard",
|
|
docker: false,
|
|
gpu: false,
|
|
beginFrameNoDamageFrames: 720,
|
|
beginFrameHasDamageFrames: 480,
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"render_complete",
|
|
expect.objectContaining({
|
|
begin_frame_no_damage_frames: 720,
|
|
begin_frame_has_damage_frames: 480,
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("redacts render_observation messages and includes renderJobId for correlation", () => {
|
|
trackRenderObservation({
|
|
renderJobId: "render-123",
|
|
phase: "capture_hdr_layered",
|
|
status: "error",
|
|
compositionHash: "abc123",
|
|
captureMode: "screenshot",
|
|
captureOperation: "captureScreenshot",
|
|
framesCompleted: 12,
|
|
totalFrames: 900,
|
|
heartbeatIndex: 1,
|
|
stageElapsedMs: 30_000,
|
|
message: "Navigation failed for C:\\Users\\Alice\\project\\video.mov?not-a-query",
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"render_observation",
|
|
expect.objectContaining({
|
|
render_job_id: "render-123",
|
|
composition_hash: "abc123",
|
|
capture_mode: "screenshot",
|
|
capture_operation: "captureScreenshot",
|
|
frames_completed: 12,
|
|
total_frames: 900,
|
|
heartbeat_index: 1,
|
|
stage_elapsed_ms: 30_000,
|
|
message: "Navigation failed for [path]",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("carries capture_parallel_stream on render_error via the shared payload", () => {
|
|
trackRenderError({
|
|
fps: 30,
|
|
quality: "standard",
|
|
docker: false,
|
|
errorMessage: "worker crashed",
|
|
captureParallelStream: "beginframe",
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"render_error",
|
|
expect.objectContaining({ capture_parallel_stream: "beginframe" }),
|
|
undefined,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("trackRenderFeedback", () => {
|
|
beforeEach(() => {
|
|
trackEvent.mockClear();
|
|
});
|
|
|
|
it("omits render_duration_ms when no duration is known (standalone feedback)", () => {
|
|
trackRenderFeedback({ rating: 4, comment: "great" });
|
|
|
|
const [, props] = trackEvent.mock.calls[0] as [string, Record<string, unknown>];
|
|
expect(props).not.toHaveProperty("render_duration_ms");
|
|
expect(props.rating).toBe(4);
|
|
expect(props.rating_scale).toBe(10);
|
|
});
|
|
|
|
it("includes render_duration_ms when a real duration is supplied", () => {
|
|
trackRenderFeedback({ rating: 5, renderDurationMs: 6000 });
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"cli_render_feedback",
|
|
expect.objectContaining({ render_duration_ms: 6000 }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("trackCliError", () => {
|
|
beforeEach(() => {
|
|
trackEvent.mockClear();
|
|
});
|
|
|
|
it("redacts install paths from error_message and stack_trace", () => {
|
|
trackCliError({
|
|
error_name: "Error",
|
|
error_message: "ENOENT: open '/Users/alice/project/index.html'",
|
|
stack_trace: "Error: boom\n at /Users/alice/.cache/hyperframes/chrome/headless",
|
|
command: "info",
|
|
kind: "command_error",
|
|
});
|
|
|
|
const [, props] = trackEvent.mock.calls[0] as [string, Record<string, string>];
|
|
expect(props.error_message).not.toContain("/Users/alice");
|
|
expect(props.error_message).toContain("[path]");
|
|
expect(props.stack_trace).not.toContain("/Users/alice");
|
|
});
|
|
|
|
it("forwards the figma endpoint label when supplied", () => {
|
|
trackCliError({
|
|
error_name: "RATE_LIMITED",
|
|
error_message: "figma rate limit hit (429)",
|
|
command: "figma asset",
|
|
kind: "command_error",
|
|
endpoint: "images",
|
|
});
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"cli_error",
|
|
expect.objectContaining({ endpoint: "images" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("trackCommandFailure", () => {
|
|
beforeEach(() => {
|
|
trackEvent.mockClear();
|
|
});
|
|
|
|
it("reports an Error as a command_error with name/message/stack", () => {
|
|
const err = new Error("ffmpeg is required to extract audio");
|
|
trackCommandFailure("transcribe", err);
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"cli_error",
|
|
expect.objectContaining({
|
|
kind: "command_error",
|
|
command: "transcribe",
|
|
error_name: "Error",
|
|
error_message: "ffmpeg is required to extract audio",
|
|
// stack_trace is asserted (redacted) in the trackCliError suite; the
|
|
// raw err.stack no longer matches once paths are stripped.
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("coerces a non-Error reason (e.g. a string) into the message", () => {
|
|
trackCommandFailure("transcribe", "No words found in transcript.");
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"cli_error",
|
|
expect.objectContaining({
|
|
kind: "command_error",
|
|
command: "transcribe",
|
|
error_message: "No words found in transcript.",
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("trackFigmaImport", () => {
|
|
beforeEach(() => {
|
|
trackEvent.mockClear();
|
|
});
|
|
|
|
it("emits figma_import with phase + quality counters, no identifiers", () => {
|
|
trackFigmaImport({
|
|
phase: "component",
|
|
durationMs: 1234,
|
|
unresolvedBindings: 2,
|
|
rasterizedNodes: 3,
|
|
});
|
|
expect(trackEvent).toHaveBeenCalledWith("figma_import", {
|
|
phase: "component",
|
|
duration_ms: 1234,
|
|
unresolved_bindings: 2,
|
|
rasterized_nodes: 3,
|
|
});
|
|
});
|
|
|
|
it("carries reused for the asset phase and omits absent props entirely", () => {
|
|
trackFigmaImport({ phase: "asset", durationMs: 42, reused: true });
|
|
expect(trackEvent).toHaveBeenCalledWith("figma_import", {
|
|
phase: "asset",
|
|
duration_ms: 42,
|
|
reused: true,
|
|
});
|
|
});
|
|
|
|
it("carries tokens mode + entry count for the tokens phase", () => {
|
|
trackFigmaImport({ phase: "tokens", durationMs: 10, tokensMode: "styles", entryCount: 0 });
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"figma_import",
|
|
expect.objectContaining({ phase: "tokens", tokens_mode: "styles", entry_count: 0 }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("auth login telemetry events", () => {
|
|
beforeEach(() => {
|
|
trackEvent.mockClear();
|
|
});
|
|
|
|
it("emits auth_login_started tagged with the method", () => {
|
|
trackAuthLoginStarted("oauth");
|
|
expect(trackEvent).toHaveBeenCalledWith("auth_login_started", { method: "oauth" }, undefined);
|
|
});
|
|
|
|
it("emits auth_login_completed tagged with the method", () => {
|
|
trackAuthLoginCompleted("api_key");
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"auth_login_completed",
|
|
{ method: "api_key" },
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("emits auth_login_failed with the method and a low-cardinality reason", () => {
|
|
trackAuthLoginFailed("oauth", "flow_error");
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"auth_login_failed",
|
|
{ method: "oauth", reason: "flow_error" },
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("distinguishes a timed-out browser flow from a real error", () => {
|
|
trackAuthLoginFailed("oauth", "flow_timeout");
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"auth_login_failed",
|
|
{ method: "oauth", reason: "flow_timeout" },
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("records an aborted prompt / stdin timeout as its own reason", () => {
|
|
trackAuthLoginFailed("api_key", "aborted");
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"auth_login_failed",
|
|
{ method: "api_key", reason: "aborted" },
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("carries only method + reason — never a key, token, or free text", () => {
|
|
trackAuthLoginFailed("api_key", "rejected");
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"auth_login_failed",
|
|
{ method: "api_key", reason: "rejected" },
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("forwards an explicit distinctId to trackEvent for user-level attribution", () => {
|
|
trackAuthLoginCompleted("oauth", "alice@example.com");
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"auth_login_completed",
|
|
{ method: "oauth" },
|
|
"alice@example.com",
|
|
);
|
|
});
|
|
|
|
it("identifyUser emits a $identify alias linking the anon install to the identity", () => {
|
|
identifyUser("alice@example.com");
|
|
expect(trackEvent).toHaveBeenCalledWith(
|
|
"$identify",
|
|
{ $anon_distinct_id: "anon-test-123" },
|
|
"alice@example.com",
|
|
);
|
|
});
|
|
|
|
it("identifyUser is a no-op when there is no identity to attach", () => {
|
|
identifyUser("");
|
|
expect(trackEvent).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("power-state sampling respects the telemetry opt-out", () => {
|
|
beforeEach(() => {
|
|
getPowerState.mockClear();
|
|
shouldTrack.mockReturnValue(true);
|
|
});
|
|
|
|
it("samples power state for a tracked render", () => {
|
|
trackRenderComplete({ durationMs: 1, fps: 30, quality: "high", docker: false, gpu: false });
|
|
expect(getPowerState).toHaveBeenCalled();
|
|
const props = trackEvent.mock.calls.at(-1)?.[1] as Record<string, unknown>;
|
|
expect(props.on_battery).toBe(true);
|
|
expect(props.low_power_mode).toBe(false);
|
|
});
|
|
|
|
it("does NOT spawn pmset when telemetry is disabled", () => {
|
|
// Regression: powerStateFields() is spread into the properties object at
|
|
// the call site, so it runs BEFORE trackEvent's `if (!shouldTrack())`
|
|
// guard — an opted-out install would otherwise pay two blocking
|
|
// subprocess spawns per render for an event that is then discarded.
|
|
shouldTrack.mockReturnValue(false);
|
|
trackRenderComplete({ durationMs: 1, fps: 30, quality: "high", docker: false, gpu: false });
|
|
expect(getPowerState).not.toHaveBeenCalled();
|
|
});
|
|
});
|