Files
makelore/tests/unit/project-progress-sync.test.ts
2026-07-29 17:22:35 +08:00

434 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ReturnType<typeof createProjectProgressSync>> = [];
async function createProjectDirectory(name: string): Promise<string> {
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<void> {
await writeFile(join(projectPath, 'GDD.md'), gdd, 'utf8');
await writeFile(join(projectPath, 'TASKS.md'), tasks, 'utf8');
}
async function writeProductOverview(projectPath: string, content: string): Promise<void> {
await writeFile(join(projectPath, 'PRODUCT_OVERVIEW.md'), content, 'utf8');
}
async function writeLegacyPromotionPlan(projectPath: string, content: string): Promise<void> {
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<string, WatchCallback>();
const closeMocks = new Map<string, ReturnType<typeof vi.fn>>();
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<typeof vi.fn>, index: number): Record<string, unknown> {
const init = fetchMock.mock.calls[index]?.[1] as RequestInit | undefined;
return JSON.parse(String(init?.body)) as Record<string, unknown>;
}
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');
});
});