open-design/apps/web/tests/analytics-app-version.test.tsx
lefarcen 255c3058c5
fix(analytics): app_version=0.0.0 + media providers clicks + lock run_finished error_code (#2453)
* fix(analytics): use state for runtime app version so PostHog gets the real value

`useAppVersion()` stored the fetched `/api/version` result in a `useRef`,
but ref writes do NOT trigger a re-render. The hook therefore kept
returning '0.0.0' forever and the downstream `useEffect` that calls
`client.register({ app_version, ui_version })` never re-ran with the
real version. PostHog dashboards then showed `app_version=0.0.0` and
`ui_version=0.0.0` on every event ever shipped from the web client.

Switching to `useState` lets the resolved version flow through React's
render cycle so the register-on-change effect picks it up. The boot
placeholder still ships as '0.0.0' for the first events before the
fetch resolves (we don't re-emit those), but every event after init
now carries the real daemon-pinned version.

Adds a red-spec at apps/web/tests/analytics-app-version.test.tsx that
went red on the `useRef` shape (`expected '0.0.0' to be '1.2.3'`) and
green on the `useState` shape, so a future refactor can't silently
regress it.

* feat(analytics): wire media providers click events + lock run_finished error_code invariant

Two analytics gaps shipped together because both came out of the same
PostHog spot-check after PR #2390 landed:

1. Settings → Media providers (CSV row "client_type=desktop / mason /
   media_providers") wasn't emitting any ui_click events. The contract
   type `SettingsMediaProvidersClickProps` and helper
   `trackSettingsMediaProvidersClick` were defined but no call site
   used them, so the dashboard showed zero traffic on every element.
   Added the four v2 elements:
   - `reload` on the "Reload from daemon" button
   - `key_input` on every per-provider API key field (onFocus, mirrors
     the BYOK key field pattern in this same dialog)
   - `url_input` on every per-provider base-URL field
   - `clear` on each row's Clear button (fires before the confirm
     dialog so the intent signal is recorded even if the user backs
     out)
   Each event carries `providers_id` (provider.id) and `is_configured`
   (truthy when the row has a stored entry).

2. `run_finished` with `result=failed` was reported as missing
   `error_code` on PostHog. Audited every failure path: the daemon's
   `child.on('close', ...)` handler has several branches that call
   `runs.finish('failed', code, signal)` directly without first
   emitting an SSE `error` event (ACP fatal, agentStreamError fall
   through, child close without diagnostic), leaving
   `run.errorCode === null` in the status body. The existing fallback
   in `server.ts` already derives `AGENT_SIGNAL_*` / `AGENT_EXIT_*` /
   `AGENT_TERMINATED_UNKNOWN` from `signal` / `exitCode` for those
   cases, so the wire emission should never blank out — but the logic
   was inline and had no unit coverage.

   Extracted the result/error_code derivation into
   `apps/daemon/src/run-result.ts` and added 12 unit tests covering:
   - explicit errorCode forwarding
   - signal-only failures
   - exit-code-only failures
   - clean (code=0) failures (ACP fatal shape)
   - cancelled runs (with and without stamped code)
   - empty-string errorCode defensive case
   - status→result mapping for succeeded/canceled/failed/unknown

   All 12 pass — confirming the invariant "result=failed always
   carries error_code" holds for every failure shape the daemon
   produces. The refactor pins that invariant so a future change
   loses test coverage rather than silently regressing on PostHog.

   If `error_code` still looks empty on a live event, share the
   PostHog event JSON + the agent id and I'll dig further — at this
   point the daemon emission itself is exercised end-to-end.
2026-05-20 21:50:11 +08:00

75 lines
2.8 KiB
TypeScript

// @vitest-environment jsdom
//
// Regression test for "every PostHog event ships with app_version='0.0.0'".
//
// `useAppVersion()` reads /api/version at runtime so the same web bundle
// reports the daemon-pinned version even when running against a newer or
// older daemon during dev. The previous implementation stored the fetched
// version in a `useRef`, which silently broke the contract: ref writes do
// NOT trigger a re-render, so the hook kept returning '0.0.0' forever and
// every downstream useEffect that depended on `appVersion`
// (`client.register({ app_version })` in particular) never re-ran with
// the real version. PostHog dashboards then showed `app_version=0.0.0`
// on every event.
//
// This test goes red on the `useRef` version and green on the `useState`
// version: after the mocked /api/version resolves, the hook must return
// the fetched value, not the boot placeholder.
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAppVersion } from '../src/analytics/provider';
describe('useAppVersion', () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.endsWith('/api/version')) {
return new Response(
JSON.stringify({
version: {
version: '1.2.3',
channel: 'development',
packaged: false,
platform: 'darwin',
arch: 'arm64',
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
return new Response('not found', { status: 404 });
}) as unknown as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
it('boots to the 0.0.0 placeholder before the fetch resolves', () => {
const { result } = renderHook(() => useAppVersion());
expect(result.current).toBe('0.0.0');
});
it('updates to the fetched version once /api/version resolves', async () => {
const { result } = renderHook(() => useAppVersion());
await waitFor(() => {
expect(result.current).toBe('1.2.3');
});
});
it('keeps the 0.0.0 placeholder when the fetch fails', async () => {
globalThis.fetch = vi.fn(async () => new Response('boom', { status: 500 })) as unknown as typeof fetch;
const { result } = renderHook(() => useAppVersion());
// Let any pending microtasks settle so a buggy implementation has the
// same opportunity to "succeed" with stale data as the happy path.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(result.current).toBe('0.0.0');
});
});