diff --git a/dashboard/tests/e2e/scripts/scripts.spec.ts b/dashboard/tests/e2e/scripts/scripts.spec.ts new file mode 100644 index 0000000..19a964a --- /dev/null +++ b/dashboard/tests/e2e/scripts/scripts.spec.ts @@ -0,0 +1,203 @@ +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(); + }); +});