From e2f304f1a81636d8648b846b7441c265ff310ed5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 11 Apr 2026 21:25:27 +0900 Subject: [PATCH] fix(telemetry): validate activity state shape Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/shared/posthog-activity-state.test.ts | 122 ++++++++++++++++++++++ src/shared/posthog-activity-state.ts | 9 +- 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 src/shared/posthog-activity-state.test.ts diff --git a/src/shared/posthog-activity-state.test.ts b/src/shared/posthog-activity-state.test.ts new file mode 100644 index 000000000..f2c103c21 --- /dev/null +++ b/src/shared/posthog-activity-state.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it } from "bun:test" +import { mkdirSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { tmpdir } from "node:os" + +const originalXdgDataHome = process.env.XDG_DATA_HOME + +function createDataHomePath(): string { + return join(tmpdir(), `posthog-activity-state-${Date.now()}-${Math.random()}`) +} + +async function importPostHogActivityStateModule(): Promise { + return import(`./posthog-activity-state?test=${Date.now()}-${Math.random()}`) +} + +afterEach(() => { + if (originalXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME + } else { + process.env.XDG_DATA_HOME = originalXdgDataHome + } +}) + +describe("getPostHogActivityCaptureState", () => { + it("returns default state when activity file contains null", async () => { + // given + const dataHomePath = createDataHomePath() + const cachePath = join(dataHomePath, "oh-my-opencode") + mkdirSync(cachePath, { recursive: true }) + writeFileSync(join(cachePath, "posthog-activity.json"), "null\n") + process.env.XDG_DATA_HOME = dataHomePath + const { getPostHogActivityCaptureState } = await importPostHogActivityStateModule() + + // when + const result = getPostHogActivityCaptureState(new Date("2026-04-11T10:15:00.000Z")) + + // then + expect(result).toEqual({ + dayUTC: "2026-04-11", + hourUTC: "2026-04-11T10", + captureDaily: true, + captureHourly: true, + }) + + rmSync(dataHomePath, { recursive: true, force: true }) + }) + + it("returns default state when activity file contains an array", async () => { + // given + const dataHomePath = createDataHomePath() + const cachePath = join(dataHomePath, "oh-my-opencode") + mkdirSync(cachePath, { recursive: true }) + writeFileSync(join(cachePath, "posthog-activity.json"), "[]\n") + process.env.XDG_DATA_HOME = dataHomePath + const { getPostHogActivityCaptureState } = await importPostHogActivityStateModule() + + // when + const result = getPostHogActivityCaptureState(new Date("2026-04-11T10:15:00.000Z")) + + // then + expect(result).toEqual({ + dayUTC: "2026-04-11", + hourUTC: "2026-04-11T10", + captureDaily: true, + captureHourly: true, + }) + + rmSync(dataHomePath, { recursive: true, force: true }) + }) + + it("returns default state when activity file contains a number", async () => { + // given + const dataHomePath = createDataHomePath() + const cachePath = join(dataHomePath, "oh-my-opencode") + mkdirSync(cachePath, { recursive: true }) + writeFileSync(join(cachePath, "posthog-activity.json"), "42\n") + process.env.XDG_DATA_HOME = dataHomePath + const { getPostHogActivityCaptureState } = await importPostHogActivityStateModule() + + // when + const result = getPostHogActivityCaptureState(new Date("2026-04-11T10:15:00.000Z")) + + // then + expect(result).toEqual({ + dayUTC: "2026-04-11", + hourUTC: "2026-04-11T10", + captureDaily: true, + captureHourly: true, + }) + + rmSync(dataHomePath, { recursive: true, force: true }) + }) + + it("reads valid activity state JSON", async () => { + // given + const dataHomePath = createDataHomePath() + const cachePath = join(dataHomePath, "oh-my-opencode") + mkdirSync(cachePath, { recursive: true }) + writeFileSync( + join(cachePath, "posthog-activity.json"), + `${JSON.stringify({ + lastActiveDayUTC: "2026-04-11", + lastActiveHourUTC: "2026-04-11T10", + })}\n`, + ) + process.env.XDG_DATA_HOME = dataHomePath + const { getPostHogActivityCaptureState } = await importPostHogActivityStateModule() + + // when + const result = getPostHogActivityCaptureState(new Date("2026-04-11T10:15:00.000Z")) + + // then + expect(result).toEqual({ + dayUTC: "2026-04-11", + hourUTC: "2026-04-11T10", + captureDaily: false, + captureHourly: false, + }) + + rmSync(dataHomePath, { recursive: true, force: true }) + }) +}) diff --git a/src/shared/posthog-activity-state.ts b/src/shared/posthog-activity-state.ts index cc3bd2a4e..6a44e6af2 100644 --- a/src/shared/posthog-activity-state.ts +++ b/src/shared/posthog-activity-state.ts @@ -32,6 +32,10 @@ function getUtcHourString(date: Date): string { return date.toISOString().slice(0, 13) } +function isPostHogActivityState(value: unknown): value is PostHogActivityState { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + function readPostHogActivityState(): PostHogActivityState { const stateFilePath = getPostHogActivityStateFilePath() if (!existsSync(stateFilePath)) { @@ -40,7 +44,10 @@ function readPostHogActivityState(): PostHogActivityState { try { const content = readFileSync(stateFilePath, "utf-8") - const parsed = JSON.parse(content) as PostHogActivityState + const parsed: unknown = JSON.parse(content) + if (!isPostHogActivityState(parsed)) { + return {} + } return parsed } catch (error) { log("[posthog-activity-state] Failed to read activity state", {