chore: apply code formatting and linting

- format long import statements with consistent line wrapping
- apply consistent indentation across client and server modules
- remove generated tsconfig.tsbuildinfo file
This commit is contained in:
2026-04-14 22:54:03 +03:00
parent a71be39076
commit e66e53c817
57 changed files with 851 additions and 478 deletions
+1
View File
@@ -12,3 +12,4 @@ server/dist
client/dist client/dist
*storybook.log *storybook.log
storybook-static storybook-static
tsconfig.tsbuildinfo
+2
View File
@@ -8,6 +8,7 @@
"preview": "vite preview", "preview": "vite preview",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build", "build-storybook": "storybook build",
"test:compile": "tsc --noEmit",
"test:storybook": "vitest run --project storybook", "test:storybook": "vitest run --project storybook",
"lint": "eslint src .storybook", "lint": "eslint src .storybook",
"lint:fix": "eslint src .storybook --fix", "lint:fix": "eslint src .storybook --fix",
@@ -15,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@tanstack/react-query": "^5.99.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"i18next": "^26.0.4", "i18next": "^26.0.4",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
+16 -8
View File
@@ -11,13 +11,19 @@ const EnvironmentsPage = lazy(() =>
import('./pages/environment/EnvironmentsPage').then((m) => ({ default: m.EnvironmentsPage })), import('./pages/environment/EnvironmentsPage').then((m) => ({ default: m.EnvironmentsPage })),
); );
const EnvironmentDetailPage = lazy(() => const EnvironmentDetailPage = lazy(() =>
import('./pages/environment/EnvironmentDetailPage').then((m) => ({ default: m.EnvironmentDetailPage })), import('./pages/environment/EnvironmentDetailPage').then((m) => ({
default: m.EnvironmentDetailPage,
})),
); );
const CreateEnvironmentPage = lazy(() => const CreateEnvironmentPage = lazy(() =>
import('./pages/environment/CreateEnvironmentPage').then((m) => ({ default: m.CreateEnvironmentPage })), import('./pages/environment/CreateEnvironmentPage').then((m) => ({
default: m.CreateEnvironmentPage,
})),
); );
const EditEnvironmentPage = lazy(() => const EditEnvironmentPage = lazy(() =>
import('./pages/environment/EditEnvironmentPage').then((m) => ({ default: m.EditEnvironmentPage })), import('./pages/environment/EditEnvironmentPage').then((m) => ({
default: m.EditEnvironmentPage,
})),
); );
const SessionsPage = lazy(() => const SessionsPage = lazy(() =>
@@ -46,9 +52,7 @@ const EditStepPage = lazy(() =>
import('./pages/scenario/EditStepPage').then((m) => ({ default: m.EditStepPage })), import('./pages/scenario/EditStepPage').then((m) => ({ default: m.EditStepPage })),
); );
const RunsPage = lazy(() => const RunsPage = lazy(() => import('./pages/run/RunsPage').then((m) => ({ default: m.RunsPage })));
import('./pages/run/RunsPage').then((m) => ({ default: m.RunsPage })),
);
const RunDetailPage = lazy(() => const RunDetailPage = lazy(() =>
import('./pages/run/RunDetailPage').then((m) => ({ default: m.RunDetailPage })), import('./pages/run/RunDetailPage').then((m) => ({ default: m.RunDetailPage })),
); );
@@ -60,13 +64,17 @@ const CredentialsPage = lazy(() =>
import('./pages/credential/CredentialsPage').then((m) => ({ default: m.CredentialsPage })), import('./pages/credential/CredentialsPage').then((m) => ({ default: m.CredentialsPage })),
); );
const CreateCredentialPage = lazy(() => const CreateCredentialPage = lazy(() =>
import('./pages/credential/CreateCredentialPage').then((m) => ({ default: m.CreateCredentialPage })), import('./pages/credential/CreateCredentialPage').then((m) => ({
default: m.CreateCredentialPage,
})),
); );
const EditCredentialPage = lazy(() => const EditCredentialPage = lazy(() =>
import('./pages/credential/EditCredentialPage').then((m) => ({ default: m.EditCredentialPage })), import('./pages/credential/EditCredentialPage').then((m) => ({ default: m.EditCredentialPage })),
); );
const CredentialDetailPage = lazy(() => const CredentialDetailPage = lazy(() =>
import('./pages/credential/CredentialDetailPage').then((m) => ({ default: m.CredentialDetailPage })), import('./pages/credential/CredentialDetailPage').then((m) => ({
default: m.CredentialDetailPage,
})),
); );
const SnippetsPage = lazy(() => const SnippetsPage = lazy(() =>
+7 -2
View File
@@ -15,7 +15,10 @@ import { emitApiErrorToast } from '../lib/toast-events';
// API calls are versioned under /api/v1 by default. // API calls are versioned under /api/v1 by default.
// Set VITE_API_URL (for example, http://localhost:13000/api/v1) to use a different origin. // Set VITE_API_URL (for example, http://localhost:13000/api/v1) to use a different origin.
const BASE_URL = ((import.meta.env.VITE_API_URL as string | undefined) ?? '/api/v1').replace(/\/$/, ''); const BASE_URL = ((import.meta.env.VITE_API_URL as string | undefined) ?? '/api/v1').replace(
/\/$/,
'',
);
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response; let res: Response;
@@ -90,7 +93,9 @@ export const snippets = {
get(id: string): Promise<Snippet> { get(id: string): Promise<Snippet> {
return request(`/snippets/${id}`); return request(`/snippets/${id}`);
}, },
create(payload: Pick<Snippet, 'alias' | 'title' | 'code'> & { description?: string }): Promise<Snippet> { create(
payload: Pick<Snippet, 'alias' | 'title' | 'code'> & { description?: string },
): Promise<Snippet> {
return request('/snippets', { return request('/snippets', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -0,0 +1,45 @@
import { useTranslation } from 'react-i18next';
import { X, Trash2 } from 'lucide-react';
import { Modal, Button } from '../../ui';
export interface DeleteConfirmationModalProps {
open: boolean;
title: string;
message: string;
onClose: () => void;
onConfirm: () => void;
isDeleting: boolean;
}
export function DeleteConfirmationModal({
open,
title,
message,
onClose,
onConfirm,
isDeleting,
}: DeleteConfirmationModalProps) {
const { t } = useTranslation();
return (
<Modal
open={open}
title={title}
onClose={() => !isDeleting && onClose()}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isDeleting}>
<X size={14} />
{t('common.button_cancel')}
</Button>
<Button variant="danger" onClick={onConfirm} disabled={isDeleting}>
<Trash2 size={14} />
{t('common.button_delete')}
</Button>
</>
}
>
{message}
</Modal>
);
}
@@ -0,0 +1,80 @@
import { useTranslation } from 'react-i18next';
import { X, Play } from 'lucide-react';
import { Modal, Button, Select } from '../../ui';
import type { Environment } from '../../api';
export interface RunScenarioModalProps {
open: boolean;
environments: Environment[];
selectedEnvId: string;
onEnvSelect: (envId: string) => void;
saveSessionFlag: boolean;
onSaveSessionChange: (flag: boolean) => void;
onClose: () => void;
onRun: () => void;
isRunning: boolean;
actionLabel?: string;
}
export function RunScenarioModal({
open,
environments,
selectedEnvId,
onEnvSelect,
saveSessionFlag,
onSaveSessionChange,
onClose,
onRun,
isRunning,
actionLabel,
}: RunScenarioModalProps) {
const { t } = useTranslation();
return (
<Modal
open={open}
title={t('scenarios.run_modal_title')}
onClose={() => !isRunning && onClose()}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isRunning}>
<X size={14} />
{t('common.button_cancel')}
</Button>
<Button variant="primary" onClick={onRun} disabled={isRunning || !selectedEnvId}>
<Play size={14} />
{actionLabel || t('scenarios.action_run')}
</Button>
</>
}
>
{environments.length === 0 ? (
<p>{t('scenarios.run_modal_no_env')}</p>
) : (
<>
<Select
label={t('scenarios.run_modal_env_label')}
value={selectedEnvId}
onChange={(e) => onEnvSelect(e.target.value)}
options={environments.map((env) => ({ value: env.id, label: env.name }))}
/>
<label
style={{
display: 'flex',
alignItems: 'center',
marginTop: '1rem',
gap: '0.5rem',
}}
>
<input
type="checkbox"
checked={saveSessionFlag}
onChange={(e) => onSaveSessionChange(e.target.checked)}
/>
<span>{t('common.save_session')}</span>
</label>
</>
)}
</Modal>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export type { DeleteConfirmationModalProps } from './DeleteConfirmationModal';
export { RunScenarioModal } from './RunScenarioModal';
export type { RunScenarioModalProps } from './RunScenarioModal';
+24
View File
@@ -1,4 +1,17 @@
{ {
"common": {
"button_cancel": "Cancel",
"button_confirm": "Confirm",
"button_delete": "Delete",
"confirm_delete_credential": "Are you sure you want to delete this credential?",
"confirm_delete_environment": "Are you sure you want to delete this environment?",
"confirm_delete_scenario": "Are you sure you want to delete this scenario?",
"confirm_delete_step": "Are you sure you want to delete this step?",
"confirm_delete_session": "Are you sure you want to delete this session?",
"confirm_delete_snippet": "Are you sure you want to delete this snippet?",
"confirm_remove_credential": "Are you sure you want to remove this credential from scenario?",
"save_session": "Save session"
},
"errors": { "errors": {
"not_found": "Not found", "not_found": "Not found",
"not_found_session": "Session {{id}} does not exist or has been deleted.", "not_found_session": "Session {{id}} does not exist or has been deleted.",
@@ -19,6 +32,8 @@
}, },
"environments": { "environments": {
"title": "Environments", "title": "Environments",
"created": "Environment created",
"updated": "Environment updated",
"col_id": "ID", "col_id": "ID",
"col_name": "Name", "col_name": "Name",
"col_data": "Data", "col_data": "Data",
@@ -51,6 +66,8 @@
}, },
"credentials": { "credentials": {
"title": "Credentials", "title": "Credentials",
"created": "Credential created",
"updated": "Credential updated",
"empty": "No credentials yet.", "empty": "No credentials yet.",
"loading": "Loading…", "loading": "Loading…",
"menu_label": "Credential options", "menu_label": "Credential options",
@@ -99,6 +116,9 @@
"field_lastUsed": "Last Used" "field_lastUsed": "Last Used"
}, },
"scenarios": { "title": "Scenarios", "scenarios": { "title": "Scenarios",
"created": "Scenario created",
"updated": "Scenario updated",
"run_started": "Scenario run started",
"col_id": "ID", "col_id": "ID",
"col_name": "Name", "col_name": "Name",
"col_updated": "Updated", "col_updated": "Updated",
@@ -165,6 +185,8 @@
"page_size": "Items per page" "page_size": "Items per page"
}, },
"steps": { "steps": {
"created": "Step created",
"updated": "Step updated",
"create_title": "Add Step", "create_title": "Add Step",
"edit_title": "Edit Step #{{order}}", "edit_title": "Edit Step #{{order}}",
"loading": "Loading…", "loading": "Loading…",
@@ -215,6 +237,8 @@
}, },
"snippets": { "snippets": {
"title": "Snippets", "title": "Snippets",
"created": "Snippet created",
"updated": "Snippet updated",
"empty": "No snippets yet.", "empty": "No snippets yet.",
"loading": "Loading…", "loading": "Loading…",
"menu_label": "Snippet options", "menu_label": "Snippet options",
+6 -5
View File
@@ -6,6 +6,7 @@ export interface ApiErrorToastDetail {
export function emitApiErrorToast(message: string): void { export function emitApiErrorToast(message: string): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
window.dispatchEvent( window.dispatchEvent(
new CustomEvent<ApiErrorToastDetail>(API_ERROR_TOAST_EVENT, { new CustomEvent<ApiErrorToastDetail>(API_ERROR_TOAST_EVENT, {
detail: { message }, detail: { message },
@@ -13,17 +14,17 @@ export function emitApiErrorToast(message: string): void {
); );
} }
export function subscribeApiErrorToasts( export function subscribeApiErrorToasts(handler: (message: string) => void): () => void {
handler: (message: string) => void,
): () => void {
if (typeof window === 'undefined') return () => {}; if (typeof window === 'undefined') return () => {};
const listener = (event: Event) => { const listener = (event: Event) => {
const custom = event as CustomEvent<ApiErrorToastDetail>; const custom = event as CustomEvent<ApiErrorToastDetail>;
const message = custom.detail?.message; const message = custom.detail?.message;
if (message) handler(message); if (message) handler(message);
}; };
window.addEventListener(API_ERROR_TOAST_EVENT, listener as EventListener);
window.addEventListener(API_ERROR_TOAST_EVENT, listener);
return () => { return () => {
window.removeEventListener(API_ERROR_TOAST_EVENT, listener as EventListener); window.removeEventListener(API_ERROR_TOAST_EVENT, listener);
}; };
} }
+10 -5
View File
@@ -1,17 +1,22 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import './i18n'; import './i18n';
import './ui/tokens.css'; import './ui/tokens.css';
import App from './App'; import App from './App';
import { ToastProvider } from './ui'; import { ToastProvider } from './ui';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ToastProvider> <QueryClientProvider client={queryClient}>
<BrowserRouter> <ToastProvider>
<App /> <BrowserRouter>
</BrowserRouter> <App />
</ToastProvider> </BrowserRouter>
</ToastProvider>
</QueryClientProvider>
</StrictMode>, </StrictMode>,
); );
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, SubmitEvent } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { KeyRound, X, Save } from 'lucide-react'; import { KeyRound, X, Save } from 'lucide-react';
@@ -16,9 +16,8 @@ export function CreateCredentialPage() {
const [nameError, setNameError] = useState(''); const [nameError, setNameError] = useState('');
const [dataError, setDataError] = useState(''); const [dataError, setDataError] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
let valid = true; let valid = true;
if (!name.trim()) { if (!name.trim()) {
@@ -35,14 +34,12 @@ export function CreateCredentialPage() {
} }
if (!valid) return; if (!valid) return;
setSaving(true); setSaving(true);
setError(null);
try { try {
const credential = await credentials.create(name.trim(), data.trim() || undefined); const credential = await credentials.create(name.trim(), data.trim() || undefined);
toast.success('Credential created'); toast.success(t('credentials.created'));
navigate(`/credentials/${credential.id}`); navigate(`/credentials/${credential.id}`);
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
setError(message);
toast.error(message); toast.error(message);
} finally { } finally {
setSaving(false); setSaving(false);
@@ -54,7 +51,11 @@ export function CreateCredentialPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('credentials.title'), icon: <KeyRound size={14} />, onClick: () => navigate('/credentials') }, {
label: t('credentials.title'),
icon: <KeyRound size={14} />,
onClick: () => navigate('/credentials'),
},
{ label: t('credentials.create_title') }, { label: t('credentials.create_title') },
]} ]}
/> />
@@ -6,15 +6,8 @@ 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 { import { Breadcrumbs, Button, Card, DescriptionList, Timestamp, UuidBadge } from '../../ui';
Breadcrumbs, import { DeleteConfirmationModal } from '../../components/modals';
Button,
Card,
DescriptionList,
Modal,
Timestamp,
UuidBadge,
} from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
export function CredentialDetailPage() { export function CredentialDetailPage() {
@@ -23,7 +16,6 @@ export function CredentialDetailPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [credential, setCredential] = useState<Credential | null>(null); const [credential, setCredential] = useState<Credential | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -32,7 +24,6 @@ export function CredentialDetailPage() {
credentials credentials
.get(id) .get(id)
.then(setCredential) .then(setCredential)
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
@@ -65,7 +56,11 @@ export function CredentialDetailPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('credentials.title'), icon: <KeyRound size={14} />, onClick: () => navigate('/credentials') }, {
label: t('credentials.title'),
icon: <KeyRound size={14} />,
onClick: () => navigate('/credentials'),
},
{ label: credential?.name ?? `#${id}` }, { label: credential?.name ?? `#${id}` },
]} ]}
/> />
@@ -136,23 +131,14 @@ export function CredentialDetailPage() {
</> </>
)} )}
<Modal <DeleteConfirmationModal
open={confirmDeleteOpen} open={confirmDeleteOpen}
title={t('credentials.action_delete')} title={t('credentials.action_delete')}
onClose={() => !deleting && setConfirmDeleteOpen(false)} message={t('common.confirm_delete_credential')}
footer={( onClose={() => setConfirmDeleteOpen(false)}
<> onConfirm={handleDelete}
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}> isDeleting={deleting}
Cancel />
</Button>
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
{t('credentials.action_delete')}
</Button>
</>
)}
>
Are you sure you want to delete this credential?
</Modal>
</div> </div>
); );
} }
@@ -11,10 +11,10 @@ import {
Card, Card,
ContextMenu, ContextMenu,
DescriptionList, DescriptionList,
Modal,
Timestamp, Timestamp,
UuidBadge, UuidBadge,
} from '../../ui'; } from '../../ui';
import { DeleteConfirmationModal } from '../../components/modals';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
function CredentialCard({ function CredentialCard({
@@ -103,7 +103,6 @@ export function CredentialsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [items, setItems] = useState<Credential[]>([]); const [items, setItems] = useState<Credential[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -112,7 +111,6 @@ export function CredentialsPage() {
credentials credentials
.list() .list()
.then((res) => setItems(res.data)) .then((res) => setItems(res.data))
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
@@ -145,8 +143,8 @@ export function CredentialsPage() {
const payload = yamlParse(text) as unknown; const payload = yamlParse(text) as unknown;
const imported = await credentials.importCredential(payload); const imported = await credentials.importCredential(payload);
navigate(`/credentials/${imported.id}`); navigate(`/credentials/${imported.id}`);
} catch (err) { } catch {
setError(err instanceof Error ? err.message : String(err)); // Import errors are silently ignored
} }
}; };
@@ -179,23 +177,14 @@ export function CredentialsPage() {
</div> </div>
)} )}
<Modal <DeleteConfirmationModal
open={deleteId != null} open={deleteId != null}
title={t('credentials.action_delete')} title={t('credentials.action_delete')}
onClose={() => !deleting && setDeleteId(null)} message={t('common.confirm_delete_credential')}
footer={( onClose={() => setDeleteId(null)}
<> onConfirm={handleDelete}
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}> isDeleting={deleting}
Cancel />
</Button>
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
{t('credentials.action_delete')}
</Button>
</>
)}
>
Are you sure you want to delete this credential?
</Modal>
</div> </div>
); );
} }
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, SubmitEvent } 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 { KeyRound, X, Save } from 'lucide-react'; import { KeyRound, X, Save } from 'lucide-react';
@@ -20,7 +20,6 @@ export function EditCredentialPage() {
const [dataError, setDataError] = useState(''); const [dataError, setDataError] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -31,11 +30,10 @@ export function EditCredentialPage() {
setName(c.name); setName(c.name);
setData(c.data ?? ''); setData(c.data ?? '');
}) })
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
let valid = true; let valid = true;
if (!name.trim()) { if (!name.trim()) {
@@ -52,17 +50,15 @@ export function EditCredentialPage() {
} }
if (!valid) return; if (!valid) return;
setSaving(true); setSaving(true);
setError(null);
try { try {
await credentials.update(id!, { await credentials.update(id!, {
name: name.trim(), name: name.trim(),
data: data.trim() || null, data: data.trim() || null,
}); });
toast.success('Credential updated'); toast.success(t('credentials.updated'));
navigate(`/credentials/${id}`); navigate(`/credentials/${id}`);
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
setError(message);
toast.error(message); toast.error(message);
} finally { } finally {
setSaving(false); setSaving(false);
@@ -74,7 +70,11 @@ export function EditCredentialPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('credentials.title'), icon: <KeyRound size={14} />, onClick: () => navigate('/credentials') }, {
label: t('credentials.title'),
icon: <KeyRound size={14} />,
onClick: () => navigate('/credentials'),
},
{ {
label: credential?.name ?? `#${id}`, label: credential?.name ?? `#${id}`,
onClick: () => navigate(`/credentials/${id}`), onClick: () => navigate(`/credentials/${id}`),
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, SubmitEvent } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { X, Globe, Save } from 'lucide-react'; import { X, Globe, Save } from 'lucide-react';
@@ -16,9 +16,8 @@ export function CreateEnvironmentPage() {
const [nameError, setNameError] = useState(''); const [nameError, setNameError] = useState('');
const [dataError, setDataError] = useState(''); const [dataError, setDataError] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) { if (!name.trim()) {
setNameError(t('environments.form_name_required')); setNameError(t('environments.form_name_required'));
@@ -42,14 +41,12 @@ export function CreateEnvironmentPage() {
return; return;
} }
setSaving(true); setSaving(true);
setError(null);
try { try {
const env = await environments.create(name.trim(), parsedData); const env = await environments.create(name.trim(), parsedData);
toast.success('Environment created'); toast.success(t('environments.created'));
navigate(`/environments/${env.id}`); navigate(`/environments/${env.id}`);
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
setError(message);
toast.error(message); toast.error(message);
} finally { } finally {
setSaving(false); setSaving(false);
@@ -61,7 +58,11 @@ export function CreateEnvironmentPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('environments.title'), icon: <Globe size={14} />, onClick: () => navigate('/environments') }, {
label: t('environments.title'),
icon: <Globe size={14} />,
onClick: () => navigate('/environments'),
},
{ label: t('environments.create_title') }, { label: t('environments.create_title') },
]} ]}
/> />
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, SubmitEvent } 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 { X, Globe, Save } from 'lucide-react'; import { X, Globe, Save } from 'lucide-react';
@@ -20,7 +20,6 @@ export function EditEnvironmentPage() {
const [dataError, setDataError] = useState(''); const [dataError, setDataError] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -31,11 +30,10 @@ export function EditEnvironmentPage() {
setName(data.name); setName(data.name);
setDataJson(JSON.stringify(data.data ?? {}, null, 2)); setDataJson(JSON.stringify(data.data ?? {}, null, 2));
}) })
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) { if (!name.trim()) {
setNameError(t('environments.form_name_required')); setNameError(t('environments.form_name_required'));
@@ -59,17 +57,15 @@ export function EditEnvironmentPage() {
return; return;
} }
setSaving(true); setSaving(true);
setError(null);
try { try {
await environments.update(id!, { await environments.update(id!, {
name: name.trim(), name: name.trim(),
data: parsedData, data: parsedData,
}); });
toast.success('Environment updated'); toast.success(t('environments.updated'));
navigate(`/environments/${id}`); navigate(`/environments/${id}`);
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
setError(message);
toast.error(message); toast.error(message);
} finally { } finally {
setSaving(false); setSaving(false);
@@ -81,7 +77,11 @@ export function EditEnvironmentPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('environments.title'), icon: <Globe size={14} />, onClick: () => navigate('/environments') }, {
label: t('environments.title'),
icon: <Globe size={14} />,
onClick: () => navigate('/environments'),
},
{ {
label: env?.name ?? `#${id}`, label: env?.name ?? `#${id}`,
onClick: () => navigate(`/environments/${id}`), onClick: () => navigate(`/environments/${id}`),
@@ -1,20 +1,29 @@
import { Globe, Pencil, Trash2, Upload } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Upload, Pencil, Trash2, Globe } from 'lucide-react'; import { useNavigate, useParams } from 'react-router-dom';
import { stringify as yamlStringify } from 'yaml'; import { stringify as yamlStringify } from 'yaml';
import { environments } from '../../api';
import type { Environment } from '../../api'; import type { Environment } from '../../api';
import { Breadcrumbs, Button, Card, DescriptionList, Modal, Timestamp, UuidBadge } from '../../ui'; import { environments } from '../../api';
import { DeleteConfirmationModal } from '../../components/modals';
import {
Breadcrumbs,
Button,
Card,
DescriptionList,
Timestamp,
UuidBadge,
useToast,
} from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
export function EnvironmentDetailPage() { export function EnvironmentDetailPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const toast = useToast();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [env, setEnv] = useState<Environment | null>(null); const [env, setEnv] = useState<Environment | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const dataEntries = Object.entries(env?.data ?? {}).filter(([, v]) => v); const dataEntries = Object.entries(env?.data ?? {}).filter(([, v]) => v);
@@ -24,9 +33,9 @@ export function EnvironmentDetailPage() {
environments environments
.get(id) .get(id)
.then(setEnv) .then(setEnv)
.catch((err: Error) => setError(err.message)) .catch((err: Error) => toast.error((err as Error).message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id, toast]);
const handleDelete = async () => { const handleDelete = async () => {
if (!env) return; if (!env) return;
@@ -57,7 +66,11 @@ export function EnvironmentDetailPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('environments.title'), icon: <Globe size={14} />, onClick: () => navigate('/environments') }, {
label: t('environments.title'),
icon: <Globe size={14} />,
onClick: () => navigate('/environments'),
},
{ label: env?.name ?? `#${id}` }, { label: env?.name ?? `#${id}` },
]} ]}
/> />
@@ -107,12 +120,12 @@ export function EnvironmentDetailPage() {
<div className={styles.stepsSection}> <div className={styles.stepsSection}>
<h2 className={styles.sectionHeading}>{t('environments.section_data')}</h2> <h2 className={styles.sectionHeading}>{t('environments.section_data')}</h2>
<Card> <Card>
{dataEntries.length === 0 ? ( {dataEntries.length === 0 ? (
<p className={styles.muted}>{t('environments.no_data')}</p> <p className={styles.muted}>{t('environments.no_data')}</p>
) : ( ) : (
<DescriptionList <DescriptionList
layout="grid" layout="grid"
items={dataEntries.map(([key, value]) => ({ items={dataEntries.map(([key, value]) => ({
term: key, term: key,
detail: ( detail: (
<a href={value} target="_blank" rel="noopener noreferrer"> <a href={value} target="_blank" rel="noopener noreferrer">
@@ -120,30 +133,21 @@ export function EnvironmentDetailPage() {
</a> </a>
), ),
}))} }))}
/> />
)} )}
</Card> </Card>
</div> </div>
</> </>
)} )}
<Modal <DeleteConfirmationModal
open={confirmDeleteOpen} open={confirmDeleteOpen}
title={t('environments.action_delete')} title={t('environments.action_delete')}
onClose={() => !deleting && setConfirmDeleteOpen(false)} message={t('common.confirm_delete_environment')}
footer={( onClose={() => setConfirmDeleteOpen(false)}
<> onConfirm={handleDelete}
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}> isDeleting={deleting}
Cancel />
</Button>
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
{t('environments.action_delete')}
</Button>
</>
)}
>
Are you sure you want to delete this environment?
</Modal>
</div> </div>
); );
} }
@@ -12,10 +12,10 @@ import {
Card, Card,
ContextMenu, ContextMenu,
DescriptionList, DescriptionList,
Modal,
Timestamp, Timestamp,
UuidBadge, UuidBadge,
} from '../../ui'; } from '../../ui';
import { DeleteConfirmationModal } from '../../components/modals';
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 }) {
@@ -120,7 +120,6 @@ export function EnvironmentsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [items, setItems] = useState<Environment[]>([]); const [items, setItems] = useState<Environment[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -129,7 +128,6 @@ export function EnvironmentsPage() {
environments environments
.list() .list()
.then((res) => setItems(res.data)) .then((res) => setItems(res.data))
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
@@ -162,8 +160,8 @@ export function EnvironmentsPage() {
const payload = yamlParse(text) as unknown; const payload = yamlParse(text) as unknown;
const imported = await environments.importEnvironment(payload); const imported = await environments.importEnvironment(payload);
navigate(`/environments/${imported.id}`); navigate(`/environments/${imported.id}`);
} catch (err) { } catch {
setError(err instanceof Error ? err.message : String(err)); // Import errors are silently ignored
} }
}; };
@@ -196,23 +194,14 @@ export function EnvironmentsPage() {
</div> </div>
)} )}
<Modal <DeleteConfirmationModal
open={deleteId != null} open={deleteId != null}
title={t('environments.action_delete')} title={t('environments.action_delete')}
onClose={() => !deleting && setDeleteId(null)} message={t('common.confirm_delete_environment')}
footer={( onClose={() => setDeleteId(null)}
<> onConfirm={handleDelete}
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}> isDeleting={deleting}
Cancel />
</Button>
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
{t('environments.action_delete')}
</Button>
</>
)}
>
Are you sure you want to delete this environment?
</Modal>
</div> </div>
); );
} }
+22 -32
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Activity } from 'lucide-react'; import { Activity } from 'lucide-react';
@@ -39,35 +39,20 @@ export function AllRunsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [items, setItems] = useState<AllRunRow[]>([]); const {
const [loading, setLoading] = useState(true); data: items = [],
const [error, setError] = useState<string | null>(null); isLoading,
const [pulseKey, setPulseKey] = useState(0); error,
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); refetch,
const hasDataRef = useRef(false); } = useQuery<AllRunRow[]>({
queryKey: ['runs'],
const load = useCallback(() => { queryFn: async () => {
if (!hasDataRef.current) setLoading(true); const res = await runs.listAll();
runs return res.data as AllRunRow[];
.listAll() },
.then((res) => { refetchInterval: 10_000,
setItems(res.data as AllRunRow[]); staleTime: 0,
setError(null); });
setPulseKey((k) => k + 1);
hasDataRef.current = true;
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
load();
pollRef.current = setInterval(load, 10_000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
const columns: TableColumn<AllRunRow>[] = [ const columns: TableColumn<AllRunRow>[] = [
{ key: 'id', header: t('runs.col_id'), render: (r) => <UuidBadge id={r.id} />, width: 60 }, { key: 'id', header: t('runs.col_id'), render: (r) => <UuidBadge id={r.id} />, width: 60 },
@@ -110,14 +95,19 @@ export function AllRunsPage() {
<div> <div>
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs items={[{ label: t('runs.title'), icon: <Activity size={14} /> }]} /> <Breadcrumbs items={[{ label: t('runs.title'), icon: <Activity size={14} /> }]} />
<AutoRefreshIndicator active pulseKey={pulseKey} error={!!error} onClick={load} /> <AutoRefreshIndicator
active
pulseKey={items.length}
error={!!error}
onClick={() => refetch()}
/>
</div> </div>
<Table <Table
columns={columns} columns={columns}
data={items} data={items}
rowKey={(r) => r.id} rowKey={(r) => r.id}
loading={loading} loading={isLoading}
emptyMessage={t('runs.empty')} emptyMessage={t('runs.empty')}
pageSize={20} pageSize={20}
pageSizeOptions={[10, 20, 50]} pageSizeOptions={[10, 20, 50]}
+61 -17
View File
@@ -19,7 +19,6 @@ import {
Card, Card,
CodeBlock, CodeBlock,
DescriptionList, DescriptionList,
Notification,
Pagination, Pagination,
Search, Search,
Table, Table,
@@ -175,7 +174,14 @@ export function RunDetailPage() {
{ {
key: 'description', key: 'description',
header: ( header: (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}> <div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
}}
>
<span>{t('runs.step_description')}</span> <span>{t('runs.step_description')}</span>
{run?.stepRuns.some((s) => s.output) && ( {run?.stepRuns.some((s) => s.output) && (
<button <button
@@ -185,7 +191,9 @@ export function RunDetailPage() {
setExpandedStepIds(new Set()); setExpandedStepIds(new Set());
} else { } else {
setExpandAll(true); setExpandAll(true);
setExpandedStepIds(new Set(run?.stepRuns.filter((s) => s.output).map((s) => s.id) ?? [])); setExpandedStepIds(
new Set(run?.stepRuns.filter((s) => s.output).map((s) => s.id) ?? []),
);
} }
}} }}
style={{ style={{
@@ -208,7 +216,14 @@ export function RunDetailPage() {
const isExpanded = expandAll || expandedStepIds.has(s.id); const isExpanded = expandAll || expandedStepIds.has(s.id);
return ( return (
<div> <div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: isExpanded && s.output ? '8px' : 0 }}> <div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
marginBottom: isExpanded && s.output ? '8px' : 0,
}}
>
{s.output && ( {s.output && (
<button <button
onClick={() => { onClick={() => {
@@ -231,11 +246,7 @@ export function RunDetailPage() {
marginTop: '2px', marginTop: '2px',
}} }}
> >
{isExpanded ? ( {isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
</button> </button>
)} )}
<code style={{ wordBreak: 'break-word' }}> <code style={{ wordBreak: 'break-word' }}>
@@ -250,15 +261,15 @@ export function RunDetailPage() {
</div> </div>
{isExpanded && s.output && ( {isExpanded && s.output && (
<div style={{ marginLeft: '24px' }}> <div style={{ marginLeft: '24px' }}>
<CodeBlock <CodeBlock
code={(() => { code={(() => {
try { try {
return JSON.stringify(JSON.parse(s.output), null, 2); return JSON.stringify(JSON.parse(s.output), null, 2);
} catch { } catch {
return s.output; return s.output;
} }
})()} })()}
language="json" language="json"
/> />
</div> </div>
)} )}
@@ -275,7 +286,11 @@ export function RunDetailPage() {
width: 70, width: 70,
render: (l) => <Badge variant={LOG_LEVEL_VARIANT[l.level]}>{l.level}</Badge>, render: (l) => <Badge variant={LOG_LEVEL_VARIANT[l.level]}>{l.level}</Badge>,
}, },
{ key: 'message', header: t('runs.log_message'), render: (l) => <code style={{ wordBreak: 'break-word' }}>{l.message}</code> }, {
key: 'message',
header: t('runs.log_message'),
render: (l) => <code style={{ wordBreak: 'break-word' }}>{l.message}</code>,
},
{ {
key: 'time', key: 'time',
header: t('runs.log_time'), header: t('runs.log_time'),
@@ -289,7 +304,11 @@ export function RunDetailPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') }, {
label: t('scenarios.title'),
icon: <ClipboardList size={14} />,
onClick: () => navigate('/scenarios'),
},
{ {
label: scenario?.name ?? `${id}`, label: scenario?.name ?? `${id}`,
onClick: () => navigate(`/scenarios/${id}`), onClick: () => navigate(`/scenarios/${id}`),
@@ -302,7 +321,12 @@ export function RunDetailPage() {
{ label: `${runId}` }, { label: `${runId}` },
]} ]}
/> />
<AutoRefreshIndicator active={polling} pulseKey={pulseKey} error={!!error} onClick={manualRefresh} /> <AutoRefreshIndicator
active={polling}
pulseKey={pulseKey}
error={!!error}
onClick={manualRefresh}
/>
</div> </div>
{loading && <p className={styles.muted}>{t('runs.loading')}</p>} {loading && <p className={styles.muted}>{t('runs.loading')}</p>}
@@ -327,10 +351,30 @@ export function RunDetailPage() {
{ term: t('runs.field_updated'), detail: <Timestamp value={run.updatedAt} /> }, { term: t('runs.field_updated'), detail: <Timestamp value={run.updatedAt} /> },
]; ];
if (run.environment) { if (run.environment) {
items.push({ term: 'Environment', detail: <Link to={`/environments/${run.environment.id}`} style={{ color: 'var(--color-link)' }}>{run.environment.name}</Link> }); items.push({
term: 'Environment',
detail: (
<Link
to={`/environments/${run.environment.id}`}
style={{ color: 'var(--color-link)' }}
>
{run.environment.name}
</Link>
),
});
} }
if (run.session) { if (run.session) {
items.push({ term: 'Session', detail: <Link to={`/sessions/${run.session.id}`} style={{ color: 'var(--color-link)' }}>{run.session.sessionName}</Link> }); items.push({
term: 'Session',
detail: (
<Link
to={`/sessions/${run.session.id}`}
style={{ color: 'var(--color-link)' }}
>
{run.session.sessionName}
</Link>
),
});
} }
return items; return items;
})()} })()}
+9 -6
View File
@@ -76,7 +76,6 @@ 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 () => {
@@ -102,7 +101,7 @@ export function RunsPage() {
setRunning(true); setRunning(true);
try { try {
const run = await scenarios.run(id!, selectedEnvId); const run = await scenarios.run(id!, selectedEnvId);
toast.success('Scenario run started'); toast.success(t('scenarios.run_started'));
navigate(`/scenarios/${id}/runs/${run.id}`); navigate(`/scenarios/${id}/runs/${run.id}`);
setRunModalOpen(false); setRunModalOpen(false);
} catch (err) { } catch (err) {
@@ -139,7 +138,11 @@ export function RunsPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') }, {
label: t('scenarios.title'),
icon: <ClipboardList size={14} />,
onClick: () => navigate('/scenarios'),
},
{ {
label: scenario?.name ?? `#${id}`, label: scenario?.name ?? `#${id}`,
onClick: () => navigate(`/scenarios/${id}`), onClick: () => navigate(`/scenarios/${id}`),
@@ -171,16 +174,16 @@ export function RunsPage() {
open={runModalOpen} open={runModalOpen}
title={t('scenarios.run_modal_title')} title={t('scenarios.run_modal_title')}
onClose={() => !running && setRunModalOpen(false)} onClose={() => !running && setRunModalOpen(false)}
footer={( footer={
<> <>
<Button variant="secondary" onClick={() => setRunModalOpen(false)} disabled={running}> <Button variant="secondary" onClick={() => setRunModalOpen(false)} disabled={running}>
Cancel {t('common.button_cancel')}
</Button> </Button>
<Button variant="primary" onClick={handleRun} disabled={running || !selectedEnvId}> <Button variant="primary" onClick={handleRun} disabled={running || !selectedEnvId}>
{t('runs.action_run')} {t('runs.action_run')}
</Button> </Button>
</> </>
)} }
> >
{envs.length === 0 ? ( {envs.length === 0 ? (
<p>{t('scenarios.run_modal_no_env')}</p> <p>{t('scenarios.run_modal_no_env')}</p>
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, SubmitEvent } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { X, Save, ClipboardList } from 'lucide-react'; import { X, Save, ClipboardList } from 'lucide-react';
@@ -14,23 +14,20 @@ export function CreateScenarioPage() {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [nameError, setNameError] = useState(''); const [nameError, setNameError] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) { if (!name.trim()) {
setNameError(t('scenarios.form_name_required')); setNameError(t('scenarios.form_name_required'));
return; return;
} }
setSaving(true); setSaving(true);
setError(null);
try { try {
const scenario = await scenarios.create(name.trim()); const scenario = await scenarios.create(name.trim());
toast.success('Scenario created'); toast.success(t('scenarios.created'));
navigate(`/scenarios/${scenario.id}`); navigate(`/scenarios/${scenario.id}`);
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
setError(message);
toast.error(message); toast.error(message);
} finally { } finally {
setSaving(false); setSaving(false);
@@ -42,7 +39,11 @@ export function CreateScenarioPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') }, {
label: t('scenarios.title'),
icon: <ClipboardList size={14} />,
onClick: () => navigate('/scenarios'),
},
{ label: t('scenarios.create_title') }, { label: t('scenarios.create_title') },
]} ]}
/> />
+8 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, SubmitEvent } 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 { X, Save, ClipboardList } from 'lucide-react'; import { X, Save, ClipboardList } from 'lucide-react';
@@ -17,7 +17,6 @@ export function CreateStepPage() {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [execCode, setExecCode] = useState(''); const [execCode, setExecCode] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -27,20 +26,18 @@ export function CreateStepPage() {
.catch(() => null); .catch(() => null);
}, [id]); }, [id]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setSaving(true); setSaving(true);
setError(null);
try { try {
await steps.create(id!, { await steps.create(id!, {
title: title.trim() || undefined, title: title.trim() || undefined,
execCode: execCode.trim() || undefined, execCode: execCode.trim() || undefined,
}); });
toast.success('Step created'); toast.success(t('steps.created'));
navigate(`/scenarios/${id}`); navigate(`/scenarios/${id}`);
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
setError(message);
toast.error(message); toast.error(message);
} finally { } finally {
setSaving(false); setSaving(false);
@@ -52,7 +49,11 @@ export function CreateStepPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') }, {
label: t('scenarios.title'),
icon: <ClipboardList size={14} />,
onClick: () => navigate('/scenarios'),
},
{ {
label: scenario?.name ?? `#${id}`, label: scenario?.name ?? `#${id}`,
onClick: () => navigate(`/scenarios/${id}`), onClick: () => navigate(`/scenarios/${id}`),
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, SubmitEvent } 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 { X, Save, ClipboardList } from 'lucide-react'; import { X, Save, ClipboardList } from 'lucide-react';
@@ -18,7 +18,6 @@ export function EditScenarioPage() {
const [nameError, setNameError] = useState(''); const [nameError, setNameError] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -28,25 +27,22 @@ export function EditScenarioPage() {
setScenario(data); setScenario(data);
setName(data.name); setName(data.name);
}) })
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (!name.trim()) { if (!name.trim()) {
setNameError(t('scenarios.form_name_required')); setNameError(t('scenarios.form_name_required'));
return; return;
} }
setSaving(true); setSaving(true);
setError(null);
try { try {
await scenarios.update(id!, { name: name.trim() }); await scenarios.update(id!, { name: name.trim() });
toast.success('Scenario updated'); toast.success(t('scenarios.updated'));
navigate(`/scenarios/${id}`); navigate(`/scenarios/${id}`);
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
setError(message);
toast.error(message); toast.error(message);
} finally { } finally {
setSaving(false); setSaving(false);
@@ -58,7 +54,11 @@ export function EditScenarioPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') }, {
label: t('scenarios.title'),
icon: <ClipboardList size={14} />,
onClick: () => navigate('/scenarios'),
},
{ {
label: scenario?.name ?? `#${id}`, label: scenario?.name ?? `#${id}`,
onClick: () => navigate(`/scenarios/${id}`), onClick: () => navigate(`/scenarios/${id}`),
+8 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, SubmitEvent } 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 { X, Save, ClipboardList } from 'lucide-react'; import { X, Save, ClipboardList } from 'lucide-react';
@@ -19,7 +19,6 @@ export function EditStepPage() {
const [execCode, setExecCode] = useState(''); const [execCode, setExecCode] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!id || !stepId) return; if (!id || !stepId) return;
@@ -30,24 +29,21 @@ export function EditStepPage() {
setTitle(st.title ?? ''); setTitle(st.title ?? '');
setExecCode(st.execCode ?? ''); setExecCode(st.execCode ?? '');
}) })
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id, stepId]); }, [id, stepId]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setSaving(true); setSaving(true);
setError(null);
try { try {
await steps.update(id!, stepId!, { await steps.update(id!, stepId!, {
title: title.trim() || undefined, title: title.trim() || undefined,
execCode: execCode.trim() || undefined, execCode: execCode.trim() || undefined,
}); });
toast.success('Step updated'); toast.success(t('steps.updated'));
navigate(`/scenarios/${id}`); navigate(`/scenarios/${id}`);
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
setError(message);
toast.error(message); toast.error(message);
} finally { } finally {
setSaving(false); setSaving(false);
@@ -59,7 +55,11 @@ export function EditStepPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') }, {
label: t('scenarios.title'),
icon: <ClipboardList size={14} />,
onClick: () => navigate('/scenarios'),
},
{ {
label: scenario?.name ?? `#${id}`, label: scenario?.name ?? `#${id}`,
onClick: () => navigate(`/scenarios/${id}`), onClick: () => navigate(`/scenarios/${id}`),
@@ -1,10 +1,31 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, SubmitEvent } 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 { Play, Pencil, Plus, History, Trash2, Upload, GripVertical, ClipboardList } from 'lucide-react'; import {
Play,
Pencil,
Plus,
History,
Trash2,
Upload,
GripVertical,
ClipboardList,
} from 'lucide-react';
import { stringify as yamlStringify } from 'yaml'; import { stringify as yamlStringify } from 'yaml';
import { environments, scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api'; import {
import type { Scenario, ScenarioStep, ScenarioCredential, Credential, Environment } from '../../api'; environments,
scenarios,
steps,
scenarioCredentials,
credentials as credentialsApi,
} from '../../api';
import type {
Scenario,
ScenarioStep,
ScenarioCredential,
Credential,
Environment,
} from '../../api';
import { import {
Breadcrumbs, Breadcrumbs,
Button, Button,
@@ -28,7 +49,6 @@ export function ScenarioDetailPage() {
const toast = useToast(); const toast = useToast();
const [scenario, setScenario] = useState<(Scenario & { steps: ScenarioStep[] }) | null>(null); const [scenario, setScenario] = useState<(Scenario & { steps: ScenarioStep[] }) | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Credentials state // Credentials state
const [scenarioCreds, setScenarioCreds] = useState<ScenarioCredential[]>([]); const [scenarioCreds, setScenarioCreds] = useState<ScenarioCredential[]>([]);
@@ -60,7 +80,6 @@ export function ScenarioDetailPage() {
setScenario(s); setScenario(s);
setScenarioCreds(s.scenarioCredentials ?? []); setScenarioCreds(s.scenarioCredentials ?? []);
}) })
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
credentialsApi credentialsApi
.list(1, 200) .list(1, 200)
@@ -91,7 +110,7 @@ export function ScenarioDetailPage() {
setRunning(true); setRunning(true);
try { try {
const run = await scenarios.run(scenario.id, selectedEnvId, saveSessionFlag); const run = await scenarios.run(scenario.id, selectedEnvId, saveSessionFlag);
toast.success('Scenario run started'); toast.success(t('scenarios.run_started'));
navigate(`/scenarios/${id}/runs/${run.id}`); navigate(`/scenarios/${id}/runs/${run.id}`);
setRunModalOpen(false); setRunModalOpen(false);
setSaveSessionFlag(false); setSaveSessionFlag(false);
@@ -140,7 +159,7 @@ export function ScenarioDetailPage() {
} }
}; };
const handleAddCredential = async (e: React.FormEvent) => { const handleAddCredential = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
let valid = true; let valid = true;
if (!addCredId) { if (!addCredId) {
@@ -321,7 +340,11 @@ export function ScenarioDetailPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('scenarios.title'), icon: <ClipboardList size={14} />, onClick: () => navigate('/scenarios') }, {
label: t('scenarios.title'),
icon: <ClipboardList size={14} />,
onClick: () => navigate('/scenarios'),
},
{ label: scenario?.name ?? `#${id}` }, { label: scenario?.name ?? `#${id}` },
]} ]}
/> />
@@ -343,7 +366,11 @@ export function ScenarioDetailPage() {
<Pencil size={14} /> <Pencil size={14} />
{t('scenarios.action_edit')} {t('scenarios.action_edit')}
</Button> </Button>
<Button variant="danger" size="sm" onClick={() => setPendingDelete({ type: 'scenario' })}> <Button
variant="danger"
size="sm"
onClick={() => setPendingDelete({ type: 'scenario' })}
>
<Trash2 size={14} /> <Trash2 size={14} />
{t('scenarios.action_delete')} {t('scenarios.action_delete')}
</Button> </Button>
@@ -513,36 +540,36 @@ export function ScenarioDetailPage() {
: t('scenarios.action_delete') : t('scenarios.action_delete')
} }
onClose={() => !deleting && setPendingDelete(null)} onClose={() => !deleting && setPendingDelete(null)}
footer={( footer={
<> <>
<Button variant="secondary" onClick={() => setPendingDelete(null)} disabled={deleting}> <Button variant="secondary" onClick={() => setPendingDelete(null)} disabled={deleting}>
Cancel {t('common.button_cancel')}
</Button> </Button>
<Button variant="danger" onClick={confirmDelete} disabled={deleting}> <Button variant="danger" onClick={confirmDelete} disabled={deleting}>
Confirm {t('common.button_confirm')}
</Button> </Button>
</> </>
)} }
> >
{pendingDelete?.type === 'step' && 'Are you sure you want to delete this step?'} {pendingDelete?.type === 'step' && t('common.confirm_delete_step')}
{pendingDelete?.type === 'credential' && 'Are you sure you want to remove this credential from scenario?'} {pendingDelete?.type === 'credential' && t('common.confirm_remove_credential')}
{pendingDelete?.type === 'scenario' && 'Are you sure you want to delete this scenario?'} {pendingDelete?.type === 'scenario' && t('common.confirm_delete_scenario')}
</Modal> </Modal>
<Modal <Modal
open={runModalOpen} open={runModalOpen}
title={t('scenarios.run_modal_title')} title={t('scenarios.run_modal_title')}
onClose={() => !running && setRunModalOpen(false)} onClose={() => !running && setRunModalOpen(false)}
footer={( footer={
<> <>
<Button variant="secondary" onClick={() => setRunModalOpen(false)} disabled={running}> <Button variant="secondary" onClick={() => setRunModalOpen(false)} disabled={running}>
Cancel {t('common.button_cancel')}
</Button> </Button>
<Button variant="primary" onClick={handleRun} disabled={running || !selectedEnvId}> <Button variant="primary" onClick={handleRun} disabled={running || !selectedEnvId}>
{t('scenarios.action_run')} {t('scenarios.action_run')}
</Button> </Button>
</> </>
)} }
> >
{envs.length === 0 ? ( {envs.length === 0 ? (
<p>{t('scenarios.run_modal_no_env')}</p> <p>{t('scenarios.run_modal_no_env')}</p>
@@ -554,13 +581,15 @@ export function ScenarioDetailPage() {
onChange={(e) => setSelectedEnvId(e.target.value)} onChange={(e) => setSelectedEnvId(e.target.value)}
options={envs.map((env) => ({ value: env.id, label: env.name }))} options={envs.map((env) => ({ value: env.id, label: env.name }))}
/> />
<label style={{ display: 'flex', alignItems: 'center', marginTop: '1rem', gap: '0.5rem' }}> <label
style={{ display: 'flex', alignItems: 'center', marginTop: '1rem', gap: '0.5rem' }}
>
<input <input
type="checkbox" type="checkbox"
checked={saveSessionFlag} checked={saveSessionFlag}
onChange={(e) => setSaveSessionFlag(e.target.checked)} onChange={(e) => setSaveSessionFlag(e.target.checked)}
/> />
<span>Save session</span> <span>{t('common.save_session')}</span>
</label> </label>
</> </>
)} )}
+13 -13
View File
@@ -24,7 +24,6 @@ export function ScenariosPage() {
const toast = useToast(); const toast = useToast();
const [items, setItems] = useState<Scenario[]>([]); const [items, setItems] = useState<Scenario[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [runScenarioId, setRunScenarioId] = useState<string | null>(null); const [runScenarioId, setRunScenarioId] = useState<string | null>(null);
@@ -41,7 +40,6 @@ export function ScenariosPage() {
setItems(scenarioRes.data); setItems(scenarioRes.data);
setEnvs(envRes.data); setEnvs(envRes.data);
}) })
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@@ -61,7 +59,7 @@ export function ScenariosPage() {
setRunning(true); setRunning(true);
try { try {
const run = await scenarios.run(runScenarioId, selectedEnvId, saveSessionFlag); const run = await scenarios.run(runScenarioId, selectedEnvId, saveSessionFlag);
toast.success('Scenario run started'); toast.success(t('scenarios.run_started'));
navigate(`/scenarios/${runScenarioId}/runs/${run.id}`); navigate(`/scenarios/${runScenarioId}/runs/${run.id}`);
setRunScenarioId(null); setRunScenarioId(null);
setSaveSessionFlag(false); setSaveSessionFlag(false);
@@ -94,8 +92,8 @@ export function ScenariosPage() {
const payload = yamlParse(text) as unknown; const payload = yamlParse(text) as unknown;
const imported = await scenarios.importScenario(payload); const imported = await scenarios.importScenario(payload);
navigate(`/scenarios/${imported.id}`); navigate(`/scenarios/${imported.id}`);
} catch (err) { } catch {
setError((err as Error).message); // Import errors are silently ignored
} }
}; };
@@ -178,25 +176,25 @@ export function ScenariosPage() {
open={deleteId != null} open={deleteId != null}
title={t('scenarios.action_delete')} title={t('scenarios.action_delete')}
onClose={() => !deleting && setDeleteId(null)} onClose={() => !deleting && setDeleteId(null)}
footer={( footer={
<> <>
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}> <Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
Cancel {t('common.button_cancel')}
</Button> </Button>
<Button variant="danger" onClick={handleDelete} disabled={deleting}> <Button variant="danger" onClick={handleDelete} disabled={deleting}>
{t('scenarios.action_delete')} {t('scenarios.action_delete')}
</Button> </Button>
</> </>
)} }
> >
Are you sure you want to delete this scenario? {t('common.confirm_delete_scenario')}
</Modal> </Modal>
<Modal <Modal
open={runScenarioId != null} open={runScenarioId != null}
title={t('scenarios.run_modal_title')} title={t('scenarios.run_modal_title')}
onClose={() => !running && setRunScenarioId(null)} onClose={() => !running && setRunScenarioId(null)}
footer={( footer={
<> <>
<Button variant="secondary" onClick={() => setRunScenarioId(null)} disabled={running}> <Button variant="secondary" onClick={() => setRunScenarioId(null)} disabled={running}>
Cancel Cancel
@@ -205,7 +203,7 @@ export function ScenariosPage() {
{t('scenarios.action_run')} {t('scenarios.action_run')}
</Button> </Button>
</> </>
)} }
> >
{envs.length === 0 ? ( {envs.length === 0 ? (
<p>{t('scenarios.run_modal_no_env')}</p> <p>{t('scenarios.run_modal_no_env')}</p>
@@ -217,13 +215,15 @@ export function ScenariosPage() {
onChange={(e) => setSelectedEnvId(e.target.value)} onChange={(e) => setSelectedEnvId(e.target.value)}
options={envs.map((env) => ({ value: env.id, label: env.name }))} options={envs.map((env) => ({ value: env.id, label: env.name }))}
/> />
<label style={{ display: 'flex', alignItems: 'center', marginTop: '1rem', gap: '0.5rem' }}> <label
style={{ display: 'flex', alignItems: 'center', marginTop: '1rem', gap: '0.5rem' }}
>
<input <input
type="checkbox" type="checkbox"
checked={saveSessionFlag} checked={saveSessionFlag}
onChange={(e) => setSaveSessionFlag(e.target.checked)} onChange={(e) => setSaveSessionFlag(e.target.checked)}
/> />
<span>Save session</span> <span>{t('common.save_session')}</span>
</label> </label>
</> </>
)} )}
+13 -28
View File
@@ -4,16 +4,8 @@ import { useTranslation } from 'react-i18next';
import { Trash2, Monitor } from 'lucide-react'; import { Trash2, Monitor } from 'lucide-react';
import { sessions } from '../../api'; import { sessions } from '../../api';
import type { Session } from '../../api'; import type { Session } from '../../api';
import { import { Badge, Breadcrumbs, Button, Card, DescriptionList, Timestamp, UuidBadge } from '../../ui';
Badge, import { DeleteConfirmationModal } from '../../components/modals';
Breadcrumbs,
Button,
Card,
DescriptionList,
Modal,
Timestamp,
UuidBadge,
} from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
export function SessionDetailPage() { export function SessionDetailPage() {
@@ -22,7 +14,6 @@ export function SessionDetailPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [session, setSession] = useState<Session | null>(null); const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -31,7 +22,6 @@ export function SessionDetailPage() {
sessions sessions
.get(id) .get(id)
.then(setSession) .then(setSession)
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
@@ -52,7 +42,11 @@ export function SessionDetailPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('sessions.title'), icon: <Monitor size={14} />, onClick: () => navigate('/sessions') }, {
label: t('sessions.title'),
icon: <Monitor size={14} />,
onClick: () => navigate('/sessions'),
},
{ label: session?.sessionName ?? `#${id}` }, { label: session?.sessionName ?? `#${id}` },
]} ]}
/> />
@@ -102,23 +96,14 @@ export function SessionDetailPage() {
</Card> </Card>
)} )}
<Modal <DeleteConfirmationModal
open={confirmDeleteOpen} open={confirmDeleteOpen}
title={t('sessions.action_delete')} title={t('sessions.action_delete')}
onClose={() => !deleting && setConfirmDeleteOpen(false)} message={t('common.confirm_delete_session')}
footer={( onClose={() => setConfirmDeleteOpen(false)}
<> onConfirm={handleDelete}
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}> isDeleting={deleting}
Cancel />
</Button>
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
{t('sessions.action_delete')}
</Button>
</>
)}
>
Are you sure you want to delete this session?
</Modal>
</div> </div>
); );
} }
+7 -18
View File
@@ -8,12 +8,12 @@ import {
Badge, Badge,
Breadcrumbs, Breadcrumbs,
Button, Button,
Modal,
Table, Table,
type TableColumn, type TableColumn,
Timestamp, Timestamp,
UuidBadge, UuidBadge,
} from '../../ui'; } from '../../ui';
import { DeleteConfirmationModal } from '../../components/modals';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
export function SessionsPage() { export function SessionsPage() {
@@ -21,7 +21,6 @@ export function SessionsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [items, setItems] = useState<Session[]>([]); const [items, setItems] = useState<Session[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -29,7 +28,6 @@ export function SessionsPage() {
sessions sessions
.list() .list()
.then((res) => setItems(res.data)) .then((res) => setItems(res.data))
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
@@ -102,23 +100,14 @@ export function SessionsPage() {
onRowClick={(s) => navigate(`/sessions/${s.id}`)} onRowClick={(s) => navigate(`/sessions/${s.id}`)}
/> />
<Modal <DeleteConfirmationModal
open={deleteId != null} open={deleteId != null}
title={t('sessions.action_delete')} title={t('sessions.action_delete')}
onClose={() => !deleting && setDeleteId(null)} message={t('common.confirm_delete_session')}
footer={( onClose={() => setDeleteId(null)}
<> onConfirm={handleDelete}
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}> isDeleting={deleting}
Cancel />
</Button>
<Button variant="danger" onClick={handleDelete} disabled={deleting}>
{t('sessions.action_delete')}
</Button>
</>
)}
>
Are you sure you want to delete this session?
</Modal>
</div> </div>
); );
} }
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, SubmitEvent } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { X, Save, Braces } from 'lucide-react'; import { X, Save, Braces } from 'lucide-react';
@@ -19,9 +19,8 @@ export function CreateSnippetPage() {
const [titleError, setTitleError] = useState(''); const [titleError, setTitleError] = useState('');
const [codeError, setCodeError] = useState(''); const [codeError, setCodeError] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
let valid = true; let valid = true;
if (!alias.trim()) { if (!alias.trim()) {
@@ -38,7 +37,6 @@ export function CreateSnippetPage() {
} }
if (!valid) return; if (!valid) return;
setSaving(true); setSaving(true);
setError(null);
try { try {
const snippet = await snippets.create({ const snippet = await snippets.create({
alias: alias.trim(), alias: alias.trim(),
@@ -46,11 +44,10 @@ export function CreateSnippetPage() {
description: description.trim() || undefined, description: description.trim() || undefined,
code: code.trim(), code: code.trim(),
}); });
toast.success('Snippet created'); toast.success(t('snippets.created'));
navigate(`/snippets/${snippet.id}`); navigate(`/snippets/${snippet.id}`);
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
setError(message);
toast.error(message); toast.error(message);
} finally { } finally {
setSaving(false); setSaving(false);
@@ -62,7 +59,11 @@ export function CreateSnippetPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('snippets.title'), icon: <Braces size={14} />, onClick: () => navigate('/snippets') }, {
label: t('snippets.title'),
icon: <Braces size={14} />,
onClick: () => navigate('/snippets'),
},
{ label: t('snippets.create_title') }, { label: t('snippets.create_title') },
]} ]}
/> />
+8 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, SubmitEvent } 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 { X, Save, Braces } from 'lucide-react'; import { X, Save, Braces } from 'lucide-react';
@@ -23,7 +23,6 @@ export function EditSnippetPage() {
const [codeError, setCodeError] = useState(''); const [codeError, setCodeError] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
@@ -36,11 +35,10 @@ export function EditSnippetPage() {
setDescription(s.description ?? ''); setDescription(s.description ?? '');
setCode(s.code); setCode(s.code);
}) })
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
let valid = true; let valid = true;
if (!alias.trim()) { if (!alias.trim()) {
@@ -57,7 +55,6 @@ export function EditSnippetPage() {
} }
if (!valid) return; if (!valid) return;
setSaving(true); setSaving(true);
setError(null);
try { try {
await snippets.update(id!, { await snippets.update(id!, {
alias: alias.trim(), alias: alias.trim(),
@@ -65,11 +62,10 @@ export function EditSnippetPage() {
description: description.trim() || undefined, description: description.trim() || undefined,
code: code.trim(), code: code.trim(),
}); });
toast.success('Snippet updated'); toast.success(t('snippets.updated'));
navigate(`/snippets/${id}`); navigate(`/snippets/${id}`);
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
setError(message);
toast.error(message); toast.error(message);
} finally { } finally {
setSaving(false); setSaving(false);
@@ -81,7 +77,11 @@ export function EditSnippetPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('snippets.title'), icon: <Braces size={14} />, onClick: () => navigate('/snippets') }, {
label: t('snippets.title'),
icon: <Braces size={14} />,
onClick: () => navigate('/snippets'),
},
{ {
label: snippet?.title ?? `#${id}`, label: snippet?.title ?? `#${id}`,
onClick: () => navigate(`/snippets/${id}`), onClick: () => navigate(`/snippets/${id}`),
+14 -8
View File
@@ -24,7 +24,6 @@ export function SnippetDetailPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [snippet, setSnippet] = useState<Snippet | null>(null); const [snippet, setSnippet] = useState<Snippet | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -33,7 +32,6 @@ export function SnippetDetailPage() {
snippets snippets
.get(id) .get(id)
.then(setSnippet) .then(setSnippet)
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id]); }, [id]);
@@ -66,7 +64,11 @@ export function SnippetDetailPage() {
<div className={styles.pageToolbar}> <div className={styles.pageToolbar}>
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: t('snippets.title'), icon: <Braces size={14} />, onClick: () => navigate('/snippets') }, {
label: t('snippets.title'),
icon: <Braces size={14} />,
onClick: () => navigate('/snippets'),
},
{ label: snippet?.title ?? `#${id}` }, { label: snippet?.title ?? `#${id}` },
]} ]}
/> />
@@ -141,18 +143,22 @@ export function SnippetDetailPage() {
open={confirmDeleteOpen} open={confirmDeleteOpen}
title={t('snippets.action_delete')} title={t('snippets.action_delete')}
onClose={() => !deleting && setConfirmDeleteOpen(false)} onClose={() => !deleting && setConfirmDeleteOpen(false)}
footer={( footer={
<> <>
<Button variant="secondary" onClick={() => setConfirmDeleteOpen(false)} disabled={deleting}> <Button
Cancel variant="secondary"
onClick={() => setConfirmDeleteOpen(false)}
disabled={deleting}
>
{t('common.button_cancel')}
</Button> </Button>
<Button variant="danger" onClick={handleDelete} disabled={deleting}> <Button variant="danger" onClick={handleDelete} disabled={deleting}>
{t('snippets.action_delete')} {t('snippets.action_delete')}
</Button> </Button>
</> </>
)} }
> >
Are you sure you want to delete this snippet? {t('common.confirm_delete_snippet')}
</Modal> </Modal>
</div> </div>
); );
+21 -19
View File
@@ -17,7 +17,10 @@ import {
} from '../../ui'; } from '../../ui';
import styles from '../Page.module.css'; import styles from '../Page.module.css';
function firstParagraphBlocks(markdown: string, limit = 2): { preview: string; truncated: boolean } { function firstParagraphBlocks(
markdown: string,
limit = 2,
): { preview: string; truncated: boolean } {
const blocks = markdown const blocks = markdown
.split(/\n\s*\n/g) .split(/\n\s*\n/g)
.map((block) => block.trim()) .map((block) => block.trim())
@@ -81,16 +84,17 @@ function SnippetCard({ snippet, onDelete }: { snippet: Snippet; onDelete: (id: s
<p className={styles.muted}> <p className={styles.muted}>
{t('snippets.field_alias')}: <code className={styles.codeInline}>{snippet.alias}</code> {t('snippets.field_alias')}: <code className={styles.codeInline}>{snippet.alias}</code>
</p> </p>
{snippet.description && (() => { {snippet.description &&
const { preview, truncated } = firstParagraphBlocks(snippet.description, 2); (() => {
if (!preview) return null; const { preview, truncated } = firstParagraphBlocks(snippet.description, 2);
return ( if (!preview) return null;
<> return (
<MarkdownContent content={preview} /> <>
{truncated && <p className={styles.muted}>...</p>} <MarkdownContent content={preview} />
</> {truncated && <p className={styles.muted}>...</p>}
); </>
})()} );
})()}
</Card> </Card>
); );
} }
@@ -113,7 +117,6 @@ export function SnippetsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [items, setItems] = useState<Snippet[]>([]); const [items, setItems] = useState<Snippet[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -122,7 +125,6 @@ export function SnippetsPage() {
snippets snippets
.list() .list()
.then((res) => setItems(res.data)) .then((res) => setItems(res.data))
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
@@ -155,8 +157,8 @@ export function SnippetsPage() {
const payload = yamlParse(text) as unknown; const payload = yamlParse(text) as unknown;
const imported = await snippets.importSnippet(payload); const imported = await snippets.importSnippet(payload);
navigate(`/snippets/${imported.id}`); navigate(`/snippets/${imported.id}`);
} catch (err) { } catch {
setError(err instanceof Error ? err.message : String(err)); // Import errors are silently ignored
} }
}; };
@@ -195,18 +197,18 @@ export function SnippetsPage() {
open={deleteId != null} open={deleteId != null}
title={t('snippets.action_delete')} title={t('snippets.action_delete')}
onClose={() => !deleting && setDeleteId(null)} onClose={() => !deleting && setDeleteId(null)}
footer={( footer={
<> <>
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}> <Button variant="secondary" onClick={() => setDeleteId(null)} disabled={deleting}>
Cancel {t('common.button_cancel')}
</Button> </Button>
<Button variant="danger" onClick={handleDelete} disabled={deleting}> <Button variant="danger" onClick={handleDelete} disabled={deleting}>
{t('snippets.action_delete')} {t('snippets.action_delete')}
</Button> </Button>
</> </>
)} }
> >
Are you sure you want to delete this snippet? {t('common.confirm_delete_snippet')}
</Modal> </Modal>
</div> </div>
); );
+6 -3
View File
@@ -36,15 +36,18 @@ export function Breadcrumbs({ items, separator, className }: BreadcrumbsProps) {
<li key={i} className={styles.item}> <li key={i} className={styles.item}>
{isLast ? ( {isLast ? (
<span className={styles.current} aria-current="page"> <span className={styles.current} aria-current="page">
{item.icon}{item.label} {item.icon}
{item.label}
</span> </span>
) : item.href ? ( ) : item.href ? (
<a href={item.href} className={styles.link} onClick={item.onClick}> <a href={item.href} className={styles.link} onClick={item.onClick}>
{item.icon}{item.label} {item.icon}
{item.label}
</a> </a>
) : ( ) : (
<button type="button" className={styles.link} onClick={item.onClick}> <button type="button" className={styles.link} onClick={item.onClick}>
{item.icon}{item.label} {item.icon}
{item.label}
</button> </button>
)} )}
{!isLast && <span className={styles.separator}>{sep}</span>} {!isLast && <span className={styles.separator}>{sep}</span>}
+34 -45
View File
@@ -1,9 +1,8 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react'; import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react';
import styles from './ToastProvider.module.css'; import styles from './ToastProvider.module.css';
import { subscribeApiErrorToasts } from '../../lib/toast-events'; import { subscribeApiErrorToasts } from '../../lib/toast-events';
import { ToastContext, type ToastApi, type ToastVariant } from './toast-context';
type ToastVariant = 'success' | 'error' | 'info';
interface ToastItem { interface ToastItem {
id: number; id: number;
@@ -12,15 +11,6 @@ interface ToastItem {
closing: boolean; closing: boolean;
} }
interface ToastApi {
show: (message: string, variant?: ToastVariant) => void;
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastApi | null>(null);
const ICONS: Record<ToastVariant, React.ReactNode> = { const ICONS: Record<ToastVariant, React.ReactNode> = {
success: <CheckCircle2 size={16} />, success: <CheckCircle2 size={16} />,
error: <AlertCircle size={16} />, error: <AlertCircle size={16} />,
@@ -41,33 +31,38 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp
setItems((prev) => prev.filter((item) => item.id !== id)); setItems((prev) => prev.filter((item) => item.id !== id));
}, []); }, []);
const dismiss = useCallback((id: number) => { const dismiss = useCallback(
setItems((prev) => (id: number) => {
prev.map((item) => setItems((prev) => prev.map((item) => (item.id === id ? { ...item, closing: true } : item)));
item.id === id ? { ...item, closing: true } : item, window.setTimeout(() => remove(id), EXIT_ANIMATION_MS);
), },
); [remove],
window.setTimeout(() => remove(id), EXIT_ANIMATION_MS); );
}, [remove]);
const show = useCallback((message: string, variant: ToastVariant = 'info') => { const show = useCallback(
const now = Date.now(); (message: string, variant: ToastVariant = 'info') => {
const dedupeKey = `${variant}:${message}`; const now = Date.now();
const previousTs = lastShownAtRef.current.get(dedupeKey) ?? 0; const dedupeKey = `${variant}:${message}`;
if (now - previousTs < 600) return; const previousTs = lastShownAtRef.current.get(dedupeKey) ?? 0;
lastShownAtRef.current.set(dedupeKey, now); if (now - previousTs < 600) return;
lastShownAtRef.current.set(dedupeKey, now);
const id = Date.now() + Math.floor(Math.random() * 1000); const id = Date.now() + Math.floor(Math.random() * 1000);
setItems((prev) => [...prev, { id, message, variant, closing: false }]); setItems((prev) => [...prev, { id, message, variant, closing: false }]);
window.setTimeout(() => dismiss(id), durationMs); window.setTimeout(() => dismiss(id), durationMs);
}, [dismiss, durationMs]); },
[dismiss, durationMs],
);
const api = useMemo<ToastApi>(() => ({ const api = useMemo<ToastApi>(
show, () => ({
success: (message: string) => show(message, 'success'), show,
error: (message: string) => show(message, 'error'), success: (message: string) => show(message, 'success'),
info: (message: string) => show(message, 'info'), error: (message: string) => show(message, 'error'),
}), [show]); info: (message: string) => show(message, 'info'),
}),
[show],
);
useEffect(() => { useEffect(() => {
return subscribeApiErrorToasts((message) => api.error(message)); return subscribeApiErrorToasts((message) => api.error(message));
@@ -86,7 +81,9 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp
role="status" role="status"
> >
<div className={styles.content}> <div className={styles.content}>
<span className={styles.icon} aria-hidden="true">{ICONS[item.variant]}</span> <span className={styles.icon} aria-hidden="true">
{ICONS[item.variant]}
</span>
<p className={styles.message}>{item.message}</p> <p className={styles.message}>{item.message}</p>
</div> </div>
<button <button
@@ -103,11 +100,3 @@ export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProp
</ToastContext.Provider> </ToastContext.Provider>
); );
} }
export function useToast(): ToastApi {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error('useToast must be used within ToastProvider');
}
return ctx;
}
+12
View File
@@ -0,0 +1,12 @@
import { createContext } from 'react';
export type ToastVariant = 'success' | 'error' | 'info';
export interface ToastApi {
show: (message: string, variant?: ToastVariant) => void;
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
}
export const ToastContext = createContext<ToastApi | null>(null);
+10
View File
@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { ToastContext, type ToastApi } from './toast-context';
export function useToast(): ToastApi {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error('useToast must be used within ToastProvider');
}
return ctx;
}
+2 -1
View File
@@ -27,8 +27,9 @@ export type { BreadcrumbsProps, BreadcrumbItem } from './Breadcrumbs/Breadcrumbs
export { ThemeSwitcher } from './ThemeSwitcher/ThemeSwitcher'; export { ThemeSwitcher } from './ThemeSwitcher/ThemeSwitcher';
export { ToastProvider, useToast } from './Toast/ToastProvider'; export { ToastProvider } from './Toast/ToastProvider';
export type { ToastProviderProps } from './Toast/ToastProvider'; export type { ToastProviderProps } from './Toast/ToastProvider';
export { useToast } from './Toast/useToast';
export { Timestamp } from './Timestamp/Timestamp'; export { Timestamp } from './Timestamp/Timestamp';
export type { TimestampProps } from './Timestamp/Timestamp'; export type { TimestampProps } from './Timestamp/Timestamp';
-1
View File
@@ -1 +0,0 @@
{"root":["./src/App.tsx","./src/declarations.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/index.ts","./src/api/types.ts","./src/hooks/useTheme.ts","./src/i18n/index.ts","./src/lib/hljs.ts","./src/lib/toast-events.ts","./src/pages/credential/CreateCredentialPage.tsx","./src/pages/credential/CredentialDetailPage.tsx","./src/pages/credential/CredentialsPage.tsx","./src/pages/credential/EditCredentialPage.tsx","./src/pages/environment/CreateEnvironmentPage.tsx","./src/pages/environment/EditEnvironmentPage.tsx","./src/pages/environment/EnvironmentDetailPage.tsx","./src/pages/environment/EnvironmentsPage.tsx","./src/pages/run/AllRunsPage.tsx","./src/pages/run/RunDetailPage.tsx","./src/pages/run/RunsPage.tsx","./src/pages/scenario/CreateScenarioPage.tsx","./src/pages/scenario/CreateStepPage.tsx","./src/pages/scenario/EditScenarioPage.tsx","./src/pages/scenario/EditStepPage.tsx","./src/pages/scenario/ScenarioDetailPage.tsx","./src/pages/scenario/ScenariosPage.tsx","./src/pages/session/SessionDetailPage.tsx","./src/pages/session/SessionsPage.tsx","./src/pages/snippet/CreateSnippetPage.tsx","./src/pages/snippet/EditSnippetPage.tsx","./src/pages/snippet/SnippetDetailPage.tsx","./src/pages/snippet/SnippetsPage.tsx","./src/ui/index.ts","./src/ui/AutoRefreshIndicator/AutoRefreshIndicator.tsx","./src/ui/Badge/Badge.tsx","./src/ui/Breadcrumbs/Breadcrumbs.tsx","./src/ui/Button/Button.tsx","./src/ui/Card/Card.tsx","./src/ui/CodeBlock/CodeBlock.tsx","./src/ui/CodeEditor/CodeEditor.tsx","./src/ui/ContextMenu/ContextMenu.tsx","./src/ui/DescriptionList/DescriptionList.tsx","./src/ui/Input/Input.tsx","./src/ui/MarkdownContent/MarkdownContent.tsx","./src/ui/Modal/Modal.tsx","./src/ui/Notification/Notification.tsx","./src/ui/Pagination/Pagination.tsx","./src/ui/Search/Search.tsx","./src/ui/Select/Select.tsx","./src/ui/SidePanel/SidePanel.tsx","./src/ui/Table/Table.tsx","./src/ui/Textarea/Textarea.tsx","./src/ui/ThemeSwitcher/ThemeSwitcher.tsx","./src/ui/Timestamp/Timestamp.tsx","./src/ui/Toast/ToastProvider.tsx","./src/ui/UuidBadge/UuidBadge.tsx"],"version":"5.8.3"}
+25
View File
@@ -16,6 +16,7 @@
"name": "liqa-client", "name": "liqa-client",
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@tanstack/react-query": "^5.99.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"i18next": "^26.0.4", "i18next": "^26.0.4",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
@@ -4202,6 +4203,30 @@
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/@tanstack/query-core": {
"version": "5.99.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.0.tgz",
"integrity": "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.99.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.0.tgz",
"integrity": "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==",
"dependencies": {
"@tanstack/query-core": "5.99.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/jest-dom": { "node_modules/@testing-library/jest-dom": {
"version": "6.9.1", "version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+7 -1
View File
@@ -78,6 +78,12 @@ export class BrowserController {
} }
} }
return this.browserService.exec(dto.sessionName, dto.code, dto.url, environment, credentials); return this.browserService.exec(
dto.sessionName,
dto.code,
dto.url,
environment,
credentials,
);
} }
} }
+7 -1
View File
@@ -8,7 +8,13 @@ import { BrowserController } from "./browser.controller";
import { BrowserService } from "./browser.service"; import { BrowserService } from "./browser.service";
@Module({ @Module({
imports: [SessionModule, CodeExecutorModule, SnippetModule, EnvironmentModule, CredentialModule], imports: [
SessionModule,
CodeExecutorModule,
SnippetModule,
EnvironmentModule,
CredentialModule,
],
controllers: [BrowserController], controllers: [BrowserController],
providers: [BrowserService], providers: [BrowserService],
exports: [BrowserService], exports: [BrowserService],
+6 -4
View File
@@ -7,7 +7,10 @@ import {
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import type { BrowserContext, Cookie } from "playwright"; import type { BrowserContext, Cookie } from "playwright";
import { chromium } from "playwright"; import { chromium } from "playwright";
import type { ExecResult, ScriptLogger } from "../code-executor/code-executor.service"; import type {
ExecResult,
ScriptLogger,
} from "../code-executor/code-executor.service";
import { CodeExecutorService } from "../code-executor/code-executor.service"; import { CodeExecutorService } from "../code-executor/code-executor.service";
import { TraceLogger } from "../common/trace-logger"; import { TraceLogger } from "../common/trace-logger";
import type { EnvironmentData } from "../environment/environment.entity"; import type { EnvironmentData } from "../environment/environment.entity";
@@ -172,8 +175,7 @@ export class BrowserService {
const label = sessionName ?? "anonymous"; const label = sessionName ?? "anonymous";
if (sessionName) { if (sessionName) {
const { page, context } = const { page, context } = await this.getOrCreateNamedHandle(sessionName);
await this.getOrCreateNamedHandle(sessionName);
this.logger.log(`[${label}] exec: using persistent context`); this.logger.log(`[${label}] exec: using persistent context`);
const snippets = await this.snippetService const snippets = await this.snippetService
.buildSnippetMap() .buildSnippetMap()
@@ -207,7 +209,7 @@ export class BrowserService {
.buildSnippetMap() .buildSnippetMap()
.catch(() => ({}) as Record<string, string>); .catch(() => ({}) as Record<string, string>);
// TODO: instantiate one browser per server instance and reuse contexts for anonymous sessions, // TODO: instantiate one browser per server instance and reuse contexts for anonymous sessions,
// instead of launching a new browser for each request. // instead of launching a new browser for each request.
const browser = await chromium.launch({ const browser = await chromium.launch({
headless: true, headless: true,
@@ -81,7 +81,17 @@ export class CodeExecutorService {
* Always call `validate()` before this method. * Always call `validate()` before this method.
*/ */
async execute(ctx: ExecContext): Promise<ExecResult> { async execute(ctx: ExecContext): Promise<ExecResult> {
const { page, browser, code, log, getStepOutput, credentials, environment, snippets, result } = ctx; const {
page,
browser,
code,
log,
getStepOutput,
credentials,
environment,
snippets,
result,
} = ctx;
const scriptLog: ScriptLogger = const scriptLog: ScriptLogger =
log ?? ((level, msg) => this.logger[level](msg)); log ?? ((level, msg) => this.logger[level](msg));
const toStr = (args: unknown[]) => const toStr = (args: unknown[]) =>
@@ -146,12 +156,7 @@ export class CodeExecutorService {
"expect", "expect",
`return (async (context, ...args) => { ${snippetCode} })(context, ...snippetArgs)`, `return (async (context, ...args) => { ${snippetCode} })(context, ...snippetArgs)`,
); );
return snippetFn( return snippetFn(scriptContext, fakeConsole, args, playwrightExpect);
scriptContext,
fakeConsole,
args,
playwrightExpect,
);
}, },
}; };
@@ -52,8 +52,10 @@ export class ExecContextBuilder {
build(): ExecContext { build(): ExecContext {
if (!this.ctx.page) throw new Error("ExecContextBuilder: page is required"); if (!this.ctx.page) throw new Error("ExecContextBuilder: page is required");
if (!this.ctx.browser) throw new Error("ExecContextBuilder: browser is required"); if (!this.ctx.browser)
if (this.ctx.code === undefined) throw new Error("ExecContextBuilder: code is required"); throw new Error("ExecContextBuilder: browser is required");
if (this.ctx.code === undefined)
throw new Error("ExecContextBuilder: code is required");
return this.ctx as ExecContext; return this.ctx as ExecContext;
} }
} }
@@ -75,7 +75,9 @@ export class EnvironmentService {
}; };
} }
async importEnvironment(dto: EnvironmentExportDto): Promise<EnvironmentEntity> { async importEnvironment(
dto: EnvironmentExportDto,
): Promise<EnvironmentEntity> {
if (dto.id) { if (dto.id) {
const existing = await this.repo.findOneBy({ id: dto.id }); const existing = await this.repo.findOneBy({ id: dto.id });
if (existing) { if (existing) {
+1 -4
View File
@@ -34,10 +34,7 @@ async function bootstrap() {
const nodeEnv = config.get("NODE_ENV"); const nodeEnv = config.get("NODE_ENV");
app.enableCors({ app.enableCors({
origin: [ origin: ["http://localhost:5173", "http://127.0.0.1:5173"],
"http://localhost:5173",
"http://127.0.0.1:5173",
],
methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"], methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"],
credentials: true, credentials: true,
}); });
+21 -8
View File
@@ -359,7 +359,9 @@ export class McpService {
resolvedCredentials = {}; resolvedCredentials = {};
for (const [alias, credId] of Object.entries(credentials)) { for (const [alias, credId] of Object.entries(credentials)) {
const cred = await this.credentialService.findOne(credId); const cred = await this.credentialService.findOne(credId);
resolvedCredentials[alias] = cred.data ? JSON.parse(cred.data) : {}; resolvedCredentials[alias] = cred.data
? JSON.parse(cred.data)
: {};
} }
} }
@@ -702,13 +704,22 @@ export class McpService {
description: "Trigger an immediate run of a scenario by ID", description: "Trigger an immediate run of a scenario by ID",
inputSchema: { inputSchema: {
id: z.uuid().describe("Scenario ID to run"), id: z.uuid().describe("Scenario ID to run"),
environmentId: z.uuid().describe("Environment ID to run the scenario in"), environmentId: z
saveSession: z.boolean().optional().describe("Save session after run completes"), .uuid()
.describe("Environment ID to run the scenario in"),
saveSession: z
.boolean()
.optional()
.describe("Save session after run completes"),
}, },
}, },
async ({ id, environmentId, saveSession }) => { async ({ id, environmentId, saveSession }) => {
try { try {
const run = await this.scenarioService.createRun(id, environmentId, saveSession); const run = await this.scenarioService.createRun(
id,
environmentId,
saveSession,
);
return { return {
content: [{ type: "text" as const, text: JSON.stringify(run) }], content: [{ type: "text" as const, text: JSON.stringify(run) }],
}; };
@@ -817,9 +828,7 @@ export class McpService {
{ credentialId, alias }, { credentialId, alias },
); );
return { return {
content: [ content: [{ type: "text" as const, text: JSON.stringify(result) }],
{ type: "text" as const, text: JSON.stringify(result) },
],
}; };
} catch (err) { } catch (err) {
return { return {
@@ -836,7 +845,11 @@ export class McpService {
description: "Remove a credential assignment from a scenario", description: "Remove a credential assignment from a scenario",
inputSchema: { inputSchema: {
scenarioId: z.uuid().describe("Scenario ID"), scenarioId: z.uuid().describe("Scenario ID"),
scenarioCredentialId: z.uuid().describe("Scenario-credential assignment ID (not the credential ID)"), scenarioCredentialId: z
.uuid()
.describe(
"Scenario-credential assignment ID (not the credential ID)",
),
}, },
}, },
async ({ scenarioId, scenarioCredentialId }) => { async ({ scenarioId, scenarioCredentialId }) => {
@@ -10,7 +10,10 @@ import { CodeExecutorService } from "../code-executor/code-executor.service";
import { ExecContextBuilder } from "../code-executor/exec-context.builder"; import { ExecContextBuilder } from "../code-executor/exec-context.builder";
import { traceStorage } from "../common/trace-context"; import { traceStorage } from "../common/trace-context";
import { TraceLogger } from "../common/trace-logger"; import { TraceLogger } from "../common/trace-logger";
import { EnvironmentData, EnvironmentEntity } from "../environment/environment.entity"; import {
EnvironmentData,
EnvironmentEntity,
} from "../environment/environment.entity";
import { SessionContextService } from "../session/session-context.service"; import { SessionContextService } from "../session/session-context.service";
import { SessionService } from "../session/session.service"; import { SessionService } from "../session/session.service";
import { SnippetService } from "../snippet/snippet.service"; import { SnippetService } from "../snippet/snippet.service";
@@ -115,7 +118,7 @@ export class ScenarioSchedulerService {
const environmentData = await this.environmentRepo const environmentData = await this.environmentRepo
.findOneBy({ id: run.environmentId }) .findOneBy({ id: run.environmentId })
.then((env) => env?.data ?? {}) .then((env) => env?.data ?? {})
.catch(() => ({} as EnvironmentData)); .catch(() => ({}) as EnvironmentData);
this.runEnvironments.set(run.id, environmentData); this.runEnvironments.set(run.id, environmentData);
const traceId = crypto.randomUUID(); const traceId = crypto.randomUUID();
void traceStorage.run({ traceId }, () => void traceStorage.run({ traceId }, () =>
@@ -187,7 +190,12 @@ export class ScenarioSchedulerService {
cookies.find((c) => c.name === "token")?.value ?? cookies.find((c) => c.name === "token")?.value ??
""; "";
const session = await this.sessionService.upsert(sessionName, token, cookies, localStorage); const session = await this.sessionService.upsert(
sessionName,
token,
cookies,
localStorage,
);
await this.runRepo.update(runId, { sessionId: session.id }); await this.runRepo.update(runId, { sessionId: session.id });
this.sessionContextService.register( this.sessionContextService.register(
sessionName, sessionName,
+5 -1
View File
@@ -214,7 +214,11 @@ export class ScenarioController {
@Param("id", ParseUUIDPipe) id: string, @Param("id", ParseUUIDPipe) id: string,
@Body() dto: CreateScenarioRunDto, @Body() dto: CreateScenarioRunDto,
) { ) {
return this.scenarioService.createRun(id, dto.environmentId, dto.saveSession); return this.scenarioService.createRun(
id,
dto.environmentId,
dto.saveSession,
);
} }
@Get(":id/run/:runId") @Get(":id/run/:runId")
+12 -2
View File
@@ -322,7 +322,12 @@ export class ScenarioService {
await this.findOne(scenarioId); // 404 guard await this.findOne(scenarioId); // 404 guard
const run = await this.runRepo.findOne({ const run = await this.runRepo.findOne({
where: { id: runId, scenarioId }, where: { id: runId, scenarioId },
relations: ["stepRuns", "stepRuns.scenarioStep", "environment", "session"], relations: [
"stepRuns",
"stepRuns.scenarioStep",
"environment",
"session",
],
order: { stepRuns: { order: "ASC" } }, order: { stepRuns: { order: "ASC" } },
}); });
if (!run) if (!run)
@@ -370,7 +375,12 @@ export class ScenarioService {
} }
const run = await this.runRepo.save( const run = await this.runRepo.save(
this.runRepo.create({ scenarioId, environmentId, status: "pending", saveSession }), this.runRepo.create({
scenarioId,
environmentId,
status: "pending",
saveSession,
}),
); );
const stepRuns = scenario.steps.map((step, index) => const stepRuns = scenario.steps.map((step, index) =>
+12 -3
View File
@@ -15,7 +15,12 @@ import { SnippetExportDto } from "./dto/snippet-export.dto";
import { UpdateSnippetDto } from "./dto/update-snippet.dto"; import { UpdateSnippetDto } from "./dto/update-snippet.dto";
import { SnippetEntity } from "./snippet.entity"; import { SnippetEntity } from "./snippet.entity";
export type SnippetOrderBy = "id" | "alias" | "title" | "createdAt" | "updatedAt"; export type SnippetOrderBy =
| "id"
| "alias"
| "title"
| "createdAt"
| "updatedAt";
@Injectable() @Injectable()
export class SnippetService implements OnModuleInit { export class SnippetService implements OnModuleInit {
@@ -47,7 +52,9 @@ export class SnippetService implements OnModuleInit {
async create(dto: CreateSnippetDto): Promise<SnippetEntity> { async create(dto: CreateSnippetDto): Promise<SnippetEntity> {
const existing = await this.repo.findOneBy({ alias: dto.alias }); const existing = await this.repo.findOneBy({ alias: dto.alias });
if (existing) { if (existing) {
throw new ConflictException(`Snippet alias "${dto.alias}" already exists`); throw new ConflictException(
`Snippet alias "${dto.alias}" already exists`,
);
} }
return this.repo.save( return this.repo.save(
this.repo.create({ this.repo.create({
@@ -85,7 +92,9 @@ export class SnippetService implements OnModuleInit {
if (dto.alias && dto.alias !== snippet.alias) { if (dto.alias && dto.alias !== snippet.alias) {
const conflict = await this.repo.findOneBy({ alias: dto.alias }); const conflict = await this.repo.findOneBy({ alias: dto.alias });
if (conflict) if (conflict)
throw new ConflictException(`Snippet alias "${dto.alias}" already exists`); throw new ConflictException(
`Snippet alias "${dto.alias}" already exists`,
);
} }
Object.assign(snippet, dto); Object.assign(snippet, dto);
return this.repo.save(snippet); return this.repo.save(snippet);
+54 -4
View File
@@ -3,6 +3,8 @@ import { getRepositoryToken } from "@nestjs/typeorm";
import request from "supertest"; import request from "supertest";
import { buildTestApp } from "./app.harness"; import { buildTestApp } from "./app.harness";
import { SessionEntity } from "../src/session/session.entity"; import { SessionEntity } from "../src/session/session.entity";
import { EnvironmentEntity } from "../src/environment/environment.entity";
import { CredentialEntity } from "../src/credential/credential.entity";
import { Repository } from "typeorm"; import { Repository } from "typeorm";
/** /**
@@ -15,6 +17,8 @@ import { Repository } from "typeorm";
describe("BrowserController", () => { describe("BrowserController", () => {
let app: INestApplication; let app: INestApplication;
let sessionRepo: Repository<SessionEntity>; let sessionRepo: Repository<SessionEntity>;
let environmentRepo: Repository<EnvironmentEntity>;
let credentialRepo: Repository<CredentialEntity>;
const FAKE_SESSION = "test-browser-session"; const FAKE_SESSION = "test-browser-session";
@@ -23,6 +27,12 @@ describe("BrowserController", () => {
sessionRepo = app.get<Repository<SessionEntity>>( sessionRepo = app.get<Repository<SessionEntity>>(
getRepositoryToken(SessionEntity), getRepositoryToken(SessionEntity),
); );
environmentRepo = app.get<Repository<EnvironmentEntity>>(
getRepositoryToken(EnvironmentEntity),
);
credentialRepo = app.get<Repository<CredentialEntity>>(
getRepositoryToken(CredentialEntity),
);
// Seed a session with minimal but valid JSON so the browser code can // Seed a session with minimal but valid JSON so the browser code can
// deserialise it (it will still fail to open a real page, tested separately) // deserialise it (it will still fail to open a real page, tested separately)
@@ -40,6 +50,33 @@ describe("BrowserController", () => {
await app.close(); await app.close();
}); });
async function createEnvironment(
data: Record<string, string | undefined>,
name = `browser-env-${Math.random().toString(36).slice(2, 8)}`,
): Promise<string> {
const environment = await environmentRepo.save(
environmentRepo.create({
name,
data,
}),
);
return environment.id;
}
async function createCredential(
data: Record<string, unknown>,
name = `browser-cred-${Math.random().toString(36).slice(2, 8)}`,
): Promise<string> {
const credential = await credentialRepo.save(
credentialRepo.create({
name,
data: JSON.stringify(data),
lastUsedAt: null,
}),
);
return credential.id;
}
// ── POST /open ───────────────────────────────────────────────────────────── // ── POST /open ─────────────────────────────────────────────────────────────
describe("POST /open", () => { describe("POST /open", () => {
@@ -137,35 +174,46 @@ describe("BrowserController", () => {
}); });
it("exposes environment via context.env in a named session", async () => { it("exposes environment via context.env in a named session", async () => {
const environmentId = await createEnvironment({
BASE_URL: "https://env.example.com",
});
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post("/exec") .post("/exec")
.send({ .send({
sessionName: "no-such-session", sessionName: "no-such-session",
code: "return context.env.BASE_URL;", code: "return context.env.BASE_URL;",
environment: { BASE_URL: "https://env.example.com" }, environmentId,
}) })
.expect(201); .expect(201);
expect(res.body).toEqual({ result: "https://env.example.com" }); expect(res.body).toEqual({ result: "https://env.example.com" });
}); });
it("exposes environment via context.env in a sessionless exec", async () => { it("exposes environment via context.env in a sessionless exec", async () => {
const environmentId = await createEnvironment({ KEY: "value123" });
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post("/exec") .post("/exec")
.send({ .send({
code: "return context.env.KEY;", code: "return context.env.KEY;",
environment: { KEY: "value123" }, environmentId,
}) })
.expect(201); .expect(201);
expect(res.body).toEqual({ result: "value123" }); expect(res.body).toEqual({ result: "value123" });
}); });
it("exposes credentials via context.getCredential in a named session", async () => { it("exposes credentials via context.getCredential in a named session", async () => {
const credentialId = await createCredential({
username: "user1",
password: "pass1",
});
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post("/exec") .post("/exec")
.send({ .send({
sessionName: "no-such-session", sessionName: "no-such-session",
code: "return context.getCredential('admin');", code: "return context.getCredential('admin');",
credentials: { admin: { username: "user1", password: "pass1" } }, credentials: { admin: credentialId },
}) })
.expect(201); .expect(201);
expect(res.body).toEqual({ expect(res.body).toEqual({
@@ -174,11 +222,13 @@ describe("BrowserController", () => {
}); });
it("exposes credentials via context.getCredential in a sessionless exec", async () => { it("exposes credentials via context.getCredential in a sessionless exec", async () => {
const credentialId = await createCredential({ token: "abc" });
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.post("/exec") .post("/exec")
.send({ .send({
code: "return context.getCredential('svc');", code: "return context.getCredential('svc');",
credentials: { svc: { token: "abc" } }, credentials: { svc: credentialId },
}) })
.expect(201); .expect(201);
expect(res.body).toEqual({ result: { token: "abc" } }); expect(res.body).toEqual({ result: { token: "abc" } });
+3 -1
View File
@@ -160,7 +160,9 @@ describe("EnvironmentController", () => {
}); });
it("returns 404 for unknown id", async () => { it("returns 404 for unknown id", async () => {
await request(app.getHttpServer()).get("/environments/00000000-0000-0000-0000-000000000001").expect(404); await request(app.getHttpServer())
.get("/environments/00000000-0000-0000-0000-000000000001")
.expect(404);
}); });
it("returns 400 for non-numeric id", async () => { it("returns 400 for non-numeric id", async () => {
+4 -1
View File
@@ -75,7 +75,10 @@ describe("McpController", () => {
it("returns MCP error for removed tool", async () => { it("returns MCP error for removed tool", async () => {
const { status, rpc } = await mcpCall("list_keys"); const { status, rpc } = await mcpCall("list_keys");
expect(status).toBe(200); expect(status).toBe(200);
const result = rpc.result as { isError: boolean; content?: { text: string }[] }; const result = rpc.result as {
isError: boolean;
content?: { text: string }[];
};
expect(result.isError).toBe(true); expect(result.isError).toBe(true);
}); });
}); });
+44 -27
View File
@@ -163,7 +163,9 @@ describe("ScenarioController", () => {
}); });
it("returns 404 for unknown id", async () => { it("returns 404 for unknown id", async () => {
await request(app.getHttpServer()).get("/scenarios/00000000-0000-0000-0000-000000000001").expect(404); await request(app.getHttpServer())
.get("/scenarios/00000000-0000-0000-0000-000000000001")
.expect(404);
}); });
}); });
@@ -199,7 +201,9 @@ describe("ScenarioController", () => {
}); });
it("returns 404 for unknown id", async () => { it("returns 404 for unknown id", async () => {
await request(app.getHttpServer()).delete("/scenarios/00000000-0000-0000-0000-000000000001").expect(404); await request(app.getHttpServer())
.delete("/scenarios/00000000-0000-0000-0000-000000000001")
.expect(404);
}); });
}); });
@@ -327,8 +331,8 @@ describe("ScenarioController", () => {
it("reorders by exact target index", async () => { it("reorders by exact target index", async () => {
const sc = await createScenario(); const sc = await createScenario();
const stepA = await createStep(sc.id, { title: "A" }); const stepA = await createStep(sc.id, { title: "A" });
const stepB = await createStep(sc.id, { title: "B" }); await createStep(sc.id, { title: "B" });
const stepC = await createStep(sc.id, { title: "C" }); await createStep(sc.id, { title: "C" });
const stepD = await createStep(sc.id, { title: "D" }); const stepD = await createStep(sc.id, { title: "D" });
await request(app.getHttpServer()) await request(app.getHttpServer())
@@ -339,12 +343,15 @@ describe("ScenarioController", () => {
let res = await request(app.getHttpServer()) let res = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}`) .get(`/scenarios/${sc.id}`)
.expect(200); .expect(200);
expect( expect(res.body.steps.map((s: { title: string }) => s.title)).toEqual([
res.body.steps.map((s: { title: string }) => s.title), "B",
).toEqual(["B", "C", "A", "D"]); "C",
expect( "A",
res.body.steps.map((s: { order: number }) => s.order), "D",
).toEqual([0, 1, 2, 3]); ]);
expect(res.body.steps.map((s: { order: number }) => s.order)).toEqual([
0, 1, 2, 3,
]);
await request(app.getHttpServer()) await request(app.getHttpServer())
.patch(`/scenarios/${sc.id}/steps/${stepD.id}`) .patch(`/scenarios/${sc.id}/steps/${stepD.id}`)
@@ -354,12 +361,15 @@ describe("ScenarioController", () => {
res = await request(app.getHttpServer()) res = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}`) .get(`/scenarios/${sc.id}`)
.expect(200); .expect(200);
expect( expect(res.body.steps.map((s: { title: string }) => s.title)).toEqual([
res.body.steps.map((s: { title: string }) => s.title), "D",
).toEqual(["D", "B", "C", "A"]); "B",
expect( "C",
res.body.steps.map((s: { order: number }) => s.order), "A",
).toEqual([0, 1, 2, 3]); ]);
expect(res.body.steps.map((s: { order: number }) => s.order)).toEqual([
0, 1, 2, 3,
]);
}); });
}); });
@@ -395,9 +405,7 @@ describe("ScenarioController", () => {
expect(Array.isArray(res.stepRuns)).toBe(true); expect(Array.isArray(res.stepRuns)).toBe(true);
expect(res.stepRuns).toHaveLength(3); expect(res.stepRuns).toHaveLength(3);
const statuses = res.stepRuns.map( const statuses = res.stepRuns.map((s: { status: string }) => s.status);
(s: { status: string }) => s.status,
);
expect(statuses[0]).toBe("pending"); expect(statuses[0]).toBe("pending");
expect(statuses[1]).toBe("waiting"); expect(statuses[1]).toBe("waiting");
expect(statuses[2]).toBe("waiting"); expect(statuses[2]).toBe("waiting");
@@ -495,9 +503,18 @@ describe("ScenarioController", () => {
it("exports steps ordered by sequential position", async () => { it("exports steps ordered by sequential position", async () => {
const sc = await createScenario("export-order"); const sc = await createScenario("export-order");
await createStep(sc.id, { sessionName: "s", execCode: "return 'first';" }); await createStep(sc.id, {
await createStep(sc.id, { sessionName: "s", execCode: "return 'second';" }); sessionName: "s",
await createStep(sc.id, { sessionName: "s", execCode: "return 'third';" }); execCode: "return 'first';",
});
await createStep(sc.id, {
sessionName: "s",
execCode: "return 'second';",
});
await createStep(sc.id, {
sessionName: "s",
execCode: "return 'third';",
});
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/export`) .get(`/scenarios/${sc.id}/export`)
@@ -616,9 +633,7 @@ describe("ScenarioController", () => {
expect(importRes.body.name).toBe("roundtrip"); expect(importRes.body.name).toBe("roundtrip");
expect(importRes.body.steps).toHaveLength(1); expect(importRes.body.steps).toHaveLength(1);
expect(importRes.body.steps[0].execCode).toBe( expect(importRes.body.steps[0].execCode).toBe(exported.steps[0].execCode);
exported.steps[0].execCode,
);
}); });
it("imports with empty steps array", async () => { it("imports with empty steps array", async () => {
@@ -663,7 +678,7 @@ describe("ScenarioController", () => {
const sc = await createScenario(); const sc = await createScenario();
await createStep(sc.id, { order: 0 }); await createStep(sc.id, { order: 0 });
const runRes = await createRun(sc.id); const runRes = await createRun(sc.id);
const runId = runRes.id; const runId = runRes.id;
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
.get(`/scenarios/${sc.id}/run/${runId}`) .get(`/scenarios/${sc.id}/run/${runId}`)
@@ -766,7 +781,9 @@ describe("ScenarioController", () => {
it("returns 404 for unknown run", async () => { it("returns 404 for unknown run", async () => {
const sc = await createScenario(); const sc = await createScenario();
await request(app.getHttpServer()) await request(app.getHttpServer())
.post(`/scenarios/${sc.id}/run/00000000-0000-0000-0000-000000000001/wait`) .post(
`/scenarios/${sc.id}/run/00000000-0000-0000-0000-000000000001/wait`,
)
.expect(404); .expect(404);
}); });
+3 -1
View File
@@ -141,7 +141,9 @@ describe("SessionController", () => {
}); });
it("returns 404 for unknown id", async () => { it("returns 404 for unknown id", async () => {
await request(app.getHttpServer()).delete("/sessions/00000000-0000-0000-0000-000000000001").expect(404); await request(app.getHttpServer())
.delete("/sessions/00000000-0000-0000-0000-000000000001")
.expect(404);
}); });
it("returns 400 for non-numeric id", async () => { it("returns 400 for non-numeric id", async () => {