import { expect, type Page } from '@playwright/test'; import { test } from '../fixtures/ids'; import { CleanupRegistry } from '../fixtures/cleanup'; import { adminApi } from '../fixtures/api'; // Phase B3 — Scripts CRUD + Editor. The script editor lives at // /admin/scripts/{id}. Setup uses the API to create the app (and // sometimes a baseline script) so each test can focus on the editor // flow it actually covers. const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`; const cleanup = new CleanupRegistry(); test.afterEach(async () => { await cleanup.run(); }); async function createAppViaApi(slug: string): Promise { const api = await adminApi(); try { const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } }); expect(res.ok()).toBe(true); const body = (await res.json()) as { id: string }; return body.id; } finally { await api.dispose(); } } async function createScriptViaApi( appId: string, name: string, source = HELLO_RHAI ): Promise { const api = await adminApi(); try { const res = await api.post('/api/v1/admin/scripts', { data: { app_id: appId, name, source } }); expect(res.ok()).toBe(true); const body = (await res.json()) as { id: string }; return body.id; } finally { await api.dispose(); } } async function fillCodeMirror(page: Page, locator: string, text: string): Promise { const cm = page.locator(locator).first(); await cm.click(); await page.keyboard.press('ControlOrMeta+A'); await page.keyboard.press('Delete'); await page.keyboard.type(text); } test.describe('B3 scripts CRUD', () => { test('create script via UI navigates to scripts list with the new entry', async ({ page, uniqueSlug }) => { const slug = uniqueSlug('cscr'); await createAppViaApi(slug); cleanup.app(slug); await page.goto(`/admin/apps/${slug}`); await page.getByRole('button', { name: /^New script$/ }).click(); await page.getByLabel('Name').fill('echo'); // The CodeMirror editor starts empty in create mode; type a // minimal valid script. await fillCodeMirror(page, '.cm-content', HELLO_RHAI); await page.getByRole('button', { name: 'Create script' }).click(); await expect(page.getByRole('link', { name: /echo/i })).toBeVisible(); }); test('edit + save Rhai source persists across reload', async ({ page, uniqueSlug }) => { const slug = uniqueSlug('edit'); const appId = await createAppViaApi(slug); const scriptId = await createScriptViaApi(appId, 'edit-target'); cleanup.app(slug); await page.goto(`/admin/scripts/${scriptId}`); await expect(page.locator('.cm-content').first()).toContainText('statusCode'); const updated = `// edited by e2e\nreturn #{ statusCode: 201, body: #{ edited: true } };`; await fillCodeMirror(page, '.cm-content', updated); await page.getByRole('button', { name: /^Save$/ }).click(); // Save button becomes disabled once the buffer matches the // just-saved source — that's our settle signal. await expect(page.getByRole('button', { name: /^Save$/ })).toBeDisabled(); await page.reload(); await expect(page.locator('.cm-content').first()).toContainText('edited by e2e'); }); test('invalid Rhai source: Format shows a parse error', async ({ page, uniqueSlug }) => { const slug = uniqueSlug('invrhai'); const appId = await createAppViaApi(slug); const scriptId = await createScriptViaApi(appId, 'bad-syntax'); cleanup.app(slug); await page.goto(`/admin/scripts/${scriptId}`); await fillCodeMirror(page, '.cm-content', 'this is not rhai @@@ {{{'); await page .locator('.editor-header') .getByRole('button', { name: 'Format' }) .click(); await expect(page.locator('.error.inline').first()).toBeVisible(); }); }); test.describe('B3 test-invoke', () => { test('valid JSON body returns status + body in the result panel', async ({ page, uniqueSlug }) => { const slug = uniqueSlug('inv-ok'); const appId = await createAppViaApi(slug); const scriptId = await createScriptViaApi(appId, 'invoke-ok'); cleanup.app(slug); await page.goto(`/admin/scripts/${scriptId}`); // Body editor is the second .cm-content (source is first). const bodyEditor = page.locator('.cm-content').nth(1); await bodyEditor.click(); await page.keyboard.press('ControlOrMeta+A'); await page.keyboard.press('Delete'); await page.keyboard.type('{"hello":"world"}'); await page.getByRole('button', { name: /^Send$/ }).click(); await expect(page.locator('.status')).toContainText('HTTP 200'); await expect(page.locator('.result pre')).toContainText('ok'); }); test('malformed JSON body: Format surfaces the parse error', async ({ page, uniqueSlug }) => { const slug = uniqueSlug('inv-bad'); const appId = await createAppViaApi(slug); const scriptId = await createScriptViaApi(appId, 'invoke-bad'); cleanup.app(slug); await page.goto(`/admin/scripts/${scriptId}`); const bodyEditor = page.locator('.cm-content').nth(1); await bodyEditor.click(); await page.keyboard.press('ControlOrMeta+A'); await page.keyboard.press('Delete'); await page.keyboard.type('{not valid json,'); // The Format button for the request body sits inside the // Test-invoke card next to the body editor. await page .locator('.json-block') .first() .getByRole('button', { name: 'Format' }) .click(); await expect(page.locator('.error.inline').first()).toBeVisible(); }); }); test.describe('B3 settings', () => { test('timeout input rejects zero and non-positive values', async ({ page, uniqueSlug }) => { const slug = uniqueSlug('settz'); const appId = await createAppViaApi(slug); const scriptId = await createScriptViaApi(appId, 'settings-target'); cleanup.app(slug); await page.goto(`/admin/scripts/${scriptId}`); await page.getByRole('button', { name: 'Settings' }).click(); const timeout = page.getByLabel(/Timeout/); await timeout.fill('0'); const invalid = await timeout.evaluate((el: HTMLInputElement) => !el.validity.valid); expect(invalid).toBe(true); }); }); test.describe('B3 adversarial', () => { test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => { const slug = uniqueSlug('loop'); const appId = await createAppViaApi(slug); const scriptId = await createScriptViaApi( appId, 'inf-loop', 'loop { let x = 1; }' ); cleanup.app(slug); await page.goto(`/admin/scripts/${scriptId}`); await page.getByRole('button', { name: /^Send$/ }).click(); // Either the status renders with a 5xx code, or an error // banner shows up. Either way, the page recovers. await Promise.race([ expect(page.locator('.status')).toBeVisible({ timeout: 30_000 }), expect(page.locator('.error.inline').last()).toBeVisible({ timeout: 30_000 }) ]); // The dashboard must remain interactive after the timeout. await page.getByRole('button', { name: 'Settings' }).click(); await expect(page.getByLabel(/Timeout/)).toBeVisible(); }); });