Makelore 2.0 initial clean snapshot
This commit is contained in:
433
tests/unit/project-progress-sync.test.ts
Normal file
433
tests/unit/project-progress-sync.test.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user