import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createMemoryProjectStorage, createProjectStore, } from '@electron/opencode/project-store'; import { createProjectProgressSync, mergeProjectProgressDocuments, } from '@electron/services/project-progress-sync'; import { clearWorksSquareSession, storeWorksSquareSession, } from '@electron/services/works-square-session'; type WatchCallback = (filename: string | null) => void; const temporaryDirectories: string[] = []; const synchronizers: Array> = []; async function createProjectDirectory(name: string): Promise { const directory = await mkdtemp(join(tmpdir(), `niancode-project-progress-${name}-`)); temporaryDirectories.push(directory); return directory; } async function writeGameDocuments(projectPath: string, gdd: string, tasks: string): Promise { await writeFile(join(projectPath, 'GDD.md'), gdd, 'utf8'); await writeFile(join(projectPath, 'TASKS.md'), tasks, 'utf8'); } async function writeProductOverview(projectPath: string, content: string): Promise { await writeFile(join(projectPath, 'PRODUCT_OVERVIEW.md'), content, 'utf8'); } async function writeLegacyPromotionPlan(projectPath: string, content: string): Promise { await writeFile(join(projectPath, 'PROMOTION_PLAN.md'), content, 'utf8'); } function createServerPublishFile(appId: string): string { return `${JSON.stringify({ app_id: appId, title: '测试游戏', summary: '合规同步测试', category: 'game', age_band: '10-16', difficulty: 'beginner', version_name: 'v0.1.0', change_log: 'test', zip_file_path: 'dist/test.zip', })}\n`; } function createWatcherHarness() { const callbacks = new Map(); const closeMocks = new Map>(); const watchDirectory = vi.fn((projectPath: string, onChange: WatchCallback) => { callbacks.set(projectPath, onChange); const close = vi.fn(() => callbacks.delete(projectPath)); closeMocks.set(projectPath, close); return { close }; }); return { callbacks, closeMocks, watchDirectory }; } function parsePayload(fetchMock: ReturnType, index: number): Record { const init = fetchMock.mock.calls[index]?.[1] as RequestInit | undefined; return JSON.parse(String(init?.body)) as Record; } describe('project progress compliance synchronization', () => { beforeEach(() => { vi.useFakeTimers(); clearWorksSquareSession(); }); afterEach(async () => { synchronizers.splice(0).forEach((sync) => sync.stop()); vi.useRealTimers(); clearWorksSquareSession(); await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); it('merges both document bodies with stable file markers', () => { expect(mergeProjectProgressDocuments('gdd body', 'tasks body')).toBe( '===== GDD.md =====\ngdd body\n===== TASKS.md =====\ntasks body', ); }); it('watches every registered project, maps identities, debounces changes, and skips duplicate content', async () => { const serverPath = await createProjectDirectory('server'); const localPath = await createProjectDirectory('local'); await writeGameDocuments(serverPath, '# server gdd', '# server tasks'); await writeGameDocuments(localPath, '# local gdd', '# local tasks'); await writeFile(join(serverPath, 'works-publish.json'), createServerPublishFile('server-game'), 'utf8'); const projectStore = createProjectStore(createMemoryProjectStorage()); const serverProject = await projectStore.rememberProject(serverPath); const localProject = await projectStore.rememberProject(localPath); const harness = createWatcherHarness(); const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); const sync = createProjectProgressSync(projectStore, { apiBaseUrl: 'http://127.0.0.1:8012', debounceMs: 25, fetchImpl: fetchMock, getAccessToken: async () => 'access-token', watchDirectory: harness.watchDirectory, }); synchronizers.push(sync); await sync.start(); await Promise.all([serverProject.id, localProject.id].map((projectId) => sync.flushProject(projectId))); expect(sync.getWatchedProjectIds()).toEqual(expect.arrayContaining([serverProject.id, localProject.id])); expect(fetchMock).toHaveBeenCalledTimes(2); const payloads = fetchMock.mock.calls.map((_, index) => parsePayload(fetchMock, index)); const serverPayload = payloads.find((payload) => payload.project_key === 'server-game'); const localPayload = payloads.find((payload) => payload.project_key === localProject.id); expect(serverPayload).toMatchObject({ project_type: 'server', project_key: 'server-game', progress_text: '===== GDD.md =====\n# server gdd\n===== TASKS.md =====\n# server tasks', }); expect(localPayload).toMatchObject({ project_type: 'local', project_key: localProject.id, }); const serverChanged = harness.callbacks.get(serverPath); expect(serverChanged).toBeDefined(); serverChanged?.('GDD.md'); await vi.advanceTimersByTimeAsync(25); await sync.flushProject(serverProject.id); expect(fetchMock).toHaveBeenCalledTimes(2); await writeFile(join(serverPath, 'GDD.md'), '# changed server gdd', 'utf8'); serverChanged?.('GDD.md'); await vi.advanceTimersByTimeAsync(25); await sync.flushProject(serverProject.id); expect(fetchMock).toHaveBeenCalledTimes(3); expect(parsePayload(fetchMock, 2).progress_text).toContain('# changed server gdd'); }); it('sends the full product overview to the server project Agent prompt endpoint', async () => { const projectPath = await createProjectDirectory('product-overview-server'); const productOverview = '# 产品运营介绍\n\n一句话价值:让玩家在三分钟内完成一次太空清洁任务。'; await writeGameDocuments(projectPath, '# gdd', '# tasks'); await writeProductOverview(projectPath, productOverview); await writeFile(join(projectPath, 'works-publish.json'), createServerPublishFile('promotion-game'), 'utf8'); const projectStore = createProjectStore(createMemoryProjectStorage()); const project = await projectStore.rememberProject(projectPath); const harness = createWatcherHarness(); const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); const sync = createProjectProgressSync(projectStore, { apiBaseUrl: 'http://127.0.0.1:8012', fetchImpl: fetchMock, getAccessToken: async () => 'access-token', watchDirectory: harness.watchDirectory, }); synchronizers.push(sync); await sync.start(); await sync.flushProject(project.id); expect(fetchMock).toHaveBeenCalledTimes(2); const promptIndex = fetchMock.mock.calls.findIndex(([url]) => ( String(url) === 'http://127.0.0.1:8012/api/projects/promotion-game/agent/prompt' )); expect(promptIndex).toBeGreaterThanOrEqual(0); expect(parsePayload(fetchMock, promptIndex)).toEqual({ prompt: productOverview }); }); it('prefers the product overview when a legacy promotion plan also exists', async () => { const projectPath = await createProjectDirectory('product-overview-preferred'); const productOverview = '# 产品运营介绍\n\n真实产品事实'; await writeGameDocuments(projectPath, '# gdd', '# tasks'); await writeProductOverview(projectPath, productOverview); await writeLegacyPromotionPlan(projectPath, '# 旧运营宣传计划\n\n不应覆盖新文档'); await writeFile(join(projectPath, 'works-publish.json'), createServerPublishFile('overview-preferred-game'), 'utf8'); const projectStore = createProjectStore(createMemoryProjectStorage()); const project = await projectStore.rememberProject(projectPath); const harness = createWatcherHarness(); const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); const sync = createProjectProgressSync(projectStore, { apiBaseUrl: 'http://127.0.0.1:8012', fetchImpl: fetchMock, getAccessToken: async () => 'access-token', watchDirectory: harness.watchDirectory, }); synchronizers.push(sync); await sync.start(); await sync.flushProject(project.id); const promptIndex = fetchMock.mock.calls.findIndex(([url]) => String(url).includes('/agent/prompt')); expect(parsePayload(fetchMock, promptIndex)).toEqual({ prompt: productOverview }); }); it('falls back to a legacy promotion plan for an existing project without the overview', async () => { const projectPath = await createProjectDirectory('legacy-promotion-plan'); const legacyPromotionPlan = '# 旧运营宣传计划\n\n卖点:用简单操作理解环保行动。'; await writeGameDocuments(projectPath, '# gdd', '# tasks'); await writeLegacyPromotionPlan(projectPath, legacyPromotionPlan); await writeFile(join(projectPath, 'works-publish.json'), createServerPublishFile('legacy-promotion-game'), 'utf8'); const projectStore = createProjectStore(createMemoryProjectStorage()); const project = await projectStore.rememberProject(projectPath); const harness = createWatcherHarness(); const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); const sync = createProjectProgressSync(projectStore, { apiBaseUrl: 'http://127.0.0.1:8012', fetchImpl: fetchMock, getAccessToken: async () => 'access-token', watchDirectory: harness.watchDirectory, }); synchronizers.push(sync); await sync.start(); await sync.flushProject(project.id); expect(fetchMock).toHaveBeenCalledTimes(2); const promptIndex = fetchMock.mock.calls.findIndex(([url]) => String(url).includes('/agent/prompt')); expect(parsePayload(fetchMock, promptIndex)).toEqual({ prompt: legacyPromotionPlan }); }); it('waits for a server app id before pushing a local product overview, then resyncs after binding', async () => { const projectPath = await createProjectDirectory('product-overview-late-binding'); const productOverview = '# 产品运营介绍\n\n卖点:用简单操作理解环保行动。'; await writeGameDocuments(projectPath, '# gdd', '# tasks'); await writeProductOverview(projectPath, productOverview); const projectStore = createProjectStore(createMemoryProjectStorage()); const project = await projectStore.rememberProject(projectPath); const harness = createWatcherHarness(); const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); const sync = createProjectProgressSync(projectStore, { apiBaseUrl: 'http://127.0.0.1:8012', fetchImpl: fetchMock, getAccessToken: async () => 'access-token', watchDirectory: harness.watchDirectory, }); synchronizers.push(sync); await sync.start(); await sync.flushProject(project.id); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/agent/prompt'))).toBe(false); await writeFile(join(projectPath, 'works-publish.json'), createServerPublishFile('bound-promotion-game'), 'utf8'); harness.callbacks.get(projectPath)?.('works-publish.json'); await sync.flushProject(project.id); expect(fetchMock).toHaveBeenCalledTimes(3); const promptIndex = fetchMock.mock.calls.findIndex(([url]) => ( String(url) === 'http://127.0.0.1:8012/api/projects/bound-promotion-game/agent/prompt' )); expect(promptIndex).toBeGreaterThanOrEqual(0); expect(parsePayload(fetchMock, promptIndex)).toEqual({ prompt: productOverview }); }); it('keeps a failed product overview prompt pending and retries it after a later document change', async () => { const projectPath = await createProjectDirectory('product-overview-retry'); const firstOverview = '# 初版产品运营介绍'; const secondOverview = '# 更新后的产品运营介绍\n\n新增真实试玩证据。'; await writeGameDocuments(projectPath, '# gdd', '# tasks'); await writeProductOverview(projectPath, firstOverview); await writeFile(join(projectPath, 'works-publish.json'), createServerPublishFile('retry-promotion-game'), 'utf8'); const projectStore = createProjectStore(createMemoryProjectStorage()); const project = await projectStore.rememberProject(projectPath); const harness = createWatcherHarness(); const fetchMock = vi.fn() .mockResolvedValueOnce(new Response('{}', { status: 200 })) .mockResolvedValueOnce(new Response('{}', { status: 503 })) .mockResolvedValueOnce(new Response('{}', { status: 200 })); const sync = createProjectProgressSync(projectStore, { apiBaseUrl: 'http://127.0.0.1:8012', fetchImpl: fetchMock, getAccessToken: async () => 'access-token', watchDirectory: harness.watchDirectory, }); synchronizers.push(sync); await sync.start(); await sync.flushProject(project.id); expect(fetchMock).toHaveBeenCalledTimes(2); await writeProductOverview(projectPath, secondOverview); harness.callbacks.get(projectPath)?.('PRODUCT_OVERVIEW.md'); await sync.flushProject(project.id); expect(fetchMock).toHaveBeenCalledTimes(3); expect(parsePayload(fetchMock, 2)).toEqual({ prompt: secondOverview }); }); it('retries a pending initial sync after authentication becomes available', async () => { const projectPath = await createProjectDirectory('auth'); await writeGameDocuments(projectPath, '# gdd', '# tasks'); const projectStore = createProjectStore(createMemoryProjectStorage()); const project = await projectStore.rememberProject(projectPath); const harness = createWatcherHarness(); const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); const sync = createProjectProgressSync(projectStore, { apiBaseUrl: 'http://127.0.0.1:8012', debounceMs: 25, fetchImpl: fetchMock, watchDirectory: harness.watchDirectory, }); synchronizers.push(sync); await sync.start(); await sync.flushProject(project.id); expect(fetchMock).not.toHaveBeenCalled(); storeWorksSquareSession({ accessToken: 'access-token' }); await sync.flushProject(project.id); expect(fetchMock).toHaveBeenCalledTimes(1); expect(parsePayload(fetchMock, 0)).toMatchObject({ project_type: 'local', project_key: project.id, }); }); it('keeps failed content pending and retries it on a later file change', async () => { const projectPath = await createProjectDirectory('retry'); await writeGameDocuments(projectPath, '# first gdd', '# first tasks'); const projectStore = createProjectStore(createMemoryProjectStorage()); await projectStore.rememberProject(projectPath); const harness = createWatcherHarness(); const fetchMock = vi.fn() .mockResolvedValueOnce(new Response('{}', { status: 503 })) .mockResolvedValueOnce(new Response('{}', { status: 200 })); const sync = createProjectProgressSync(projectStore, { apiBaseUrl: 'http://127.0.0.1:8012', debounceMs: 25, fetchImpl: fetchMock, getAccessToken: async () => 'access-token', watchDirectory: harness.watchDirectory, }); synchronizers.push(sync); await sync.start(); const project = (await projectStore.listProjects())[0]!; await sync.flushProject(project.id); expect(fetchMock).toHaveBeenCalledTimes(1); await writeFile(join(projectPath, 'TASKS.md'), '# second tasks', 'utf8'); harness.callbacks.get(projectPath)?.('TASKS.md'); await vi.advanceTimersByTimeAsync(25); await sync.flushProject(project.id); expect(fetchMock).toHaveBeenCalledTimes(2); expect(parsePayload(fetchMock, 1).progress_text).toContain('# second tasks'); }); it('attaches a watcher when a project is registered after startup', async () => { const projectStore = createProjectStore(createMemoryProjectStorage()); const harness = createWatcherHarness(); const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); const sync = createProjectProgressSync(projectStore, { apiBaseUrl: 'http://127.0.0.1:8012', fetchImpl: fetchMock, getAccessToken: async () => 'access-token', watchDirectory: harness.watchDirectory, }); synchronizers.push(sync); await sync.start(); const projectPath = await createProjectDirectory('late-registration'); await writeGameDocuments(projectPath, '# late gdd', '# late tasks'); const project = await projectStore.rememberProject(projectPath); expect(sync.getWatchedProjectIds()).toContain(project.id); await sync.flushProject(project.id); expect(fetchMock).toHaveBeenCalledTimes(1); expect(parsePayload(fetchMock, 0).progress_text).toContain('# late tasks'); }); it('closes project watchers when a project is removed or synchronization stops', async () => { const projectPath = await createProjectDirectory('lifecycle'); await writeGameDocuments(projectPath, '# gdd', '# tasks'); const projectStore = createProjectStore(createMemoryProjectStorage()); const project = await projectStore.rememberProject(projectPath); const harness = createWatcherHarness(); const sync = createProjectProgressSync(projectStore, { debounceMs: 25, fetchImpl: vi.fn().mockResolvedValue(new Response('{}', { status: 200 })), getAccessToken: async () => 'access-token', watchDirectory: harness.watchDirectory, }); synchronizers.push(sync); await sync.start(); expect(harness.closeMocks.get(projectPath)).toBeDefined(); await projectStore.removeProject(project.id); expect(sync.getWatchedProjectIds()).toEqual([]); expect(harness.closeMocks.get(projectPath)).toHaveBeenCalledTimes(1); sync.stop(); expect(harness.closeMocks.get(projectPath)).toHaveBeenCalledTimes(1); }); it('does not expose document content in error logs or request URLs', async () => { const projectPath = await createProjectDirectory('request-shape'); await writeGameDocuments(projectPath, 'secret gdd content', 'secret task content'); const projectStore = createProjectStore(createMemoryProjectStorage()); await projectStore.rememberProject(projectPath); const harness = createWatcherHarness(); const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); const sync = createProjectProgressSync(projectStore, { apiBaseUrl: 'http://127.0.0.1:8012', fetchImpl: fetchMock, getAccessToken: async () => 'access-token', watchDirectory: harness.watchDirectory, }); synchronizers.push(sync); await sync.start(); const project = (await projectStore.listProjects())[0]!; await sync.flushProject(project.id); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe('http://127.0.0.1:8012/api/project-progress'); expect(init.headers).toMatchObject({ Authorization: 'Bearer access-token' }); expect(url).not.toContain('secret'); expect(await readFile(join(projectPath, 'GDD.md'), 'utf8')).toContain('secret gdd content'); }); });