Files
nexu-io-open-design/apps/web/tests/components/TasksView.analytics.test.tsx
elihahah666 dc6819ce4f feat(web): instrument Automations tab ui_click tracking (#3911)
The Automations tab (TasksView) only emitted page_view — all 14 in-tab
clicks were uninstrumented, so PostHog showed ~19k monthly visitors with
zero recorded interactions (埋点文档 row 25 was unverified / ).

Wire every actionable click to trackAutomationsClick: new_automation,
run_now, history, edit, pause/resume, delete, open_artifact, view_progress,
crystallize, proposal_apply/reject, plus filter_tab (with filter_id) and
type_card (with template_kind). Extend AutomationsClickProps in
packages/contracts additively (existing values kept so RoutinesSection
stays compatible): filter_id now mirrors the real template-category tabs,
and template_kind is added for template cards.

Add TasksView.analytics.test.tsx as the acceptance gate (page_view fires
once; each click reports the right element/params).

Co-authored-by: qiongyu1999 <2694684348@qq.com>
2026-06-08 11:50:00 +00:00

206 lines
6.7 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Routine } from '@open-design/contracts';
import { TasksView } from '../../src/components/TasksView';
import * as router from '../../src/router';
// Capture every analytics event the tab emits. The page_view + ui_click
// instrumentation lives in TasksView; this suite is the acceptance gate for
// the Automations tab tracking (埋点文档 row 25), which previously reported
// only page_view with zero in-tab clicks.
const analyticsMocks = vi.hoisted(() => ({
track: vi.fn(),
newRequestId: vi.fn(() => 'request-1'),
}));
vi.mock('../../src/analytics/provider', () => ({
useAnalytics: () => ({
track: analyticsMocks.track,
newRequestId: analyticsMocks.newRequestId,
setConfigureGlobals: vi.fn(),
setConsent: vi.fn(),
setIdentity: vi.fn(),
}),
}));
const originalFetch = globalThis.fetch;
const originalConfirm = window.confirm;
function routine(overrides: Partial<Routine> = {}): Routine {
return {
id: 'routine-1',
name: 'Orbit digest',
prompt: 'Build the digest.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now(),
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
...overrides,
} as Routine;
}
function mockFetch({ routines = [] as Routine[] } = {}) {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
const isGet = !init || init.method === undefined;
if (url === '/api/routines' && isGet) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && isGet) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-templates' && isGet) {
return new Response(JSON.stringify({ templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-proposals?status=pending-review' && isGet) {
return new Response(JSON.stringify({ proposals: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1/run' && init?.method === 'POST') {
return new Response(JSON.stringify({}), {
status: 202,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1' && init?.method === 'PATCH') {
return new Response(JSON.stringify({ routine: routine({ enabled: false }) }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1' && init?.method === 'DELETE') {
return new Response(null, { status: 204 });
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
}
function clicks() {
return analyticsMocks.track.mock.calls.filter(([event]) => event === 'ui_click');
}
function lastClick() {
const all = clicks();
return all[all.length - 1]?.[1];
}
describe('TasksView analytics', () => {
beforeEach(() => {
analyticsMocks.track.mockReset();
vi.spyOn(router, 'navigate').mockImplementation(() => {});
});
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
window.confirm = originalConfirm;
vi.restoreAllMocks();
});
it('fires page_view=automations once on mount', async () => {
mockFetch();
render(<TasksView />);
await screen.findByRole('heading', { name: 'Automations' });
const pageViews = analyticsMocks.track.mock.calls.filter(([event]) => event === 'page_view');
expect(pageViews).toHaveLength(1);
expect(pageViews[0]?.[1]).toMatchObject({ page_name: 'automations' });
});
it('tracks the new-automation hero click', async () => {
mockFetch();
render(<TasksView />);
fireEvent.click(await screen.findByTestId('automations-new'));
expect(lastClick()).toMatchObject({
page_name: 'automations',
area: 'automations',
element: 'new_automation',
});
});
// Run / Pause / Delete each trigger a refresh + re-render, so each row
// action gets an isolated render to avoid asserting against a stale node.
it.each([
{ button: 'Run', element: 'run_now' },
{ button: 'Pause', element: 'pause' },
{ button: 'History', element: 'history' },
{ button: 'Edit', element: 'edit' },
{ button: 'Delete automation', element: 'delete' },
])('tracks the $element row action', async ({ button, element }) => {
window.confirm = vi.fn(() => true);
mockFetch({ routines: [routine()] });
render(<TasksView />);
const row = (await screen.findByText('Orbit digest')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: button }));
expect(lastClick()).toMatchObject({
page_name: 'automations',
area: 'automations',
element,
});
});
it('tracks open_artifact from the last-run link', async () => {
const startedAt = new Date('2026-05-25T09:29:00.000Z').getTime();
mockFetch({
routines: [
routine({
lastRun: {
runId: 'run-1',
status: 'failed',
trigger: 'scheduled',
startedAt,
completedAt: startedAt + 5_000,
projectId: 'proj-result',
conversationId: 'conv-result',
agentRunId: 'agent-run-1',
},
} as Partial<Routine>),
],
});
render(<TasksView />);
const row = (await screen.findByText('Orbit digest')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'Open result' }));
expect(lastClick()).toMatchObject({ element: 'open_artifact' });
});
it('tracks the template filter tab with its filter_id', async () => {
mockFetch();
render(<TasksView />);
const tabs = await screen.findByRole('tablist', { name: 'Template filters' });
fireEvent.click(within(tabs).getByRole('tab', { name: /Orbit/i }));
expect(lastClick()).toMatchObject({ element: 'filter_tab', filter_id: 'orbit' });
});
it('tracks a template card click with its template_kind', async () => {
mockFetch();
render(<TasksView />);
// Fallback orbit template ("Daily connector digest") is always rendered.
fireEvent.click(await screen.findByRole('button', { name: /Daily connector digest/i }));
expect(lastClick()).toMatchObject({ element: 'type_card', template_kind: 'orbit' });
});
});