mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* test: align extended Playwright coverage with current UI behavior * test: address extended suite review feedback * test: restore Codex path hydration assertion
321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import type { Page, Response } from '@playwright/test';
|
|
import {
|
|
createFakeAgentRuntimes,
|
|
FAKE_AGENT_RUNTIME_IDS,
|
|
} from '@/playwright/fake-agents';
|
|
import type { FakeAgentId } from '@/playwright/fake-agents';
|
|
|
|
const STORAGE_KEY = 'open-design:config';
|
|
const GENERATED_FILE = 'real-daemon-smoke.html';
|
|
const GENERATED_HEADING = 'Real Daemon Smoke';
|
|
const CHUNKED_FILE = 'chunked-daemon-smoke.html';
|
|
const CHUNKED_HEADING = 'Chunked Daemon Smoke';
|
|
const FOLLOW_UP_FILE = 'follow-up-daemon-smoke.html';
|
|
let fakeRuntimes: Awaited<ReturnType<typeof createFakeAgentRuntimes>>;
|
|
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
test.beforeAll(async () => {
|
|
fakeRuntimes = await createFakeAgentRuntimes();
|
|
});
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
test.setTimeout(60_000);
|
|
|
|
await resetDaemonAppConfig(page);
|
|
|
|
await page.addInitScript(({ key, codexEnv }) => {
|
|
if (window.localStorage.getItem(key) != null) return;
|
|
window.localStorage.setItem(
|
|
key,
|
|
JSON.stringify({
|
|
mode: 'daemon',
|
|
apiKey: '',
|
|
baseUrl: 'https://api.anthropic.com',
|
|
model: 'claude-sonnet-4-5',
|
|
agentId: 'codex',
|
|
skillId: null,
|
|
designSystemId: null,
|
|
onboardingCompleted: true,
|
|
agentModels: { codex: { model: 'default', reasoning: 'default' } },
|
|
agentCliEnv: { codex: codexEnv },
|
|
}),
|
|
);
|
|
}, { key: STORAGE_KEY, codexEnv: fakeRuntimes.codex.env });
|
|
|
|
await configureFakeAgent(page, 'codex');
|
|
});
|
|
|
|
test.afterEach(async ({ page }) => {
|
|
await resetDaemonAppConfig(page);
|
|
});
|
|
|
|
test('real daemon run streams, persists, and previews an artifact', async ({ page }) => {
|
|
await page.goto('/');
|
|
await createProject(page, 'Real daemon run smoke');
|
|
await expectWorkspaceReady(page);
|
|
|
|
await sendPrompt(page, 'Create a deterministic smoke artifact');
|
|
|
|
await expect(page.getByText(GENERATED_FILE, { exact: true })).toBeVisible({ timeout: 15_000 });
|
|
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
|
|
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
|
|
await expect(frame.getByRole('heading', { name: GENERATED_HEADING })).toBeVisible();
|
|
|
|
const { projectId } = currentProject(page);
|
|
await expectProjectFileToContain(page, projectId, GENERATED_FILE, GENERATED_HEADING);
|
|
});
|
|
|
|
test('real daemon run persists an artifact streamed across multiple chunks', async ({ page }) => {
|
|
await page.goto('/');
|
|
await createProject(page, 'Chunked daemon run smoke');
|
|
await expectWorkspaceReady(page);
|
|
|
|
await sendPrompt(page, 'Create a chunked deterministic smoke artifact');
|
|
|
|
await expect(page.getByText(CHUNKED_FILE, { exact: true })).toBeVisible({ timeout: 15_000 });
|
|
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
|
|
await expect(frame.getByRole('heading', { name: CHUNKED_HEADING })).toBeVisible();
|
|
|
|
const { projectId } = currentProject(page);
|
|
await expectProjectFileToContain(page, projectId, CHUNKED_FILE, CHUNKED_HEADING);
|
|
});
|
|
|
|
test('real daemon run surfaces process/parser errors in chat', async ({ page }) => {
|
|
await page.goto('/');
|
|
await createProject(page, 'Daemon error smoke');
|
|
await expectWorkspaceReady(page);
|
|
|
|
await sendPrompt(page, 'Return an intentional daemon smoke failure');
|
|
|
|
await expect(page.locator('.msg.error')).toContainText('intentional fake codex failure', { timeout: 15_000 });
|
|
await expect(page.locator('.status-pill', { hasText: 'intentional fake codex failure' })).toBeVisible();
|
|
});
|
|
|
|
test('real daemon run supports a follow-up turn in the same project', async ({ page }) => {
|
|
await page.goto('/');
|
|
await createProject(page, 'Daemon follow-up smoke');
|
|
await expectWorkspaceReady(page);
|
|
|
|
await sendPrompt(page, 'Create a deterministic smoke artifact');
|
|
await expect(page.getByText(GENERATED_FILE, { exact: true })).toBeVisible({ timeout: 15_000 });
|
|
|
|
await sendPrompt(page, 'Create a follow-up deterministic smoke artifact');
|
|
await expect(page.getByText(FOLLOW_UP_FILE, { exact: true })).toBeVisible({ timeout: 15_000 });
|
|
|
|
const { projectId } = currentProject(page);
|
|
const response = await page.request.get(`/api/projects/${projectId}/files`);
|
|
expect(response.ok()).toBeTruthy();
|
|
const { files } = (await response.json()) as { files: Array<{ name: string }> };
|
|
expect(files.map((file) => file.name)).toEqual(expect.arrayContaining([GENERATED_FILE, FOLLOW_UP_FILE]));
|
|
|
|
await expectProjectFileToContain(page, projectId, FOLLOW_UP_FILE, 'Generated after an earlier daemon turn.');
|
|
});
|
|
|
|
test('real daemon run previews an artifact from a fake OpenCode runtime', async ({ page }) => {
|
|
await configureFakeAgent(page, 'opencode');
|
|
await page.goto('/');
|
|
await setBrowserAgentConfig(page, 'opencode');
|
|
await page.reload();
|
|
await createProject(page, 'Fake OpenCode runtime smoke');
|
|
await expectWorkspaceReady(page);
|
|
|
|
await sendPrompt(page, 'Fake runtime smoke for opencode');
|
|
|
|
const fileName = 'fake-agent-runtime-opencode.html';
|
|
const heading = 'Fake Agent Runtime opencode';
|
|
await expect(page.getByText(fileName, { exact: true })).toBeVisible({ timeout: 15_000 });
|
|
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
|
|
await expect(frame.getByRole('heading', { name: heading })).toBeVisible();
|
|
|
|
const { projectId } = currentProject(page);
|
|
await expectProjectFileToContain(page, projectId, fileName, heading);
|
|
});
|
|
|
|
test('real daemon run supports fake non-Codex runtime protocols', async ({ page }) => {
|
|
test.setTimeout(180_000);
|
|
|
|
for (const agentId of FAKE_AGENT_RUNTIME_IDS) {
|
|
await test.step(agentId, async () => {
|
|
await configureFakeAgent(page, agentId);
|
|
const projectId = `fake-runtime-${agentId}-${Date.now()}`.replace(/[^A-Za-z0-9._-]/g, '-');
|
|
const { conversationId } = await createProjectViaApi(page, projectId, `Fake ${agentId} runtime smoke`);
|
|
const expectedArtifact = `fake-agent-runtime-${agentId}`;
|
|
|
|
expect(conversationId).toBeTruthy();
|
|
await startRunAndWaitForSuccess(page, {
|
|
agentId,
|
|
projectId,
|
|
conversationId,
|
|
message: `Fake runtime smoke for ${agentId}`,
|
|
expectedOutput: expectedArtifact,
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
async function createProject(page: Page, name: string) {
|
|
await expect(page.getByTestId('new-project-panel')).toBeVisible();
|
|
await page.getByTestId('new-project-tab-prototype').click();
|
|
await page.getByTestId('new-project-name').fill(name);
|
|
await page.getByTestId('create-project').click();
|
|
}
|
|
|
|
async function createProjectViaApi(page: Page, projectId: string, name: string) {
|
|
const response = await page.request.post('/api/projects', {
|
|
data: {
|
|
id: projectId,
|
|
name,
|
|
skillId: null,
|
|
designSystemId: null,
|
|
pendingPrompt: null,
|
|
metadata: { kind: 'prototype' },
|
|
},
|
|
});
|
|
expect(response.ok()).toBeTruthy();
|
|
return (await response.json()) as { conversationId: string };
|
|
}
|
|
|
|
async function expectWorkspaceReady(page: Page) {
|
|
await expect(page).toHaveURL(/\/projects\//);
|
|
await expect(page.getByTestId('chat-composer')).toBeVisible();
|
|
await expect(page.getByTestId('chat-composer-input')).toBeVisible();
|
|
await expect(page.getByTestId('file-workspace')).toBeVisible();
|
|
}
|
|
|
|
async function sendPrompt(page: Page, prompt: string) {
|
|
const input = page.getByTestId('chat-composer-input');
|
|
const sendButton = page.getByTestId('chat-send');
|
|
await input.click();
|
|
await input.fill(prompt);
|
|
await expect(input).toHaveValue(prompt);
|
|
await expect(sendButton).toBeEnabled();
|
|
const chatResponse = page.waitForResponse(isCreateRunResponse);
|
|
await sendButton.click();
|
|
const response = await chatResponse;
|
|
expect(response.ok()).toBeTruthy();
|
|
}
|
|
|
|
async function configureFakeAgent(page: Page, agentId: FakeAgentId) {
|
|
const runtime = fakeRuntimes[agentId];
|
|
const response = await page.request.put('/api/app-config', {
|
|
data: {
|
|
onboardingCompleted: true,
|
|
agentId,
|
|
agentModels: { [agentId]: { model: 'default', reasoning: 'default' } },
|
|
agentCliEnv: { [agentId]: runtime.env },
|
|
skillId: null,
|
|
designSystemId: null,
|
|
},
|
|
});
|
|
expect(response.ok()).toBeTruthy();
|
|
}
|
|
|
|
async function setBrowserAgentConfig(page: Page, agentId: FakeAgentId) {
|
|
await page.evaluate(({ key, id, env }) => {
|
|
window.localStorage.setItem(
|
|
key,
|
|
JSON.stringify({
|
|
mode: 'daemon',
|
|
apiKey: '',
|
|
baseUrl: 'https://api.anthropic.com',
|
|
model: 'claude-sonnet-4-5',
|
|
agentId: id,
|
|
skillId: null,
|
|
designSystemId: null,
|
|
onboardingCompleted: true,
|
|
agentModels: { [id]: { model: 'default', reasoning: 'default' } },
|
|
agentCliEnv: { [id]: env },
|
|
}),
|
|
);
|
|
}, { key: STORAGE_KEY, id: agentId, env: fakeRuntimes[agentId].env });
|
|
}
|
|
|
|
async function resetDaemonAppConfig(page: Page) {
|
|
const response = await page.request.put('/api/app-config', {
|
|
data: {
|
|
onboardingCompleted: true,
|
|
agentId: 'mock',
|
|
agentModels: {},
|
|
agentCliEnv: {},
|
|
skillId: null,
|
|
designSystemId: null,
|
|
},
|
|
});
|
|
expect(response.ok()).toBeTruthy();
|
|
}
|
|
|
|
async function startRunAndWaitForSuccess(
|
|
page: Page,
|
|
options: {
|
|
agentId: FakeAgentId;
|
|
projectId: string;
|
|
conversationId: string;
|
|
message: string;
|
|
expectedOutput?: string;
|
|
},
|
|
) {
|
|
const requestId = `fake-${options.agentId}-${Date.now()}`;
|
|
const response = await page.request.post('/api/runs', {
|
|
data: {
|
|
agentId: options.agentId,
|
|
message: options.message,
|
|
projectId: options.projectId,
|
|
conversationId: options.conversationId,
|
|
assistantMessageId: `assistant-${requestId}`,
|
|
clientRequestId: requestId,
|
|
skillId: null,
|
|
designSystemId: null,
|
|
model: 'default',
|
|
reasoning: 'default',
|
|
},
|
|
});
|
|
expect(response.ok()).toBeTruthy();
|
|
const { runId } = (await response.json()) as { runId: string };
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const status = await page.request.get(`/api/runs/${runId}`);
|
|
if (!status.ok()) return `http-${status.status()}`;
|
|
const body = (await status.json()) as { status: string };
|
|
return body.status;
|
|
}, { timeout: 20_000 })
|
|
.toBe('succeeded');
|
|
|
|
if (options.expectedOutput) {
|
|
const events = await page.request.get(`/api/runs/${runId}/events`);
|
|
expect(events.ok()).toBeTruthy();
|
|
await expect(events.text()).resolves.toContain(options.expectedOutput);
|
|
}
|
|
}
|
|
|
|
async function expectProjectFileToContain(
|
|
page: Page,
|
|
projectId: string,
|
|
fileName: string,
|
|
expected: string,
|
|
) {
|
|
await expect
|
|
.poll(async () => {
|
|
const response = await page.request.get(`/api/projects/${projectId}/files/${fileName}`);
|
|
if (!response.ok()) return '';
|
|
return response.text();
|
|
}, { timeout: 15_000 })
|
|
.toContain(expected);
|
|
}
|
|
|
|
function isCreateRunResponse(response: Response): boolean {
|
|
const url = new URL(response.url());
|
|
return url.pathname === '/api/runs' && response.request().method() === 'POST';
|
|
}
|
|
|
|
function currentProject(page: Page): { projectId: string } {
|
|
const current = new URL(page.url());
|
|
const [, projects, projectId] = current.pathname.split('/');
|
|
if (projects !== 'projects' || !projectId) {
|
|
throw new Error(`unexpected project route: ${current.pathname}`);
|
|
}
|
|
return { projectId };
|
|
}
|