Files
Genarrative/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx
T
lhk229 1e186369c9
Project CI / AI game creator shell Rust crates (push) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
客户端埋点设置 (#446)
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/446
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-23 00:09:12 +08:00

333 lines
10 KiB
TypeScript

// @vitest-environment jsdom
import { act, cleanup, renderHook } from '@testing-library/react';
import { StrictMode } from 'react';
import { afterEach, expect, it, vi } from 'vitest';
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import type { TauriInvoke } from '../src/app/types';
import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation';
import {
beginProjectOpenAnalytics,
beginUiSaveAnalytics,
} from '../src/services/clientAnalytics';
const context = {
route: { user_id: 'user-a', destination_origin: 'https://a.example' },
editor_session_id: 'session-a',
client_version: '1',
};
function deferred<T>() {
let resolve!: (value: T) => void;
return {
promise: new Promise<T>((done) => {
resolve = done;
}),
resolve: (value: T) => resolve(value),
};
}
afterEach(() => {
cleanup();
delete window.__TAURI__;
vi.restoreAllMocks();
});
function mount(
capture: () => Promise<unknown> = async () => context,
preview: (path: string) => Promise<unknown> = async () => null,
) {
vi.spyOn(crypto, 'randomUUID').mockReturnValue(
'12345678-1234-4234-8234-123456789012',
);
const manifest = createGameCreationAppManifest('project', '项目');
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'capture_analytics_context') return capture();
if (command === 'get_local_game_preview_status')
return preview(String(args?.projectPath));
if (command === 'inspect_local_project_directory')
return { exists: true, isDirectory: true, isGameCreatorProject: true };
if (command === 'get_local_game_manifest') return manifest;
if (command === 'pick_local_project_directory') return 'C:/picker';
if (command === 'init_local_game_project')
return { projectPath: args?.projectPath, manifest };
if (command === 'get_local_game_project_revision') return { revision: 1 };
return null;
},
);
window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
const hook = renderHook(
() =>
useHomeProjectCreation({
setStatus: vi.fn(),
setLauncherView: vi.fn(),
rememberRecentWorkspace: vi.fn(),
}),
{ wrapper: StrictMode },
);
return {
...hook,
invoke,
manifest,
records: () =>
invoke.mock.calls.filter(
([command]) => command === 'record_analytics_project_open',
),
};
}
it.each(['recent', 'picker', 'create'] as const)(
'成功进入项目记录真实 %s 来源',
async (source) => {
const { result, records } = mount();
await act(async () => {
if (source === 'picker') await result.current.pickAndOpenProject();
else
await result.current.openProject(
'C:/project',
source === 'create' ? 'create' : 'open',
);
});
expect(records()).toHaveLength(1);
expect(records()[0][1]).toMatchObject({ context, openSource: source });
},
);
it('身份抓取挂起不阻塞打开,成功时间在抓取完成前冻结', async () => {
const captured = deferred<unknown>();
const { result, records } = mount(() => captured.promise);
await act(async () => {
await result.current.openProject('C:/project', 'open');
});
expect(result.current.currentProjectContext?.projectPath).toBe('C:/project');
const latestSuccessTime = Date.now();
expect(records()).toHaveLength(0);
await act(async () => {
captured.resolve(context);
});
expect(records()).toHaveLength(1);
expect(Date.parse(String(records()[0][1]?.eventTime))).toBeLessThanOrEqual(
latestSuccessTime,
);
});
it('预览核验挂起时卸载,不记录从未进入的工作区', async () => {
const preview = deferred<unknown>();
const { result, unmount, records, invoke } = mount(
undefined,
() => preview.promise,
);
let pending!: Promise<void>;
await act(async () => {
pending = result.current.openProject('C:/old', 'open');
});
unmount();
await act(async () => {
preview.resolve(null);
await pending;
});
expect(records()).toHaveLength(0);
expect(
invoke.mock.calls.filter(
([command]) => command === 'record_analytics_project_leave',
),
).toHaveLength(0);
});
it('StrictMode 重放不产生离开,真正卸载清除已进入的项目', async () => {
const { result, unmount, invoke } = mount();
await act(async () => {
await result.current.openProject('C:/project', 'open');
});
const leaves = () =>
invoke.mock.calls.filter(
([command]) => command === 'record_analytics_project_leave',
);
expect(leaves()).toHaveLength(0);
unmount();
await act(async () => {});
expect(leaves()).toEqual([
['record_analytics_project_leave', { projectPath: 'C:/project' }],
]);
});
it('页面关闭通知尽力清理,随后卸载不会重复通知', async () => {
const { result, unmount, invoke } = mount();
await act(async () => {
await result.current.openProject('C:/project', 'open');
});
window.dispatchEvent(new Event('pagehide'));
unmount();
await act(async () => {});
expect(
invoke.mock.calls.filter(
([command]) => command === 'record_analytics_project_leave',
),
).toHaveLength(1);
});
it('同项目再次显式打开是新操作,重渲染不会产生额外打开', async () => {
const { result, rerender, records } = mount();
vi.mocked(crypto.randomUUID)
.mockReturnValueOnce('12345678-1234-4234-8234-123456789011')
.mockReturnValueOnce('12345678-1234-4234-8234-123456789012');
await act(async () => {
await result.current.openProject('C:/project', 'open');
});
rerender();
expect(records()).toHaveLength(1);
await act(async () => {
await result.current.openProject('C:/project', 'open');
});
expect(records()).toHaveLength(2);
expect(records()[0][1]?.operationId).not.toBe(records()[1][1]?.operationId);
});
it('身份抓取和记录写入失败静默,不改变成功打开', async () => {
const { result, records, invoke } = mount(async () => {
throw new Error('offline');
});
await act(async () => {
await result.current.openProject('C:/project', 'open');
});
expect(result.current.currentProjectContext?.projectPath).toBe('C:/project');
expect(records()).toHaveLength(0);
const failing = vi.fn(async (command: string) => {
if (command === 'capture_analytics_context') return context;
throw new Error('write');
});
beginProjectOpenAnalytics(failing as TauriInvoke, 'recent')?.record(
'C:/project',
);
await act(async () => {});
expect(failing).toHaveBeenCalledWith(
'record_analytics_project_open',
expect.anything(),
);
expect(invoke).toHaveBeenCalledWith('capture_analytics_context');
});
it('较晚的导航获采纳,旧预览核验迟到不记录打开', async () => {
const oldPreview = deferred<unknown>();
const { result, invoke, manifest, records } = mount(
undefined,
async (path) => (path === 'C:/old' ? oldPreview.promise : null),
);
let old!: Promise<void>;
await act(async () => {
old = result.current.enterCreatedTemplateProject(
{ projectPath: 'C:/old', manifestPath: '', manifest },
() => true,
beginProjectOpenAnalytics(invoke as TauriInvoke, 'create'),
);
});
await act(async () => {
await result.current.enterCreatedTemplateProject(
{ projectPath: 'C:/new', manifestPath: '', manifest },
() => true,
beginProjectOpenAnalytics(invoke as TauriInvoke, 'create'),
);
oldPreview.resolve(null);
await old;
});
expect(result.current.currentProjectContext?.projectPath).toBe('C:/new');
expect(records().map(([, args]) => args?.projectPath)).toEqual(['C:/new']);
});
it('模板权限失效后不记录被舍弃的导航,同次成功通知只记一次', async () => {
const pending = deferred<unknown>();
const { result, invoke, manifest, records } = mount(
undefined,
() => pending.promise,
);
let current = true;
let entry!: Promise<void>;
const action = beginProjectOpenAnalytics(invoke as TauriInvoke, 'create');
await act(async () => {
entry = result.current.enterCreatedTemplateProject(
{ projectPath: 'C:/old', manifestPath: '', manifest },
() => current,
action,
);
});
await act(async () => {
current = false;
pending.resolve(null);
await entry;
});
expect(records()).toHaveLength(0);
action?.record('C:/accepted');
action?.record('C:/accepted');
await act(async () => {});
expect(records()).toHaveLength(1);
});
it('freezes UI save identity and completion time while context delivery is delayed', async () => {
const captured = deferred<typeof context>();
const invoke = vi.fn(async (command: string) =>
command === 'capture_analytics_context' ? captured.promise : undefined,
);
const now = vi
.spyOn(Date.prototype, 'toISOString')
.mockReturnValue('2026-09-21T01:00:00.000Z');
const analytics = beginUiSaveAnalytics(
invoke as TauriInvoke,
'C:/project',
'manual',
);
analytics?.record(false);
now.mockReturnValue('2026-09-21T02:00:00.000Z');
analytics?.record(true);
captured.resolve(context);
await act(async () => {
await captured.promise;
});
const calls = invoke.mock.calls.filter(
([command]) => command === 'record_analytics_ui_save',
);
expect(calls).toHaveLength(1);
expect(invoke).toHaveBeenCalledWith(
'record_analytics_ui_save',
expect.objectContaining({
context,
changed: false,
eventTime: '2026-09-21T01:00:00.000Z',
}),
);
});
it('isolates UI save analytics UUID and sync/async bridge failures', async () => {
const throwing = vi.fn(() => {
throw new Error('bridge unavailable');
});
expect(() =>
beginUiSaveAnalytics(
throwing as TauriInvoke,
'C:/project',
'manual',
)?.record(true),
).not.toThrow();
const rejected = vi.fn().mockRejectedValue(new Error('bridge rejected'));
beginUiSaveAnalytics(rejected as TauriInvoke, 'C:/project', 'manual')?.record(
true,
);
const recordFailure = vi.fn((command: string) => {
if (command === 'capture_analytics_context')
return Promise.resolve(context);
throw new Error('record failed');
});
beginUiSaveAnalytics(
recordFailure as TauriInvoke,
'C:/project',
'manual',
)?.record(true);
vi.spyOn(crypto, 'randomUUID').mockImplementation(() => {
throw new Error('no UUID');
});
expect(
beginUiSaveAnalytics(rejected as TauriInvoke, 'C:/project', 'manual'),
).toBeNull();
await act(async () => {
await Promise.resolve();
});
});