From 1164289173d99b71e3327f62391ce1009cf84708 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Fri, 10 Apr 2026 00:22:51 +0300 Subject: [PATCH] feat(snippets): add snippets entity, crud, and auto-browser-per-run - add Snippet entity with name, description, code; full CRUD backend - add snippets pages (list, create, edit, detail) and nav entry - add runSnippet helper in code-executor using new Function with args array - add result param to execute() so validateCode can access exec output - remove sessionName from steps; each run now spawns its own fresh browser - fix waitForURL race by polling localStorage for token instead --- client/src/App.tsx | 11 +- client/src/api/client.ts | 29 +++- client/src/api/types.ts | 13 +- client/src/i18n/locales/en.json | 30 ++++ client/src/pages/run/RunDetailPage.tsx | 5 - client/src/pages/scenario/CreateStepPage.tsx | 14 -- client/src/pages/scenario/EditStepPage.tsx | 19 --- .../src/pages/scenario/ScenarioDetailPage.tsx | 2 +- .../src/pages/snippet/CreateSnippetPage.tsx | 113 ++++++++++++++ client/src/pages/snippet/EditSnippetPage.tsx | 142 ++++++++++++++++++ .../src/pages/snippet/SnippetDetailPage.tsx | 107 +++++++++++++ client/src/pages/snippet/SnippetsPage.tsx | 129 ++++++++++++++++ client/vite.config.ts | 2 +- server/src/app.module.ts | 4 + .../code-executor/code-executor.service.ts | 51 +++++-- .../scenario/dto/create-scenario-step.dto.ts | 8 +- .../src/scenario/dto/scenario-export.dto.ts | 2 +- .../scenario/scenario-scheduler.service.ts | 48 +++--- server/src/scenario/scenario-step.entity.ts | 4 +- server/src/scenario/scenario.module.ts | 2 + server/src/snippet/dto/create-snippet.dto.ts | 19 +++ server/src/snippet/dto/update-snippet.dto.ts | 21 +++ server/src/snippet/snippet.controller.ts | 67 +++++++++ server/src/snippet/snippet.entity.ts | 34 +++++ server/src/snippet/snippet.module.ts | 13 ++ server/src/snippet/snippet.service.ts | 76 ++++++++++ 26 files changed, 876 insertions(+), 89 deletions(-) create mode 100644 client/src/pages/snippet/CreateSnippetPage.tsx create mode 100644 client/src/pages/snippet/EditSnippetPage.tsx create mode 100644 client/src/pages/snippet/SnippetDetailPage.tsx create mode 100644 client/src/pages/snippet/SnippetsPage.tsx create mode 100644 server/src/snippet/dto/create-snippet.dto.ts create mode 100644 server/src/snippet/dto/update-snippet.dto.ts create mode 100644 server/src/snippet/snippet.controller.ts create mode 100644 server/src/snippet/snippet.entity.ts create mode 100644 server/src/snippet/snippet.module.ts create mode 100644 server/src/snippet/snippet.service.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 7a5e208..edf0f54 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,6 +1,6 @@ import { NavLink, Navigate, Route, Routes } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Globe, KeyRound, Monitor, ClipboardList, Activity, KeySquare } from 'lucide-react'; +import { Globe, KeyRound, Monitor, ClipboardList, Activity, KeySquare, Braces } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import styles from './App.module.css'; import { SidePanel, ThemeSwitcher } from './ui'; @@ -25,10 +25,15 @@ import { CredentialsPage } from './pages/credential/CredentialsPage'; import { CreateCredentialPage } from './pages/credential/CreateCredentialPage'; import { EditCredentialPage } from './pages/credential/EditCredentialPage'; import { CredentialDetailPage } from './pages/credential/CredentialDetailPage'; +import { SnippetsPage } from './pages/snippet/SnippetsPage'; +import { CreateSnippetPage } from './pages/snippet/CreateSnippetPage'; +import { EditSnippetPage } from './pages/snippet/EditSnippetPage'; +import { SnippetDetailPage } from './pages/snippet/SnippetDetailPage'; const NAV: { path: string; labelKey: string; Icon: LucideIcon }[] = [ { path: '/environments', labelKey: 'nav.environments', Icon: Globe }, { path: '/credentials', labelKey: 'nav.credentials', Icon: KeySquare }, + { path: '/snippets', labelKey: 'nav.snippets', Icon: Braces }, { path: '/keys', labelKey: 'nav.keys', Icon: KeyRound }, { path: '/sessions', labelKey: 'nav.sessions', Icon: Monitor }, { path: '/scenarios', labelKey: 'nav.scenarios', Icon: ClipboardList }, @@ -87,6 +92,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/client/src/api/client.ts b/client/src/api/client.ts index ce08573..e3f09bc 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -10,6 +10,7 @@ import type { ScenarioRunDetail, ScenarioRunStep, ScenarioStep, + Snippet, } from './types'; // In dev, Vite proxies /environments /sessions /scenarios /keys to localhost:3000. @@ -58,6 +59,32 @@ export const credentials = { }, }; +// ── Snippets ────────────────────────────────────────────────────────────────── + +export const snippets = { + list(page = 1, limit = 50): Promise> { + return request(`/snippets?page=${page}&limit=${limit}`); + }, + get(id: number): Promise { + return request(`/snippets/${id}`); + }, + create(payload: Pick & { description?: string }): Promise { + return request('/snippets', { + method: 'POST', + body: JSON.stringify(payload), + }); + }, + update(id: number, patch: Partial>): Promise { + return request(`/snippets/${id}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }); + }, + remove(id: number): Promise { + return request(`/snippets/${id}`, { method: 'DELETE' }); + }, +}; + // ── Environments ────────────────────────────────────────────────────────────── export const environments = { @@ -184,7 +211,6 @@ export interface CreateStepPayload { title?: string; order: number; type: 'login' | 'exec' | 'sign'; - sessionName: string; execCode?: string; validateCode?: string; } @@ -193,7 +219,6 @@ export interface UpdateStepPayload { title?: string; order?: number; type?: 'login' | 'exec' | 'sign'; - sessionName?: string; execCode?: string; validateCode?: string; } diff --git a/client/src/api/types.ts b/client/src/api/types.ts index c247e1d..823cd71 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -35,6 +35,17 @@ export interface Credential { updatedAt: string; } +// ── Snippets ────────────────────────────────────────────────────────────────── + +export interface Snippet { + id: number; + name: string; + description: string | null; + code: string; + createdAt: string; + updatedAt: string; +} + // ── Keys ────────────────────────────────────────────────────────────────────── export interface KeysResponse { @@ -65,7 +76,7 @@ export interface ScenarioStep { order: number; type: StepType; title: string | null; - sessionName: string; + sessionName: string | null; execCode: string | null; validateCode: string | null; createdAt: string; diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 39e03db..e4595ff 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -5,11 +5,13 @@ "not_found_environment": "Environment {{id}} does not exist or has been deleted.", "not_found_credential": "Credential {{id}} does not exist or has been deleted.", "not_found_scenario": "Scenario {{id}} does not exist or has been deleted.", + "not_found_snippet": "Snippet {{id}} does not exist or has been deleted.", "generic": "Something went wrong. Please try again." }, "nav": { "environments": "Environments", "credentials": "Credentials", + "snippets": "Snippets", "keys": "Keys", "sessions": "Sessions", "scenarios": "Scenarios", @@ -199,5 +201,33 @@ "log_level": "Level", "log_message": "Message", "log_time": "Time" + }, + "snippets": { + "title": "Snippets", + "empty": "No snippets yet.", + "loading": "Loading…", + "menu_label": "Snippet options", + "action_edit": "Edit", + "action_delete": "Delete", + "action_add": "Add snippet", + "action_save": "Create", + "action_update": "Save changes", + "action_cancel": "Cancel", + "create_title": "New Snippet", + "edit_title": "Edit Snippet", + "form_name": "Name", + "form_name_placeholder": "e.g. clickLoginButton", + "form_name_required": "Name is required", + "form_description": "Description", + "form_description_placeholder": "What does this snippet do?", + "form_code": "Code", + "form_code_placeholder": "await page.click('#login-btn');", + "form_code_required": "Code is required", + "field_id": "ID", + "field_name": "Name", + "field_description": "Description", + "field_created": "Created", + "field_updated": "Updated", + "section_code": "Code" } } diff --git a/client/src/pages/run/RunDetailPage.tsx b/client/src/pages/run/RunDetailPage.tsx index 8ab0c5a..7b2f1f6 100644 --- a/client/src/pages/run/RunDetailPage.tsx +++ b/client/src/pages/run/RunDetailPage.tsx @@ -119,11 +119,6 @@ export function RunDetailPage() { width: 80, render: (s) => {s.scenarioStep?.type ?? '–'}, }, - { - key: 'session', - header: t('runs.step_session'), - render: (s) => s.scenarioStep?.sessionName ?? '–', - }, { key: 'status', header: t('runs.step_status'), diff --git a/client/src/pages/scenario/CreateStepPage.tsx b/client/src/pages/scenario/CreateStepPage.tsx index aebf8ce..64d8f56 100644 --- a/client/src/pages/scenario/CreateStepPage.tsx +++ b/client/src/pages/scenario/CreateStepPage.tsx @@ -18,7 +18,6 @@ export function CreateStepPage() { const [order, setOrder] = useState('0'); const [title, setTitle] = useState(''); const [type, setType] = useState('exec'); - const [sessionName, setSessionName] = useState(''); const [execCode, setExecCode] = useState(''); const [validateCode, setValidateCode] = useState(''); const [errors, setErrors] = useState>>({}); @@ -32,7 +31,6 @@ export function CreateStepPage() { const validate = (): boolean => { const next: typeof errors = {}; - if (!sessionName.trim()) next.sessionName = t('steps.form_session_required'); if (isNaN(Number(order)) || Number(order) < 0) next.order = t('steps.form_order_invalid'); setErrors(next); return Object.keys(next).length === 0; @@ -48,7 +46,6 @@ export function CreateStepPage() { order: Number(order), title: title.trim() || undefined, type, - sessionName: sessionName.trim(), execCode: execCode.trim() || undefined, validateCode: validateCode.trim() || undefined, }); @@ -100,17 +97,6 @@ export function CreateStepPage() { onChange={(e) => setType(e.target.value as StepType)} options={STEP_TYPES.map((v) => ({ value: v, label: v }))} /> - { - setSessionName(e.target.value); - setErrors((prev) => ({ ...prev, sessionName: undefined })); - }} - error={errors.sessionName} - required - />