Merge empty-work preview into main
This commit is contained in:
@@ -115,6 +115,79 @@ describe('AgentBrowserPanel', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows an empty work hint without starting or recovering a conversation', async () => {
|
||||
const original = hostApiFetchMock.getMockImplementation()!;
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/api/agent-browser/ensure-work') return { status: 'empty' };
|
||||
return original(path, init);
|
||||
});
|
||||
const onOpenChange = vi.fn();
|
||||
const onWorkConversation = vi.fn();
|
||||
const onRecovery = vi.fn();
|
||||
render(<AgentBrowserPanel projectId="project-a" conversationId="c1" embedded open
|
||||
onOpenChange={onOpenChange} onWorkConversation={onWorkConversation} onRecovery={onRecovery} />);
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('还没有作品,快和老师聊聊吧');
|
||||
expect(screen.queryByText('正在打开你的作品…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('准备好后,页面会自动出现在这里。')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /再试一次|帮我检查并打开|重新打开作品|登录并继续/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: false }));
|
||||
expect(onWorkConversation).not.toHaveBeenCalled();
|
||||
expect(onRecovery).not.toHaveBeenCalled();
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '回到操作对话' }));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agent-browser/ensure-work')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('replaces an earlier presentation failure with the empty hint and can later show a work', async () => {
|
||||
const original = hostApiFetchMock.getMockImplementation()!;
|
||||
let result: 'ready' | 'empty' = 'ready';
|
||||
let unavailable = true;
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/api/agent-browser/ensure-work' && result === 'empty') return { status: 'empty' };
|
||||
if (path === '/api/agent-browser/present' && body(init).visible && unavailable) throw new Error('开发浏览器已关闭。');
|
||||
return original(path, init);
|
||||
});
|
||||
render(<AgentBrowserPanel projectId="project-a" embedded open onOpenChange={vi.fn()} />);
|
||||
await screen.findByRole('button', { name: '重新打开作品' });
|
||||
result = 'empty';
|
||||
act(() => window.dispatchEvent(new Event('focus')));
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('还没有作品,快和老师聊聊吧'));
|
||||
expect(screen.queryByRole('button', { name: '重新打开作品' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
result = 'ready';
|
||||
unavailable = false;
|
||||
act(() => window.dispatchEvent(new Event('focus')));
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true }));
|
||||
expect(screen.queryByText('还没有作品,快和老师聊聊吧')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('checks and displays a host-opened work while the empty work tab stays open', async () => {
|
||||
const original = hostApiFetchMock.getMockImplementation()!;
|
||||
let opened = false;
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
|
||||
if (path === '/api/agent-browser/ensure-work' && !opened) return { status: 'empty' };
|
||||
if (path.startsWith('/api/agent-browser/state?') && !opened) {
|
||||
return { success: true, browser: snapshot({ browserId: null, state: 'closed', url: '' }) };
|
||||
}
|
||||
return original(path, init);
|
||||
});
|
||||
const onWorkConversation = vi.fn();
|
||||
render(<AgentBrowserPanel projectId="project-a" embedded open onOpenChange={vi.fn()} onWorkConversation={onWorkConversation} />);
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('还没有作品,快和老师聊聊吧'));
|
||||
opened = true;
|
||||
act(() => hostEventListeners.get('agent-browser:show')?.(snapshot()));
|
||||
|
||||
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true }));
|
||||
expect(screen.queryByText('还没有作品,快和老师聊聊吧')).not.toBeInTheDocument();
|
||||
expect(onWorkConversation).not.toHaveBeenCalled();
|
||||
const checks = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agent-browser/ensure-work');
|
||||
expect(checks).toHaveLength(2);
|
||||
expect(body(checks[1][1]).request_id).toBe(body(checks[0][1]).request_id);
|
||||
});
|
||||
|
||||
it('offers login recovery instead of repeatedly trying to start the page', async () => {
|
||||
const original = hostApiFetchMock.getMockImplementation()!;
|
||||
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => path === '/api/agent-browser/ensure-work'
|
||||
|
||||
73
tests/unit/coding-work-presence.test.ts
Normal file
73
tests/unit/coding-work-presence.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment node
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { inspectWorkPresence } from '../../electron/coding-runtime/work-presence';
|
||||
|
||||
const directories: string[] = [];
|
||||
async function project() {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'makelore-work-presence-'));
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
afterEach(async () => {
|
||||
await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('local work presence', () => {
|
||||
it('recognizes a new project with only metadata, knowledge and imported materials', async () => {
|
||||
const root = await project();
|
||||
expect(await inspectWorkPresence(root)).toBe('empty');
|
||||
for (const directory of ['.makelore', '.git', 'knowledge', 'assets', 'node_modules/dependency']) {
|
||||
await mkdir(join(root, directory), { recursive: true });
|
||||
}
|
||||
for (const file of ['.makelore/project.json', '.git/config', 'knowledge/example.html', 'node_modules/dependency/index.js',
|
||||
'assets/角色.PNG', 'assets/背景.svg', 'README.md', '.DS_Store']) {
|
||||
await writeFile(join(root, file), 'material');
|
||||
}
|
||||
expect(await inspectWorkPresence(root)).toBe('empty');
|
||||
});
|
||||
|
||||
it.each(['index.html', 'public/game.html', 'apps/game/src/App.tsx', 'dist/index.html', 'package.json', 'server.py', 'custom.work', 'knowledge', '.makelore', 'Knowledge/game.html'])(
|
||||
'retains startup for possible work at %s', async file => {
|
||||
const root = await project();
|
||||
const parts = file.split('/');
|
||||
parts.pop();
|
||||
await mkdir(join(root, ...parts), { recursive: true });
|
||||
await writeFile(join(root, file), 'work');
|
||||
expect(await inspectWorkPresence(root)).toBe('possible');
|
||||
},
|
||||
);
|
||||
|
||||
it('does not follow directory links or mistake them for an empty project', async () => {
|
||||
const root = await project();
|
||||
const outside = await project();
|
||||
await symlink(outside, join(root, 'linked-work'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||
expect(await inspectWorkPresence(root)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('does not skip a knowledge symlink as if it were the local material directory', async () => {
|
||||
const root = await project();
|
||||
const outside = await project();
|
||||
await symlink(outside, join(root, 'knowledge'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||
expect(await inspectWorkPresence(root)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('does not classify unreadable or missing projects as empty', async () => {
|
||||
const root = await project();
|
||||
expect(await inspectWorkPresence(join(root, 'missing'))).toBe('unknown');
|
||||
});
|
||||
|
||||
it('bounds deep traversal without hiding potential work', async () => {
|
||||
const root = await project();
|
||||
await mkdir(join(root, ...Array<string>(10).fill('nested')), { recursive: true });
|
||||
expect(await inspectWorkPresence(root)).toBe('unknown');
|
||||
});
|
||||
|
||||
it('bounds wide traversal instead of declaring a partial scan empty', async () => {
|
||||
const root = await project();
|
||||
await Promise.all(Array.from({ length: 2_001 }, (_, index) => writeFile(join(root, `${index}.txt`), '')));
|
||||
expect(await inspectWorkPresence(root)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { CodingWorkPreviewService, workPageResponds } from '../../electron/coding-runtime/work-preview';
|
||||
import { inspectWorkPresence, type WorkPresence } from '../../electron/coding-runtime/work-presence';
|
||||
import { OPEN_WORK_PROMPT } from '../../shared/coding-work-preview';
|
||||
import { firstMessageTitle } from '../../electron/coding-runtime/conversation-title';
|
||||
import type { AgentBrowserSnapshot } from '../../shared/agent-browser';
|
||||
@@ -21,13 +25,84 @@ function setup() {
|
||||
const runtime = { getDiagnostics: vi.fn(() => ({ revision: { provider: 0, resources: 0 }, workers: [] })) };
|
||||
const ensureActive = vi.fn(async () => {});
|
||||
const responds = vi.fn(async () => false);
|
||||
const service = new CodingWorkPreviewService({ browser, conversations, runtime, ensureActive, responds });
|
||||
return { service, browser, conversations, runtime, ensureActive, responds, snapshot };
|
||||
const inspectWork = vi.fn(async (_projectPath: string): Promise<WorkPresence> => 'possible');
|
||||
const service = new CodingWorkPreviewService({ browser, conversations, runtime, ensureActive, responds, inspectWork });
|
||||
return { service, browser, conversations, runtime, ensureActive, responds, inspectWork, snapshot };
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
const directories: string[] = [];
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await Promise.all(directories.splice(0).map(directory => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('opening a work preview', () => {
|
||||
it('keeps repeated empty-project clicks out of conversations and discovers work made later', async () => {
|
||||
const s = setup();
|
||||
const path = await mkdtemp(join(tmpdir(), 'makelore-empty-preview-'));
|
||||
directories.push(path);
|
||||
const emptyProject = { ...project, path };
|
||||
await mkdir(join(path, '.makelore'));
|
||||
await mkdir(join(path, 'knowledge'));
|
||||
await writeFile(join(path, '.makelore/project.json'), '{}');
|
||||
await writeFile(join(path, '头像.png'), 'image');
|
||||
s.inspectWork.mockImplementation(inspectWorkPresence);
|
||||
s.conversations.listConversations.mockResolvedValue([]);
|
||||
for (const requestId of ['req', 'req', 'new-click']) {
|
||||
expect(await s.service.ensure(emptyProject, requestId)).toEqual({ status: 'empty' });
|
||||
}
|
||||
expect(s.conversations.createConversation).not.toHaveBeenCalled();
|
||||
expect(s.conversations.getConversation).not.toHaveBeenCalled();
|
||||
expect(s.conversations.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
expect(s.browser.open).not.toHaveBeenCalled();
|
||||
|
||||
await mkdir(join(path, 'game'));
|
||||
await writeFile(join(path, 'game/play.html'), '<h1>Game</h1>');
|
||||
expect(await s.service.ensure(emptyProject, 'new-click')).toMatchObject({ status: 'starting' });
|
||||
expect(s.conversations.createConversation).toHaveBeenCalledTimes(1);
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves startup when a local check cannot establish absence', async () => {
|
||||
const s = setup();
|
||||
s.inspectWork.mockResolvedValue('unknown');
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'starting' });
|
||||
expect(s.conversations.acceptPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('waits for ongoing creation before showing an empty state', async () => {
|
||||
const s = setup();
|
||||
s.inspectWork.mockResolvedValue('empty');
|
||||
s.runtime.getDiagnostics.mockReturnValueOnce({ revision: { provider: 0, resources: 0 }, workers: [{ conversationId: 'c1', stage: 'running' }] } as never);
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'waiting' });
|
||||
expect(s.inspectWork).not.toHaveBeenCalled();
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'empty' });
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('revalidates the active project after an asynchronous presence check', async () => {
|
||||
const s = setup();
|
||||
s.inspectWork.mockImplementation(async () => {
|
||||
s.ensureActive.mockRejectedValue(new Error('project changed'));
|
||||
return 'empty';
|
||||
});
|
||||
await expect(s.service.ensure(project, 'req', 'c1')).rejects.toThrow('project changed');
|
||||
expect(s.conversations.createConversation).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits if creation starts during the local presence check', async () => {
|
||||
const s = setup();
|
||||
s.inspectWork.mockImplementation(async () => {
|
||||
s.runtime.getDiagnostics.mockReturnValue({ revision: { provider: 0, resources: 0 }, workers: [{ conversationId: 'c1', stage: 'running' }] } as never);
|
||||
return 'empty';
|
||||
});
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toEqual({ status: 'waiting' });
|
||||
expect(s.conversations.createConversation).not.toHaveBeenCalled();
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a successful manual open supersede a pending startup without a late Agent request', async () => {
|
||||
const s = setup();
|
||||
let finish!: (value: ConversationSnapshot) => void;
|
||||
@@ -82,6 +157,7 @@ describe('opening a work preview', () => {
|
||||
expect(await s.service.ensure(project, 'req', 'c1')).toMatchObject({ status: 'ready', browser: s.snapshot });
|
||||
expect(s.conversations.acceptPrompt).not.toHaveBeenCalled();
|
||||
expect(s.browser.open).not.toHaveBeenCalled();
|
||||
expect(s.inspectWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores a crashed preview while keeping a responding service', async () => {
|
||||
|
||||
@@ -7,6 +7,26 @@ beforeEach(() => { vi.useFakeTimers(); ensure.mockReset(); });
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('work preview request lifecycle', () => {
|
||||
it('stops polling an empty project and checks again when the work tab is reopened', async () => {
|
||||
ensure.mockResolvedValue({ status: 'empty' });
|
||||
const onResult = vi.fn();
|
||||
const { result, rerender } = renderHook(({ enabled }) => useWorkPreview({ projectId: 'p1', enabled, onResult }), {
|
||||
initialProps: { enabled: true },
|
||||
});
|
||||
await act(async () => {});
|
||||
await act(async () => vi.advanceTimersByTimeAsync(60_000));
|
||||
expect(result.current.result).toEqual({ status: 'empty' });
|
||||
expect(onResult).toHaveBeenCalledWith({ status: 'empty' });
|
||||
expect(ensure).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => rerender({ enabled: false }));
|
||||
ensure.mockResolvedValue({ status: 'ready' });
|
||||
await act(async () => rerender({ enabled: true }));
|
||||
expect(result.current.result).toEqual({ status: 'ready' });
|
||||
await act(async () => vi.advanceTimersByTimeAsync(60_000));
|
||||
expect(ensure).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resumes a manually opened work page and ignores the older startup response', async () => {
|
||||
let finish!: (value: unknown) => void;
|
||||
ensure.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })).mockResolvedValue({ status: 'ready' });
|
||||
|
||||
Reference in New Issue
Block a user