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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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> = {};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user