This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// ── Pieces editor (custom piece CRUD via the UI) E2E ──────────────────────────
|
||||
//
|
||||
// REQUIRES `npm run test:e2e` + a running server (ui/playwright.config.ts boots
|
||||
// the real orchestrator with auth OFF → synthetic 'local' user). This does NOT
|
||||
// run in the sandbox — there is no live server here.
|
||||
//
|
||||
// The Pieces page (page=pieces) is NOT auth-gated (NAV_ITEMS: pieces
|
||||
// requiresAuth:false). The published-tested `splitPieces` lib covers the
|
||||
// custom/default split; this proves the actual editor wiring that has no e2e:
|
||||
// create a custom piece from the sidebar, then edit + save it through the editor.
|
||||
//
|
||||
// Selector grounding (read from the real components):
|
||||
// - PiecesPage.tsx: the "+" button next to the "Custom Pieces" section opens an
|
||||
// inline input with placeholder="piece-name"; Enter creates the piece via
|
||||
// POST /api/pieces and selects it.
|
||||
// - PieceEditor.tsx: a Visual/YAML mode toggle ("Visual" / "YAML" literal
|
||||
// buttons), a <textarea> for the YAML, and a footer "Save" button (enabled
|
||||
// only when dirty). These are English literals, not i18n keys.
|
||||
// Backend state is verified through GET /api/pieces?source=custom — the same
|
||||
// endpoint the page reads.
|
||||
|
||||
// With auth disabled the /api/auth/me probe 404s on purpose; the pieces page
|
||||
// reads /api/pieces (200). Personal-space probes may 404 on an empty tree.
|
||||
const BENIGN_PATHS = new Set([
|
||||
'/api/auth/me',
|
||||
'/api/users/me/orgs',
|
||||
'/api/mcp/connections',
|
||||
'/api/mcp/servers',
|
||||
'/api/mcp/user-servers',
|
||||
'/api/ssh/connections',
|
||||
]);
|
||||
const BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
|
||||
|
||||
function trackFatalErrors(page: Page): string[] {
|
||||
const fatalErrors: string[] = [];
|
||||
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
|
||||
page.on('response', (res) => {
|
||||
if (res.status() >= 400) {
|
||||
const { pathname } = new URL(res.url());
|
||||
if (!BENIGN_ASSET.test(pathname) && !BENIGN_PATHS.has(pathname)) {
|
||||
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
return fatalErrors;
|
||||
}
|
||||
|
||||
// Unique name per run so reruns against the same throwaway DB never collide.
|
||||
const pieceName = `e2e-custom-${Date.now()}`;
|
||||
|
||||
test('create a custom piece from the sidebar, then edit + save it via the editor', async ({ page }) => {
|
||||
const fatalErrors = trackFatalErrors(page);
|
||||
|
||||
await page.goto('/ui?page=pieces');
|
||||
await expect(page).toHaveURL(/[?&]page=pieces(&|$)/);
|
||||
|
||||
// The "Custom Pieces" section + its "+" create affordance render. The "+" is
|
||||
// the only button in that section header (title=newPieceTitle).
|
||||
await expect(page.getByText('Custom Pieces', { exact: true })).toBeVisible({ timeout: 15_000 });
|
||||
const createToggle = page.locator('button[title]').filter({ hasText: '+' }).first();
|
||||
await createToggle.click();
|
||||
|
||||
// The inline name input appears (placeholder="piece-name"); type the name and
|
||||
// submit with Enter → POST /api/pieces, then the new piece is selected.
|
||||
const nameInput = page.getByPlaceholder('piece-name');
|
||||
await expect(nameInput).toBeVisible();
|
||||
await nameInput.fill(pieceName);
|
||||
await nameInput.press('Enter');
|
||||
|
||||
// The new custom piece appears in the sidebar list.
|
||||
await expect(page.getByText(pieceName, { exact: false })).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// It was persisted as a custom piece (the endpoint the page reads returns
|
||||
// { pieces: [...] } with custom:true on user pieces).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const res = await page.request.get('/api/pieces');
|
||||
if (!res.ok()) return [];
|
||||
const data = (await res.json()) as { pieces?: Array<{ name: string; custom?: boolean }> };
|
||||
return (data.pieces ?? []).filter((p) => p.custom).map((p) => p.name);
|
||||
}, { timeout: 15_000 })
|
||||
.toContain(pieceName);
|
||||
|
||||
// Edit the piece through the YAML editor. Switch to YAML mode, set a new
|
||||
// description, and Save (footer button is enabled only once dirty).
|
||||
await page.getByRole('button', { name: 'YAML' }).click();
|
||||
const yaml = page.locator('textarea').first();
|
||||
await expect(yaml).toBeVisible({ timeout: 15_000 });
|
||||
const edited = [
|
||||
`name: ${pieceName}`,
|
||||
'description: edited by e2e',
|
||||
'max_movements: 25',
|
||||
'initial_movement: execute',
|
||||
'movements:',
|
||||
' - name: execute',
|
||||
' edit: true',
|
||||
' persona: worker',
|
||||
" instruction: 'do the thing'",
|
||||
' allowed_tools: [Read, Write, Edit]',
|
||||
' default_next: COMPLETE',
|
||||
' rules:',
|
||||
" - condition: '完了'",
|
||||
' next: COMPLETE',
|
||||
'',
|
||||
].join('\n');
|
||||
await yaml.fill(edited);
|
||||
|
||||
const saveBtn = page.getByRole('button', { name: 'Save' });
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 15_000 });
|
||||
await saveBtn.click();
|
||||
|
||||
// The save persists to PUT /api/pieces/:name — verify the new description via
|
||||
// the single-piece read endpoint (source=user-custom; response = { piece }).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const res = await page.request.get(`/api/pieces/${encodeURIComponent(pieceName)}?source=user-custom`);
|
||||
if (!res.ok()) return '';
|
||||
const data = (await res.json()) as { piece?: { description?: string } };
|
||||
return data.piece?.description ?? '';
|
||||
}, { timeout: 15_000 })
|
||||
.toContain('edited by e2e');
|
||||
|
||||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||||
});
|
||||
|
||||
// Negative/visibility: built-in pieces are not deletable and the editor renders
|
||||
// them read-only (no Save/Delete footer). 'chat' is a built-in piece always
|
||||
// present. Selecting it shows the read-only badge instead of the editable footer.
|
||||
test('a built-in piece opens read-only (no Save, no Delete)', async ({ page }) => {
|
||||
const fatalErrors = trackFatalErrors(page);
|
||||
|
||||
await page.goto('/ui?page=pieces');
|
||||
await expect(page.getByText('Default Pieces', { exact: false })).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Open the built-in 'chat' piece from the Default Pieces section.
|
||||
await page.getByRole('button', { name: /chat/i }).first().click();
|
||||
|
||||
// The editor marks it read-only (PieceEditor renders the readonly badge and
|
||||
// omits the editable footer's Save button for built-ins).
|
||||
await expect(page.getByText(/read-?only/i).first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).toHaveCount(0);
|
||||
|
||||
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
|
||||
});
|
||||
Reference in New Issue
Block a user