refactor(scenario): remove step type and simplify scheduler to exec-only

- remove StepType, type column, and type field from step entity and DTOs
- remove executeLoginStep, executeSignStep, executeExecStep from scheduler
- inline exec logic directly in executeStepRun; all steps run execCode
- remove AuthService, EnvironmentService, runEnvironments from scheduler
- remove type selector from CreateStepPage and EditStepPage
- remove type badge column from ScenarioDetailPage
- fix useEffect dependency arrays in RunDetailPage (FINAL, id, runId, polling)
- fix duplicate /snippets proxy key in vite.config.ts
- remove unused escapeHtml export from hljs.ts
This commit is contained in:
2026-04-10 16:14:17 +03:00
parent 1c13e068ad
commit 673aa0f458
36 changed files with 339 additions and 421 deletions
+23 -6
View File
@@ -83,7 +83,10 @@ export const snippets = {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
}, },
update(id: string, patch: Partial<Pick<Snippet, 'name' | 'description' | 'code'>>): Promise<Snippet> { update(
id: string,
patch: Partial<Pick<Snippet, 'name' | 'description' | 'code'>>,
): Promise<Snippet> {
return request(`/snippets/${id}`, { return request(`/snippets/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(patch), body: JSON.stringify(patch),
@@ -184,7 +187,11 @@ export const scenarios = {
waitForRun(scenarioId: string, runId: string): Promise<ScenarioRunDetail> { waitForRun(scenarioId: string, runId: string): Promise<ScenarioRunDetail> {
return request(`/scenarios/${scenarioId}/run/${runId}/wait`, { method: 'POST' }); return request(`/scenarios/${scenarioId}/run/${runId}/wait`, { method: 'POST' });
}, },
listRuns(scenarioId: string, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> { listRuns(
scenarioId: string,
page = 1,
limit = 20,
): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`); return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
}, },
exportScenario(id: string): Promise<unknown> { exportScenario(id: string): Promise<unknown> {
@@ -201,12 +208,24 @@ export const scenarios = {
// ── Runs ───────────────────────────────────────────────────────────────────── // ── Runs ─────────────────────────────────────────────────────────────────────
export const runs = { export const runs = {
listAll(page = 1, limit = 20, status?: string): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[]; scenario: { id: string; name: string } }>> { listAll(
page = 1,
limit = 20,
status?: string,
): Promise<
PaginatedResponse<
ScenarioRun & { stepRuns: ScenarioRunStep[]; scenario: { id: string; name: string } }
>
> {
const q = new URLSearchParams({ page: String(page), limit: String(limit) }); const q = new URLSearchParams({ page: String(page), limit: String(limit) });
if (status) q.set('status', status); if (status) q.set('status', status);
return request(`/scenarios/runs?${q}`); return request(`/scenarios/runs?${q}`);
}, },
list(scenarioId: string, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> { list(
scenarioId: string,
page = 1,
limit = 20,
): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`); return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
}, },
get(scenarioId: string, runId: string, q?: string): Promise<ScenarioRunDetail> { get(scenarioId: string, runId: string, q?: string): Promise<ScenarioRunDetail> {
@@ -238,7 +257,6 @@ export const scenarioCredentials = {
export interface CreateStepPayload { export interface CreateStepPayload {
title?: string; title?: string;
order: number; order: number;
type: 'login' | 'exec' | 'sign';
execCode?: string; execCode?: string;
validateCode?: string; validateCode?: string;
} }
@@ -246,7 +264,6 @@ export interface CreateStepPayload {
export interface UpdateStepPayload { export interface UpdateStepPayload {
title?: string; title?: string;
order?: number; order?: number;
type?: 'login' | 'exec' | 'sign';
execCode?: string; execCode?: string;
validateCode?: string; validateCode?: string;
} }
-3
View File
@@ -68,13 +68,10 @@ export interface Session {
// ── Scenarios ───────────────────────────────────────────────────────────────── // ── Scenarios ─────────────────────────────────────────────────────────────────
export type StepType = 'login' | 'exec' | 'sign';
export interface ScenarioStep { export interface ScenarioStep {
id: string; id: string;
scenarioId: string; scenarioId: string;
order: number; order: number;
type: StepType;
title: string | null; title: string | null;
sessionName: string | null; sessionName: string | null;
execCode: string | null; execCode: string | null;
-4
View File
@@ -6,7 +6,3 @@ hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('json', json); hljs.registerLanguage('json', json);
export default hljs; export default hljs;
export function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
@@ -6,7 +6,15 @@ import { CodeBlock } from '../../ui';
import { stringify as yamlStringify } from 'yaml'; import { stringify as yamlStringify } from 'yaml';
import { credentials } from '../../api'; import { credentials } from '../../api';
import type { Credential } from '../../api'; import type { Credential } from '../../api';
import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp, UuidBadge } from '../../ui'; import {
Breadcrumbs,
Button,
Card,
DescriptionList,
Notification,
Timestamp,
UuidBadge,
} from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
export function CredentialDetailPage() { export function CredentialDetailPage() {
@@ -95,11 +103,7 @@ export function CredentialDetailPage() {
{ term: t('credentials.field_id'), detail: <UuidBadge id={credential.id} /> }, { term: t('credentials.field_id'), detail: <UuidBadge id={credential.id} /> },
{ {
term: t('credentials.field_last_used'), term: t('credentials.field_last_used'),
detail: credential.lastUsedAt ? ( detail: credential.lastUsedAt ? <Timestamp value={credential.lastUsedAt} /> : '—',
<Timestamp value={credential.lastUsedAt} />
) : (
'—'
),
}, },
{ {
term: t('credentials.field_created'), term: t('credentials.field_created'),
@@ -5,7 +5,15 @@ import { Settings, Pencil, Trash2, Plus, Download } from 'lucide-react';
import { parse as yamlParse } from 'yaml'; import { parse as yamlParse } from 'yaml';
import { credentials } from '../../api'; import { credentials } from '../../api';
import type { Credential } from '../../api'; import type { Credential } from '../../api';
import { Breadcrumbs, Button, Card, ContextMenu, DescriptionList, Timestamp, UuidBadge } from '../../ui'; import {
Breadcrumbs,
Button,
Card,
ContextMenu,
DescriptionList,
Timestamp,
UuidBadge,
} from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
function CredentialCard({ function CredentialCard({
@@ -4,7 +4,15 @@ import { useTranslation } from 'react-i18next';
import { Settings, ExternalLink, Pencil, Trash2, Plus } from 'lucide-react'; import { Settings, ExternalLink, Pencil, Trash2, Plus } from 'lucide-react';
import { environments } from '../../api'; import { environments } from '../../api';
import type { Environment } from '../../api'; import type { Environment } from '../../api';
import { Breadcrumbs, Button, Card, ContextMenu, DescriptionList, Timestamp, UuidBadge } from '../../ui'; import {
Breadcrumbs,
Button,
Card,
ContextMenu,
DescriptionList,
Timestamp,
UuidBadge,
} from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: string) => void }) { function EnvironmentCard({ env, onDelete }: { env: Environment; onDelete: (id: string) => void }) {
+11 -5
View File
@@ -3,7 +3,14 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { runs } from '../../api'; import { runs } from '../../api';
import type { ScenarioRun, ScenarioRunStep, ScenarioRunStatus } from '../../api'; import type { ScenarioRun, ScenarioRunStep, ScenarioRunStatus } from '../../api';
import { AutoRefreshIndicator, Badge, Table, Timestamp, UuidBadge, type TableColumn } from '../../ui'; import {
AutoRefreshIndicator,
Badge,
Table,
Timestamp,
UuidBadge,
type TableColumn,
} from '../../ui';
import type { BadgeVariant } from '../../ui'; import type { BadgeVariant } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
@@ -37,6 +44,7 @@ export function AllRunsPage() {
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const load = () => { const load = () => {
setLoading(true);
runs runs
.listAll() .listAll()
.then((res) => { .then((res) => {
@@ -48,7 +56,7 @@ export function AllRunsPage() {
}; };
useEffect(() => { useEffect(() => {
setLoading(true); // eslint-disable-next-line react-hooks/set-state-in-effect
load(); load();
pollRef.current = setInterval(load, 10_000); pollRef.current = setInterval(load, 10_000);
return () => { return () => {
@@ -77,9 +85,7 @@ export function AllRunsPage() {
key: 'status', key: 'status',
header: t('runs.col_status'), header: t('runs.col_status'),
width: 110, width: 110,
render: (r) => ( render: (r) => <Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>,
<Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>
),
}, },
{ {
key: 'steps', key: 'steps',
+13 -6
View File
@@ -58,6 +58,8 @@ const LOG_LEVEL_VARIANT: Record<LogLevel, BadgeVariant> = {
error: 'error', error: 'error',
}; };
const FINAL: ScenarioRunStatus[] = ['pass', 'fail'];
export function RunDetailPage() { export function RunDetailPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { id, runId } = useParams<{ id: string; runId: string }>(); const { id, runId } = useParams<{ id: string; runId: string }>();
@@ -76,8 +78,6 @@ export function RunDetailPage() {
const LOG_PAGE_SIZE = 25; const LOG_PAGE_SIZE = 25;
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const FINAL: ScenarioRunStatus[] = ['pass', 'fail'];
const stopPolling = () => { const stopPolling = () => {
if (pollRef.current) { if (pollRef.current) {
clearInterval(pollRef.current); clearInterval(pollRef.current);
@@ -97,8 +97,11 @@ export function RunDetailPage() {
useEffect(() => { useEffect(() => {
if (!id || !runId || polling) return; if (!id || !runId || polling) return;
runs.get(id, runId, debouncedSearch).then(setRun).catch(() => undefined); runs
}, [debouncedSearch]); .get(id, runId, debouncedSearch)
.then(setRun)
.catch(() => undefined);
}, [id, runId, polling, debouncedSearch]);
useEffect(() => { useEffect(() => {
if (!id || !runId) return; if (!id || !runId) return;
@@ -124,8 +127,12 @@ export function RunDetailPage() {
}, 1000); }, 1000);
} }
}) })
.catch((err: Error) => { if (!cancelled) setError(err.message); }) .catch((err: Error) => {
.finally(() => { if (!cancelled) setLoading(false); }); if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { return () => {
cancelled = true; cancelled = true;
+12 -4
View File
@@ -4,7 +4,16 @@ import { useTranslation } from 'react-i18next';
import { Play } from 'lucide-react'; import { Play } from 'lucide-react';
import { scenarios, runs } from '../../api'; import { scenarios, runs } from '../../api';
import type { Scenario, ScenarioRun, ScenarioRunStep, ScenarioRunStatus } from '../../api'; import type { Scenario, ScenarioRun, ScenarioRunStep, ScenarioRunStatus } from '../../api';
import { AutoRefreshIndicator, Badge, Breadcrumbs, Button, Table, Timestamp, UuidBadge, type TableColumn } from '../../ui'; import {
AutoRefreshIndicator,
Badge,
Breadcrumbs,
Button,
Table,
Timestamp,
UuidBadge,
type TableColumn,
} from '../../ui';
import type { BadgeVariant } from '../../ui'; import type { BadgeVariant } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
@@ -50,6 +59,7 @@ export function RunsPage() {
}, [id]); }, [id]);
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load(); load();
pollRef.current = setInterval(load, 10_000); pollRef.current = setInterval(load, 10_000);
return () => { return () => {
@@ -68,9 +78,7 @@ export function RunsPage() {
key: 'status', key: 'status',
header: t('runs.col_status'), header: t('runs.col_status'),
width: 100, width: 100,
render: (r) => ( render: (r) => <Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>,
<Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>
),
}, },
{ {
key: 'steps', key: 'steps',
+12 -23
View File
@@ -2,13 +2,11 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { scenarios, steps } from '../../api'; import { scenarios, steps } from '../../api';
import type { Scenario, StepType } from '../../api'; import type { Scenario } from '../../api';
import type { CreateStepPayload } from '../../api/client'; import type { CreateStepPayload } from '../../api/client';
import { Breadcrumbs, Button, Card, Input, Select, CodeEditor } from '../../ui'; import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
const STEP_TYPES: StepType[] = ['login', 'exec', 'sign'];
export function CreateStepPage() { export function CreateStepPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -17,7 +15,6 @@ export function CreateStepPage() {
const [scenario, setScenario] = useState<Scenario | null>(null); const [scenario, setScenario] = useState<Scenario | null>(null);
const [order, setOrder] = useState('0'); const [order, setOrder] = useState('0');
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [type, setType] = useState<StepType>('exec');
const [execCode, setExecCode] = useState(''); const [execCode, setExecCode] = useState('');
const [validateCode, setValidateCode] = useState(''); const [validateCode, setValidateCode] = useState('');
const [errors, setErrors] = useState<Partial<Record<keyof CreateStepPayload, string>>>({}); const [errors, setErrors] = useState<Partial<Record<keyof CreateStepPayload, string>>>({});
@@ -26,7 +23,10 @@ export function CreateStepPage() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
scenarios.get(id).then(setScenario).catch(() => null); scenarios
.get(id)
.then(setScenario)
.catch(() => null);
}, [id]); }, [id]);
const validate = (): boolean => { const validate = (): boolean => {
@@ -45,7 +45,6 @@ export function CreateStepPage() {
await steps.create(id!, { await steps.create(id!, {
order: Number(order), order: Number(order),
title: title.trim() || undefined, title: title.trim() || undefined,
type,
execCode: execCode.trim() || undefined, execCode: execCode.trim() || undefined,
validateCode: validateCode.trim() || undefined, validateCode: validateCode.trim() || undefined,
}); });
@@ -91,32 +90,22 @@ export function CreateStepPage() {
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
<Select
label={t('steps.form_type')}
value={type}
onChange={(e) => setType(e.target.value as StepType)}
options={STEP_TYPES.map((v) => ({ value: v, label: v }))}
/>
<div className={styles.formField}> <div className={styles.formField}>
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label> <label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
<textarea <CodeEditor
className={styles.textarea}
rows={6}
value={execCode} value={execCode}
onChange={(e) => setExecCode(e.target.value)} onChange={setExecCode}
rows={6}
placeholder={t('steps.form_exec_code_placeholder')} placeholder={t('steps.form_exec_code_placeholder')}
spellCheck={false}
/> />
</div> </div>
<div className={styles.formField}> <div className={styles.formField}>
<label className={styles.fieldLabel}>{t('steps.form_validate_code')}</label> <label className={styles.fieldLabel}>{t('steps.form_validate_code')}</label>
<textarea <CodeEditor
className={styles.textarea}
rows={6}
value={validateCode} value={validateCode}
onChange={(e) => setValidateCode(e.target.value)} onChange={setValidateCode}
rows={6}
placeholder={t('steps.form_validate_code_placeholder')} placeholder={t('steps.form_validate_code_placeholder')}
spellCheck={false}
/> />
</div> </div>
</div> </div>
+4 -23
View File
@@ -2,12 +2,10 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { scenarios, steps } from '../../api'; import { scenarios, steps } from '../../api';
import type { Scenario, ScenarioStep, StepType } from '../../api'; import type { Scenario, ScenarioStep } from '../../api';
import { Breadcrumbs, Button, Card, Input, Select, CodeEditor } from '../../ui'; import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
const STEP_TYPES: StepType[] = ['login', 'exec', 'sign'];
export function EditStepPage() { export function EditStepPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { id, stepId } = useParams<{ id: string; stepId: string }>(); const { id, stepId } = useParams<{ id: string; stepId: string }>();
@@ -17,7 +15,6 @@ export function EditStepPage() {
const [step, setStep] = useState<ScenarioStep | null>(null); const [step, setStep] = useState<ScenarioStep | null>(null);
const [order, setOrder] = useState(''); const [order, setOrder] = useState('');
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [type, setType] = useState<StepType>('exec');
const [execCode, setExecCode] = useState(''); const [execCode, setExecCode] = useState('');
const [validateCode, setValidateCode] = useState(''); const [validateCode, setValidateCode] = useState('');
const [orderError, setOrderError] = useState(''); const [orderError, setOrderError] = useState('');
@@ -33,7 +30,6 @@ export function EditStepPage() {
setStep(st); setStep(st);
setOrder(String(st.order)); setOrder(String(st.order));
setTitle(st.title ?? ''); setTitle(st.title ?? '');
setType(st.type);
setExecCode(st.execCode ?? ''); setExecCode(st.execCode ?? '');
setValidateCode(st.validateCode ?? ''); setValidateCode(st.validateCode ?? '');
}) })
@@ -59,7 +55,6 @@ export function EditStepPage() {
await steps.update(id!, stepId!, { await steps.update(id!, stepId!, {
order: Number(order), order: Number(order),
title: title.trim() || undefined, title: title.trim() || undefined,
type,
execCode: execCode.trim() || undefined, execCode: execCode.trim() || undefined,
validateCode: validateCode.trim() || undefined, validateCode: validateCode.trim() || undefined,
}); });
@@ -110,27 +105,13 @@ export function EditStepPage() {
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
<Select
label={t('steps.form_type')}
value={type}
onChange={(e) => setType(e.target.value as StepType)}
options={STEP_TYPES.map((v) => ({ value: v, label: v }))}
/>
<div className={styles.formField}> <div className={styles.formField}>
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label> <label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
<CodeEditor <CodeEditor value={execCode} onChange={setExecCode} rows={6} />
value={execCode}
onChange={setExecCode}
rows={6}
/>
</div> </div>
<div className={styles.formField}> <div className={styles.formField}>
<label className={styles.fieldLabel}>{t('steps.form_validate_code')}</label> <label className={styles.fieldLabel}>{t('steps.form_validate_code')}</label>
<CodeEditor <CodeEditor value={validateCode} onChange={setValidateCode} rows={6} />
value={validateCode}
onChange={setValidateCode}
rows={6}
/>
</div> </div>
</div> </div>
@@ -6,7 +6,6 @@ import { stringify as yamlStringify } from 'yaml';
import { scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api'; import { scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api';
import type { Scenario, ScenarioStep, ScenarioCredential, Credential } from '../../api'; import type { Scenario, ScenarioStep, ScenarioCredential, Credential } from '../../api';
import { import {
Badge,
Breadcrumbs, Breadcrumbs,
Button, Button,
Card, Card,
@@ -21,12 +20,6 @@ import {
} from '../../ui'; } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
const STEP_TYPE_VARIANT: Record<string, 'info' | 'warning' | 'success'> = {
login: 'success',
exec: 'info',
sign: 'warning',
};
export function ScenarioDetailPage() { export function ScenarioDetailPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -95,8 +88,14 @@ export function ScenarioDetailPage() {
const handleAddCredential = async (e: React.FormEvent) => { const handleAddCredential = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
let valid = true; let valid = true;
if (!addCredId) { setAddCredError(t('scenarios.cred_form_cred_required')); valid = false; } if (!addCredId) {
if (!addAlias.trim()) { setAddAliasError(t('scenarios.cred_form_alias_required')); valid = false; } setAddCredError(t('scenarios.cred_form_cred_required'));
valid = false;
}
if (!addAlias.trim()) {
setAddAliasError(t('scenarios.cred_form_alias_required'));
valid = false;
}
if (!valid) return; if (!valid) return;
setAddCredSaving(true); setAddCredSaving(true);
try { try {
@@ -122,16 +121,13 @@ export function ScenarioDetailPage() {
const stepColumns: TableColumn<ScenarioStep>[] = [ const stepColumns: TableColumn<ScenarioStep>[] = [
{ key: 'order', header: t('scenarios.step_order'), render: (s) => s.order, width: 60 }, { key: 'order', header: t('scenarios.step_order'), render: (s) => s.order, width: 60 },
{
key: 'type',
header: t('scenarios.step_type'),
width: 90,
render: (s) => <Badge variant={STEP_TYPE_VARIANT[s.type] ?? 'neutral'}>{s.type}</Badge>,
},
{ {
key: 'title', key: 'title',
header: t('scenarios.step_title'), header: t('scenarios.step_title'),
render: (s) => s.title ?? <span style={{ color: 'var(--color-text-muted)', fontStyle: 'italic' }}>{'—'}</span>, render: (s) =>
s.title ?? (
<span style={{ color: 'var(--color-text-muted)', fontStyle: 'italic' }}>{'—'}</span>
),
}, },
{ {
key: 'updated', key: 'updated',
@@ -315,42 +311,52 @@ export function ScenarioDetailPage() {
{addCredOpen && ( {addCredOpen && (
<div style={{ marginBottom: 'var(--space-4)' }}> <div style={{ marginBottom: 'var(--space-4)' }}>
<Card className={styles.formCard}> <Card className={styles.formCard}>
<form onSubmit={handleAddCredential} noValidate> <form onSubmit={handleAddCredential} noValidate>
<div className={styles.formFields}> <div className={styles.formFields}>
<Select <Select
label={t('scenarios.cred_form_cred')} label={t('scenarios.cred_form_cred')}
value={addCredId} value={addCredId}
onChange={(e) => { setAddCredId(e.target.value); setAddCredError(''); }} onChange={(e) => {
error={addCredError || undefined} setAddCredId(e.target.value);
options={[ setAddCredError('');
{ value: '', label: t('scenarios.cred_form_cred_placeholder') }, }}
...allCredentials.map((c) => ({ value: String(c.id), label: c.name })), error={addCredError || undefined}
]} options={[
/> { value: '', label: t('scenarios.cred_form_cred_placeholder') },
<Input ...allCredentials.map((c) => ({ value: String(c.id), label: c.name })),
label={t('scenarios.cred_form_alias')} ]}
placeholder={t('scenarios.cred_form_alias_placeholder')} />
value={addAlias} <Input
onChange={(e) => { setAddAlias(e.target.value); setAddAliasError(''); }} label={t('scenarios.cred_form_alias')}
error={addAliasError || undefined} placeholder={t('scenarios.cred_form_alias_placeholder')}
/> value={addAlias}
</div> onChange={(e) => {
<div className={styles.formActions}> setAddAlias(e.target.value);
<Button setAddAliasError('');
type="button" }}
variant="secondary" error={addAliasError || undefined}
size="sm" />
onClick={() => { setAddCredOpen(false); setAddCredId(''); setAddAlias(''); }} </div>
> <div className={styles.formActions}>
{t('scenarios.cred_action_cancel')} <Button
</Button> type="button"
<Button type="submit" size="sm" loading={addCredSaving}> variant="secondary"
{t('scenarios.cred_action_save')} size="sm"
</Button> onClick={() => {
</div> setAddCredOpen(false);
</form> setAddCredId('');
</Card> setAddAlias('');
}}
>
{t('scenarios.cred_action_cancel')}
</Button>
<Button type="submit" size="sm" loading={addCredSaving}>
{t('scenarios.cred_action_save')}
</Button>
</div>
</form>
</Card>
</div> </div>
)} )}
+10 -1
View File
@@ -4,7 +4,16 @@ import { useTranslation } from 'react-i18next';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { sessions } from '../../api'; import { sessions } from '../../api';
import type { Session } from '../../api'; import type { Session } from '../../api';
import { Badge, Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp, UuidBadge } from '../../ui'; import {
Badge,
Breadcrumbs,
Button,
Card,
DescriptionList,
Notification,
Timestamp,
UuidBadge,
} from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
export function SessionDetailPage() { export function SessionDetailPage() {
+9 -1
View File
@@ -4,7 +4,15 @@ import { useTranslation } from 'react-i18next';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { sessions } from '../../api'; import { sessions } from '../../api';
import type { Session } from '../../api'; import type { Session } from '../../api';
import { Badge, Breadcrumbs, Button, Table, type TableColumn, Timestamp, UuidBadge } from '../../ui'; import {
Badge,
Breadcrumbs,
Button,
Table,
type TableColumn,
Timestamp,
UuidBadge,
} from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
export function SessionsPage() { export function SessionsPage() {
+1 -5
View File
@@ -121,11 +121,7 @@ export function EditSnippetPage() {
</div> </div>
</div> </div>
<div className={styles.formActions}> <div className={styles.formActions}>
<Button <Button type="button" variant="secondary" onClick={() => navigate(`/snippets/${id}`)}>
type="button"
variant="secondary"
onClick={() => navigate(`/snippets/${id}`)}
>
{t('snippets.action_cancel')} {t('snippets.action_cancel')}
</Button> </Button>
<Button type="submit" disabled={saving}> <Button type="submit" disabled={saving}>
@@ -6,7 +6,15 @@ import { CodeBlock } from '../../ui';
import { stringify as yamlStringify } from 'yaml'; import { stringify as yamlStringify } from 'yaml';
import { snippets } from '../../api'; import { snippets } from '../../api';
import type { Snippet } from '../../api'; import type { Snippet } from '../../api';
import { Breadcrumbs, Button, Card, DescriptionList, Notification, Timestamp, UuidBadge } from '../../ui'; import {
Breadcrumbs,
Button,
Card,
DescriptionList,
Notification,
Timestamp,
UuidBadge,
} from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
export function SnippetDetailPage() { export function SnippetDetailPage() {
+3 -13
View File
@@ -8,13 +8,7 @@ import type { Snippet } from '../../api';
import { Breadcrumbs, Button, Card, ContextMenu, Timestamp, UuidBadge } from '../../ui'; import { Breadcrumbs, Button, Card, ContextMenu, Timestamp, UuidBadge } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
function SnippetCard({ function SnippetCard({ snippet, onDelete }: { snippet: Snippet; onDelete: (id: string) => void }) {
snippet,
onDelete,
}: {
snippet: Snippet;
onDelete: (id: string) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -62,9 +56,7 @@ function SnippetCard({
footer={footer} footer={footer}
onClick={() => navigate(`/snippets/${snippet.id}`)} onClick={() => navigate(`/snippets/${snippet.id}`)}
> >
{snippet.description && ( {snippet.description && <p className={styles.muted}>{snippet.description}</p>}
<p className={styles.muted}>{snippet.description}</p>
)}
</Card> </Card>
); );
} }
@@ -148,9 +140,7 @@ export function SnippetsPage() {
{items.map((s) => ( {items.map((s) => (
<SnippetCard key={s.id} snippet={s} onDelete={handleDelete} /> <SnippetCard key={s.id} snippet={s} onDelete={handleDelete} />
))} ))}
{items.length === 0 && ( {items.length === 0 && <p className={styles.muted}>{t('snippets.empty')}</p>}
<p className={styles.muted}>{t('snippets.empty')}</p>
)}
<AddSnippetCard /> <AddSnippetCard />
</div> </div>
)} )}
@@ -6,7 +6,11 @@ export interface AutoRefreshIndicatorProps {
label?: string; label?: string;
} }
export function AutoRefreshIndicator({ active, pulseKey, label = 'Live' }: AutoRefreshIndicatorProps) { export function AutoRefreshIndicator({
active,
pulseKey,
label = 'Live',
}: AutoRefreshIndicatorProps) {
return ( return (
<div <div
className={[styles.root, active ? styles.active : styles.inactive].join(' ')} className={[styles.root, active ? styles.active : styles.inactive].join(' ')}
@@ -15,9 +19,7 @@ export function AutoRefreshIndicator({ active, pulseKey, label = 'Live' }: AutoR
aria-live="polite" aria-live="polite"
> >
<span className={styles.dot}> <span className={styles.dot}>
{pulseKey !== undefined && pulseKey > 0 && ( {pulseKey !== undefined && pulseKey > 0 && <span key={pulseKey} className={styles.ring} />}
<span key={pulseKey} className={styles.ring} />
)}
</span> </span>
<span className={styles.label}>{label}</span> <span className={styles.label}>{label}</span>
</div> </div>
+1 -1
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import hljs, { escapeHtml } from '../../lib/hljs'; import hljs from '../../lib/hljs';
import styles from './CodeBlock.module.css'; import styles from './CodeBlock.module.css';
export interface CodeBlockProps { export interface CodeBlockProps {
+3 -7
View File
@@ -12,13 +12,13 @@ export interface CodeEditorProps {
function useMonacoTheme() { function useMonacoTheme() {
const [theme, setTheme] = useState(() => const [theme, setTheme] = useState(() =>
document.documentElement.getAttribute('data-theme') === 'dark' ? 'vs-dark' : 'vs-light' document.documentElement.getAttribute('data-theme') === 'dark' ? 'vs-dark' : 'vs-light',
); );
useEffect(() => { useEffect(() => {
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
setTheme( setTheme(
document.documentElement.getAttribute('data-theme') === 'dark' ? 'vs-dark' : 'vs-light' document.documentElement.getAttribute('data-theme') === 'dark' ? 'vs-dark' : 'vs-light',
); );
}); });
observer.observe(document.documentElement, { observer.observe(document.documentElement, {
@@ -49,11 +49,7 @@ export function CodeEditor({
return ( return (
<div <div
className={[ className={[styles.wrapper, focused ? styles.focused : '', error ? styles.hasError : '']
styles.wrapper,
focused ? styles.focused : '',
error ? styles.hasError : '',
]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
style={{ height }} style={{ height }}
+4 -2
View File
@@ -1,8 +1,10 @@
import React, { useId } from 'react'; import React, { useId } from 'react';
import styles from './Textarea.module.css'; import styles from './Textarea.module.css';
export interface TextareaProps export interface TextareaProps extends Omit<
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'id'> { React.TextareaHTMLAttributes<HTMLTextAreaElement>,
'id'
> {
label?: string; label?: string;
hint?: string; hint?: string;
error?: string; error?: string;
+2 -1
View File
@@ -13,7 +13,8 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/environments': 'http://localhost:13000', '/environments': 'http://localhost:13000',
'/credentials': 'http://localhost:13000', '/snippets': 'http://localhost:13000', '/snippets': 'http://localhost:13000', '/credentials': 'http://localhost:13000',
'/snippets': 'http://localhost:13000',
'/sessions': 'http://localhost:13000', '/sessions': 'http://localhost:13000',
'/scenarios': 'http://localhost:13000', '/scenarios': 'http://localhost:13000',
'/keys': 'http://localhost:13000', '/keys': 'http://localhost:13000',
@@ -65,7 +65,7 @@ export class CodeExecutorService {
const snippetMap: Record<string, string> = snippets ?? {}; const snippetMap: Record<string, string> = snippets ?? {};
// pageHelpers is referenced by runSnippet, so we declare it as a var first. // pageHelpers is referenced by runSnippet, so we declare it as a var first.
// eslint-disable-next-line prefer-const
let pageHelpers: Record<string, unknown>; let pageHelpers: Record<string, unknown>;
const fakeConsole = { const fakeConsole = {
@@ -109,7 +109,10 @@ export class CodeExecutorService {
* *
* Example: await helpers.runSnippet('clickLoginButton', '#submit') * Example: await helpers.runSnippet('clickLoginButton', '#submit')
*/ */
runSnippet: async (name: string, ...args: unknown[]): Promise<unknown> => { runSnippet: async (
name: string,
...args: unknown[]
): Promise<unknown> => {
const snippetCode = snippetMap[name]; const snippetCode = snippetMap[name];
if (snippetCode == null) { if (snippetCode == null) {
throw new Error(`Snippet "${name}" not found`); throw new Error(`Snippet "${name}" not found`);
@@ -136,7 +139,13 @@ export class CodeExecutorService {
`return (async (page, context, helpers) => { ${code} })(page, context, helpers)`, `return (async (page, context, helpers) => { ${code} })(page, context, helpers)`,
); );
this.logger.debug("Executing user code"); this.logger.debug("Executing user code");
const execResult = await fn(page, context, pageHelpers, fakeConsole, result); const execResult = await fn(
page,
context,
pageHelpers,
fakeConsole,
result,
);
return { result: execResult }; return { result: execResult };
} catch (err) { } catch (err) {
throw new InternalServerErrorException( throw new InternalServerErrorException(
+21 -4
View File
@@ -10,7 +10,12 @@ import {
PaginatedResult, PaginatedResult,
} from "../common/dto/pagination.dto"; } from "../common/dto/pagination.dto";
export type CredentialOrderBy = "id" | "name" | "lastUsedAt" | "createdAt" | "updatedAt"; export type CredentialOrderBy =
| "id"
| "name"
| "lastUsedAt"
| "createdAt"
| "updatedAt";
@Injectable() @Injectable()
export class CredentialService { export class CredentialService {
@@ -44,7 +49,10 @@ export class CredentialService {
return credential; return credential;
} }
async update(id: string, dto: UpdateCredentialDto): Promise<CredentialEntity> { async update(
id: string,
dto: UpdateCredentialDto,
): Promise<CredentialEntity> {
const credential = await this.findOne(id); const credential = await this.findOne(id);
Object.assign(credential, dto); Object.assign(credential, dto);
return this.repo.save(credential); return this.repo.save(credential);
@@ -56,7 +64,12 @@ export class CredentialService {
} }
exportCredential(credential: CredentialEntity): CredentialExportDto { exportCredential(credential: CredentialEntity): CredentialExportDto {
return { kind: "credential", id: credential.id, name: credential.name, data: credential.data }; return {
kind: "credential",
id: credential.id,
name: credential.name,
data: credential.data,
};
} }
async importCredential(dto: CredentialExportDto): Promise<CredentialEntity> { async importCredential(dto: CredentialExportDto): Promise<CredentialEntity> {
@@ -67,7 +80,11 @@ export class CredentialService {
return this.repo.save(existing); return this.repo.save(existing);
} }
return this.repo.save( return this.repo.save(
this.repo.create({ id: dto.id, name: dto.name, data: dto.data ?? null }), this.repo.create({
id: dto.id,
name: dto.name,
data: dto.data ?? null,
}),
); );
} }
return this.repo.save( return this.repo.save(
@@ -1,5 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator"; import {
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from "class-validator";
export class CredentialExportDto { export class CredentialExportDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@@ -4,7 +4,7 @@ import { IsNotEmpty, IsString, IsUUID } from "class-validator";
export class AddScenarioCredentialDto { export class AddScenarioCredentialDto {
@ApiProperty({ example: "uuid-here" }) @ApiProperty({ example: "uuid-here" })
@IsUUID() @IsUUID()
credentialId: string credentialId: string;
@ApiProperty({ example: "api_key" }) @ApiProperty({ example: "api_key" })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@@ -1,13 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from "class-validator";
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Min,
} from "class-validator";
import { StepType } from "../scenario-step.entity";
export class CreateScenarioStepDto { export class CreateScenarioStepDto {
@ApiPropertyOptional({ example: "Check login page title" }) @ApiPropertyOptional({ example: "Check login page title" })
@@ -20,12 +12,9 @@ export class CreateScenarioStepDto {
@Min(0) @Min(0)
order: number; order: number;
@ApiProperty({ enum: ["login", "exec", "sign"], example: "exec" })
@IsIn(["login", "exec", "sign"])
type: StepType;
@ApiPropertyOptional({ @ApiPropertyOptional({
description: "Session name (deprecated — browser is created automatically per run).", description:
"Session name (deprecated — browser is created automatically per run).",
example: "my-session", example: "my-session",
}) })
@IsOptional() @IsOptional()
@@ -2,7 +2,6 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { import {
IsArray, IsArray,
IsIn,
IsInt, IsInt,
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
@@ -11,7 +10,6 @@ import {
Min, Min,
ValidateNested, ValidateNested,
} from "class-validator"; } from "class-validator";
import { StepType } from "../scenario-step.entity";
export class ScenarioStepExportDto { export class ScenarioStepExportDto {
@ApiProperty() @ApiProperty()
@@ -19,10 +17,6 @@ export class ScenarioStepExportDto {
@Min(0) @Min(0)
order: number; order: number;
@ApiProperty({ enum: ["login", "exec", "sign"] })
@IsIn(["login", "exec", "sign"])
type: StepType;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsString() @IsString()
@@ -1,13 +1,5 @@
import { ApiPropertyOptional } from "@nestjs/swagger"; import { ApiPropertyOptional } from "@nestjs/swagger";
import { import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from "class-validator";
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Min,
} from "class-validator";
import { StepType } from "../scenario-step.entity";
export class UpdateScenarioStepDto { export class UpdateScenarioStepDto {
@ApiPropertyOptional({ example: "Check login page title" }) @ApiPropertyOptional({ example: "Check login page title" })
@@ -21,11 +13,6 @@ export class UpdateScenarioStepDto {
@Min(0) @Min(0)
order?: number; order?: number;
@ApiPropertyOptional({ enum: ["login", "exec", "sign"] })
@IsOptional()
@IsIn(["login", "exec", "sign"])
type?: StepType;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsString() @IsString()
+43 -192
View File
@@ -12,11 +12,8 @@ import { ScenarioRunStepEntity } from "./scenario-run-step.entity";
import { ScenarioRunLogEntity } from "./scenario-run-log.entity"; import { ScenarioRunLogEntity } from "./scenario-run-log.entity";
import { ScenarioStepEntity } from "./scenario-step.entity"; import { ScenarioStepEntity } from "./scenario-step.entity";
import type { ScriptLogger } from "../code-executor/code-executor.service"; import type { ScriptLogger } from "../code-executor/code-executor.service";
import { AuthService } from "../auth/auth.service";
import { CodeExecutorService } from "../code-executor/code-executor.service"; import { CodeExecutorService } from "../code-executor/code-executor.service";
import { ScenarioService } from "./scenario.service"; import { ScenarioService } from "./scenario.service";
import { EnvironmentService } from "../environment/environment.service";
import type { EnvironmentUrls } from "../environment/environment.entity";
import { SnippetService } from "../snippet/snippet.service"; import { SnippetService } from "../snippet/snippet.service";
interface ValidateResult { interface ValidateResult {
@@ -37,8 +34,6 @@ export class ScenarioSchedulerService {
private readonly runBrowsers = new Map<string, BrowserHandle>(); private readonly runBrowsers = new Map<string, BrowserHandle>();
// Cache credential maps per run (built once when a run starts) // Cache credential maps per run (built once when a run starts)
private readonly runCredentials = new Map<string, Record<string, unknown>>(); private readonly runCredentials = new Map<string, Record<string, unknown>>();
// Cache environment URLs per run (resolved from the first login step)
private readonly runEnvironments = new Map<string, EnvironmentUrls>();
// Cache snippet code map per run (built once when a run starts) // Cache snippet code map per run (built once when a run starts)
private readonly runSnippets = new Map<string, Record<string, string>>(); private readonly runSnippets = new Map<string, Record<string, string>>();
@@ -49,10 +44,8 @@ export class ScenarioSchedulerService {
private readonly runStepRepo: Repository<ScenarioRunStepEntity>, private readonly runStepRepo: Repository<ScenarioRunStepEntity>,
@InjectRepository(ScenarioRunLogEntity) @InjectRepository(ScenarioRunLogEntity)
private readonly runLogRepo: Repository<ScenarioRunLogEntity>, private readonly runLogRepo: Repository<ScenarioRunLogEntity>,
private readonly authService: AuthService,
private readonly codeExecutor: CodeExecutorService, private readonly codeExecutor: CodeExecutorService,
private readonly scenarioService: ScenarioService, private readonly scenarioService: ScenarioService,
private readonly environmentService: EnvironmentService,
private readonly snippetService: SnippetService, private readonly snippetService: SnippetService,
) {} ) {}
@@ -92,30 +85,6 @@ export class ScenarioSchedulerService {
}; };
} }
/**
* Finds the first login step for a scenario and returns the environment URLs
* for the environment named in that step's execCode. Returns null if there is
* no login step or the environment cannot be found.
*/
private async resolveRunEnvironment(
scenarioId: string,
): Promise<EnvironmentUrls | null> {
try {
const scenario = await this.scenarioService.findOne(scenarioId);
const loginStep = scenario.steps.find((s) => s.type === "login");
if (!loginStep?.execCode) return null;
const params = JSON.parse(loginStep.execCode) as {
environmentName?: string;
};
if (!params.environmentName) return null;
const { data } = await this.environmentService.findAll({});
const env = data.find((e) => e.name === params.environmentName);
return env?.urls ?? null;
} catch {
return null;
}
}
// ── Job: pick up pending runs and process each to completion ───────────── // ── Job: pick up pending runs and process each to completion ─────────────
@Interval(1000) @Interval(1000)
@@ -130,15 +99,12 @@ export class ScenarioSchedulerService {
// Pre-load credential map for the scenario // Pre-load credential map for the scenario
const credMap = await this.scenarioService const credMap = await this.scenarioService
.buildCredentialMap(run.scenarioId) .buildCredentialMap(run.scenarioId)
.catch(() => ({} as Record<string, unknown>)); .catch(() => ({}) as Record<string, unknown>);
this.runCredentials.set(run.id, credMap); this.runCredentials.set(run.id, credMap);
// Resolve environment from the first login step (best-effort)
const envUrls = await this.resolveRunEnvironment(run.scenarioId);
if (envUrls) this.runEnvironments.set(run.id, envUrls);
// Pre-load snippet map // Pre-load snippet map
const snippetMap = await this.snippetService const snippetMap = await this.snippetService
.buildSnippetMap() .buildSnippetMap()
.catch(() => ({} as Record<string, string>)); .catch(() => ({}) as Record<string, string>);
this.runSnippets.set(run.id, snippetMap); this.runSnippets.set(run.id, snippetMap);
const traceId = crypto.randomUUID(); const traceId = crypto.randomUUID();
void traceStorage.run({ traceId }, () => void traceStorage.run({ traceId }, () =>
@@ -176,7 +142,6 @@ export class ScenarioSchedulerService {
} finally { } finally {
this.activeRuns.delete(runId); this.activeRuns.delete(runId);
this.runCredentials.delete(runId); this.runCredentials.delete(runId);
this.runEnvironments.delete(runId);
this.runSnippets.delete(runId); this.runSnippets.delete(runId);
} }
} }
@@ -191,13 +156,45 @@ export class ScenarioSchedulerService {
); );
try { try {
if (step.type === "login") { if (!step.execCode) throw new Error("step has no execCode");
await this.executeLoginStep(stepRun, step); this.codeExecutor.validate(step.execCode);
} else if (step.type === "sign") { const { page, context } = await this.getOrCreateBrowserHandle(
await this.executeSignStep(stepRun, step); stepRun.runId,
} else { );
await this.executeExecStep(stepRun, step); const getStepOutput = this.makeGetStepOutput(stepRun);
const creds = this.runCredentials.get(stepRun.runId);
const snips = this.runSnippets.get(stepRun.runId);
const { result: execOutput } = await this.codeExecutor.execute(
page,
context,
step.execCode,
this.stepLogger(stepRun.id, stepRun.runId),
getStepOutput,
creds,
undefined,
snips,
);
if (step.validateCode) {
this.codeExecutor.validate(step.validateCode);
const { result } = await this.codeExecutor.execute(
page,
context,
step.validateCode,
this.stepLogger(stepRun.id, stepRun.runId),
getStepOutput,
creds,
undefined,
snips,
execOutput,
);
const vr = this.parseValidateResult(result);
if (!vr.success) throw new Error(vr.description ?? "Validation failed");
await this.passStepRun(stepRun, vr.description ?? null, execOutput);
return;
} }
await this.passStepRun(stepRun, null, execOutput);
} catch (err) { } catch (err) {
const msg = (err as Error).message ?? String(err); const msg = (err as Error).message ?? String(err);
this.logger.error(`StepRun #${stepRun.id} threw: ${msg}`); this.logger.error(`StepRun #${stepRun.id} threw: ${msg}`);
@@ -207,7 +204,9 @@ export class ScenarioSchedulerService {
// ── Shared browser per run ───────────────────────────────────────────────── // ── Shared browser per run ─────────────────────────────────────────────────
private async getOrCreateBrowserHandle(runId: string): Promise<BrowserHandle> { private async getOrCreateBrowserHandle(
runId: string,
): Promise<BrowserHandle> {
const existing = this.runBrowsers.get(runId); const existing = this.runBrowsers.get(runId);
if (existing) return existing; if (existing) return existing;
@@ -238,154 +237,6 @@ export class ScenarioSchedulerService {
} }
} }
// ── Login step ─────────────────────────────────────────────────────────────
private async executeLoginStep(
stepRun: ScenarioRunStepEntity,
step: ScenarioStepEntity,
): Promise<void> {
// execCode must be a JSON object: { "keyId": "...", "environmentName": "..." }
let params: { keyId: string; environmentName: string };
try {
params = JSON.parse(step.execCode ?? "{}");
} catch {
throw new Error(
"login step execCode must be valid JSON with keyId and environmentName",
);
}
if (!params.keyId || !params.environmentName) {
throw new Error(
"login step execCode must include keyId and environmentName",
);
}
const loginResult = await this.authService.login(
params.keyId,
params.environmentName,
step.sessionName ?? undefined,
);
this.logger.log(
`StepRun #${stepRun.id}: login OK, session=${loginResult.sessionName}`,
);
if (step.validateCode) {
const { page, context } = await this.getOrCreateBrowserHandle(
stepRun.runId,
);
this.codeExecutor.validate(step.validateCode);
const { result } = await this.codeExecutor.execute(
page,
context,
step.validateCode,
this.stepLogger(stepRun.id, stepRun.runId),
this.makeGetStepOutput(stepRun),
this.runCredentials.get(stepRun.runId),
this.runEnvironments.get(stepRun.runId),
this.runSnippets.get(stepRun.runId),
null,
);
const vr = this.parseValidateResult(result);
if (!vr.success) throw new Error(vr.description ?? "Validation failed");
await this.passStepRun(stepRun, vr.description ?? null);
return;
}
await this.passStepRun(stepRun, null);
}
// ── Exec step ──────────────────────────────────────────────────────────────
private async executeExecStep(
stepRun: ScenarioRunStepEntity,
step: ScenarioStepEntity,
): Promise<void> {
if (!step.execCode) throw new Error("exec step has no execCode");
this.codeExecutor.validate(step.execCode);
const { page, context } = await this.getOrCreateBrowserHandle(
stepRun.runId,
);
const getStepOutput = this.makeGetStepOutput(stepRun);
const creds = this.runCredentials.get(stepRun.runId);
const env = this.runEnvironments.get(stepRun.runId);
const snips = this.runSnippets.get(stepRun.runId);
const { result: execOutput } = await this.codeExecutor.execute(
page,
context,
step.execCode,
this.stepLogger(stepRun.id, stepRun.runId),
getStepOutput,
creds,
env,
snips,
);
this.logger.log(`StepRun #${stepRun.id}: exec OK`);
if (step.validateCode) {
this.codeExecutor.validate(step.validateCode);
const { result } = await this.codeExecutor.execute(
page,
context,
step.validateCode,
this.stepLogger(stepRun.id, stepRun.runId),
getStepOutput,
creds,
env,
snips,
execOutput,
);
const vr = this.parseValidateResult(result);
if (!vr.success) throw new Error(vr.description ?? "Validation failed");
await this.passStepRun(stepRun, vr.description ?? null, execOutput);
return;
}
await this.passStepRun(stepRun, null, execOutput);
}
// ── Sign step ──────────────────────────────────────────────────────────────
private async executeSignStep(
stepRun: ScenarioRunStepEntity,
step: ScenarioStepEntity,
): Promise<void> {
// execCode must be a JSON object: { "keyId": "..." }
let params: { keyId: string };
try {
params = JSON.parse(step.execCode ?? "{}");
} catch {
throw new Error("sign step execCode must be valid JSON with keyId");
}
if (!params.keyId) throw new Error("sign step execCode must include keyId");
const { page, context } = await this.getOrCreateBrowserHandle(
stepRun.runId,
);
await this.authService.signWithKey(params.keyId, page);
this.logger.log(`StepRun #${stepRun.id}: sign OK`);
if (step.validateCode) {
this.codeExecutor.validate(step.validateCode);
const { result } = await this.codeExecutor.execute(
page,
context,
step.validateCode,
this.stepLogger(stepRun.id, stepRun.runId),
this.makeGetStepOutput(stepRun),
this.runCredentials.get(stepRun.runId),
this.runEnvironments.get(stepRun.runId),
this.runSnippets.get(stepRun.runId),
null,
);
const vr = this.parseValidateResult(result);
if (!vr.success) throw new Error(vr.description ?? "Validation failed");
await this.passStepRun(stepRun, vr.description ?? null);
return;
}
await this.passStepRun(stepRun, null);
}
// ── Validation helper ────────────────────────────────────────────────────── // ── Validation helper ──────────────────────────────────────────────────────
private parseValidateResult(raw: unknown): ValidateResult { private parseValidateResult(raw: unknown): ValidateResult {
@@ -9,8 +9,6 @@ import {
} from "typeorm"; } from "typeorm";
import { ScenarioEntity } from "./scenario.entity"; import { ScenarioEntity } from "./scenario.entity";
export type StepType = "login" | "exec" | "sign";
@Entity("scenario_steps") @Entity("scenario_steps")
export class ScenarioStepEntity { export class ScenarioStepEntity {
@PrimaryGeneratedColumn("uuid") @PrimaryGeneratedColumn("uuid")
@@ -28,9 +26,6 @@ export class ScenarioStepEntity {
@Column({ default: 0 }) @Column({ default: 0 })
order: number; order: number;
@Column({ type: "text" })
type: StepType;
@Column({ type: "text", nullable: true }) @Column({ type: "text", nullable: true })
title: string | null; title: string | null;
+11 -3
View File
@@ -51,7 +51,9 @@ export class ScenarioController {
} }
@Get("runs") @Get("runs")
@ApiOperation({ summary: "List runs across all scenarios (paginated, filterable by status)" }) @ApiOperation({
summary: "List runs across all scenarios (paginated, filterable by status)",
})
@ApiResponse({ status: 200 }) @ApiResponse({ status: 200 })
findAllRuns(@Query() query: RunsQueryDto) { findAllRuns(@Query() query: RunsQueryDto) {
return this.scenarioService.findAllRuns(query); return this.scenarioService.findAllRuns(query);
@@ -155,7 +157,10 @@ export class ScenarioController {
@ApiOperation({ summary: "Add a credential to a scenario with an alias" }) @ApiOperation({ summary: "Add a credential to a scenario with an alias" })
@ApiResponse({ status: 201, description: "Credential added" }) @ApiResponse({ status: 201, description: "Credential added" })
@ApiResponse({ status: 404, description: "Scenario or credential not found" }) @ApiResponse({ status: 404, description: "Scenario or credential not found" })
@ApiResponse({ status: 409, description: "Alias already used in this scenario" }) @ApiResponse({
status: 409,
description: "Alias already used in this scenario",
})
addScenarioCredential( addScenarioCredential(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: AddScenarioCredentialDto, @Body() dto: AddScenarioCredentialDto,
@@ -167,7 +172,10 @@ export class ScenarioController {
@HttpCode(204) @HttpCode(204)
@ApiOperation({ summary: "Remove a credential from a scenario" }) @ApiOperation({ summary: "Remove a credential from a scenario" })
@ApiResponse({ status: 204 }) @ApiResponse({ status: 204 })
@ApiResponse({ status: 404, description: "Scenario or credential assignment not found" }) @ApiResponse({
status: 404,
description: "Scenario or credential assignment not found",
})
removeScenarioCredential( removeScenarioCredential(
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Param("scCredId", ParseUUIDPipe) scCredId: string, @Param("scCredId", ParseUUIDPipe) scCredId: string,
+16 -4
View File
@@ -1,4 +1,8 @@
import { ConflictException, Injectable, NotFoundException } from "@nestjs/common"; import {
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { Like, Repository } from "typeorm"; import { Like, Repository } from "typeorm";
import { ScenarioEntity } from "./scenario.entity"; import { ScenarioEntity } from "./scenario.entity";
@@ -66,7 +70,11 @@ export class ScenarioService {
async findOne(id: string): Promise<ScenarioEntity> { async findOne(id: string): Promise<ScenarioEntity> {
const scenario = await this.scenarioRepo.findOne({ const scenario = await this.scenarioRepo.findOne({
where: { id }, where: { id },
relations: ["steps", "scenarioCredentials", "scenarioCredentials.credential"], relations: [
"steps",
"scenarioCredentials",
"scenarioCredentials.credential",
],
order: { steps: { order: "ASC" } }, order: { steps: { order: "ASC" } },
}); });
if (!scenario) throw new NotFoundException(`Scenario ${id} not found`); if (!scenario) throw new NotFoundException(`Scenario ${id} not found`);
@@ -187,7 +195,9 @@ export class ScenarioService {
* Builds a map of alias → parsed credential data for use in code execution. * Builds a map of alias → parsed credential data for use in code execution.
* Returns null values for credentials with no data. * Returns null values for credentials with no data.
*/ */
async buildCredentialMap(scenarioId: string): Promise<Record<string, unknown>> { async buildCredentialMap(
scenarioId: string,
): Promise<Record<string, unknown>> {
const scs = await this.findScenarioCredentials(scenarioId); const scs = await this.findScenarioCredentials(scenarioId);
const map: Record<string, unknown> = {}; const map: Record<string, unknown> = {};
for (const sc of scs) { for (const sc of scs) {
@@ -227,7 +237,9 @@ export class ScenarioService {
async findAllRuns( async findAllRuns(
query: RunsQueryDto, query: RunsQueryDto,
): Promise<PaginatedResult<ScenarioRunEntity & { scenario: ScenarioEntity }>> { ): Promise<
PaginatedResult<ScenarioRunEntity & { scenario: ScenarioEntity }>
> {
const page = query.page ?? 1; const page = query.page ?? 1;
const limit = query.limit ?? 20; const limit = query.limit ?? 20;
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};
+6 -2
View File
@@ -7,12 +7,16 @@ export class CreateSnippetDto {
@IsNotEmpty() @IsNotEmpty()
name: string; name: string;
@ApiPropertyOptional({ example: "Clicks the login button and waits for navigation" }) @ApiPropertyOptional({
example: "Clicks the login button and waits for navigation",
})
@IsOptional() @IsOptional()
@IsString() @IsString()
description?: string; description?: string;
@ApiProperty({ example: "await page.click('#login-btn');\nawait page.waitForNavigation();" }) @ApiProperty({
example: "await page.click('#login-btn');\nawait page.waitForNavigation();",
})
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
code: string; code: string;
+7 -1
View File
@@ -1,5 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator"; import {
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from "class-validator";
export class SnippetExportDto { export class SnippetExportDto {
@ApiPropertyOptional() @ApiPropertyOptional()
+2 -1
View File
@@ -58,7 +58,8 @@ export class SnippetService {
const snippet = await this.findOne(id); const snippet = await this.findOne(id);
if (dto.name && dto.name !== snippet.name) { if (dto.name && dto.name !== snippet.name) {
const conflict = await this.repo.findOneBy({ name: dto.name }); const conflict = await this.repo.findOneBy({ name: dto.name });
if (conflict) throw new ConflictException(`Snippet "${dto.name}" already exists`); if (conflict)
throw new ConflictException(`Snippet "${dto.name}" already exists`);
} }
Object.assign(snippet, dto); Object.assign(snippet, dto);
return this.repo.save(snippet); return this.repo.save(snippet);