test(dashboard): add e2e script CRUD + editor spec (B3)
Seven tests covering script creation via the Scripts tab, the source editor (CodeMirror typing + save + reload), Format-button error surfaces for both Rhai and the test-invoke JSON body, the test-invoke happy path, settings input validation, and an infinite-loop adversarial that asserts the sandbox timeout reports cleanly and the editor stays interactive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
203
dashboard/tests/e2e/scripts/scripts.spec.ts
Normal file
203
dashboard/tests/e2e/scripts/scripts.spec.ts
Normal file
@@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user