feat(scenarios): add timeoutSeconds to scenarios and steps

- add timeoutSeconds field to scenario and step entities
- add timeoutSeconds to create/update DTOs for scenario and step
- enforce timeout via scheduler: abort run if step exceeds limit
- expose timeoutSeconds in MCP create/update scenario and step tools
- add client-side type, API, i18n, and form support for timeoutSeconds
- add integration tests for timeout persistence via REST and MCP
This commit is contained in:
2026-04-17 13:46:30 +03:00
parent 8dbd319bba
commit a50dce2755
19 changed files with 402 additions and 12 deletions
+8
View File
@@ -13,3 +13,11 @@ client/dist
*storybook.log *storybook.log
storybook-static storybook-static
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
# organization
TODO.md
# IDE/editor
.vscode/
.idea/
.github/
+4 -2
View File
@@ -193,7 +193,7 @@ export const scenarios = {
body: JSON.stringify({ name, description, environmentId }), body: JSON.stringify({ name, description, environmentId }),
}); });
}, },
update(id: string, patch: Partial<Pick<Scenario, 'name' | 'description' | 'environmentId'>>): Promise<Scenario> { update(id: string, patch: Partial<Pick<Scenario, 'name' | 'description' | 'environmentId' | 'timeoutSeconds'>>): Promise<Scenario> {
return request(`/scenarios/${id}`, { return request(`/scenarios/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(patch), body: JSON.stringify(patch),
@@ -254,7 +254,7 @@ export const runs = {
orderDir: 'ASC' | 'DESC' = 'DESC', orderDir: 'ASC' | 'DESC' = 'DESC',
): Promise< ): Promise<
PaginatedResponse< PaginatedResponse<
ScenarioRun & { stepRuns: ScenarioRunStep[]; scenario: { id: string; name: string } } ScenarioRun & { scenario: { id: string; name: string } }
> >
> { > {
const q = new URLSearchParams({ page: String(page), limit: String(limit), orderBy, orderDir }); const q = new URLSearchParams({ page: String(page), limit: String(limit), orderBy, orderDir });
@@ -299,12 +299,14 @@ export const scenarioCredentials = {
export interface CreateStepPayload { export interface CreateStepPayload {
title?: string; title?: string;
execCode?: string; execCode?: string;
timeoutSeconds?: number | null;
} }
export interface UpdateStepPayload { export interface UpdateStepPayload {
title?: string; title?: string;
order?: number; order?: number;
execCode?: string; execCode?: string;
timeoutSeconds?: number | null;
} }
export const steps = { export const steps = {
+2
View File
@@ -67,6 +67,7 @@ export interface ScenarioStep {
order: number; order: number;
title: string | null; title: string | null;
execCode: string | null; execCode: string | null;
timeoutSeconds: number | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -85,6 +86,7 @@ export interface Scenario {
name: string; name: string;
description?: string; description?: string;
environmentId?: string | null; environmentId?: string | null;
timeoutSeconds?: number | null;
environment?: Pick<Environment, 'id' | 'name'>; environment?: Pick<Environment, 'id' | 'name'>;
steps?: ScenarioStep[]; steps?: ScenarioStep[];
scenarioCredentials?: ScenarioCredential[]; scenarioCredentials?: ScenarioCredential[];
+7 -1
View File
@@ -185,6 +185,9 @@
"export_modal_title": "Export Scenario", "export_modal_title": "Export Scenario",
"export_include_environment": "Include Environment", "export_include_environment": "Include Environment",
"export_include_credentials": "Include Credentials", "export_include_credentials": "Include Credentials",
"form_timeout": "Scenario Timeout (seconds)",
"form_timeout_placeholder": "Default: 600",
"form_timeout_hint": "Max seconds for the entire scenario run (default: 600)",
"field_name": "Name", "field_name": "Name",
"field_id": "ID" "field_id": "ID"
}, },
@@ -222,7 +225,10 @@
"form_session_placeholder": "e.g. my-session", "form_session_placeholder": "e.g. my-session",
"form_session_required": "Session name is required", "form_session_required": "Session name is required",
"form_exec_code": "Exec code", "form_exec_code": "Exec code",
"form_exec_code_placeholder": "return await page.title();" "form_exec_code_placeholder": "return await page.title();",
"form_timeout": "Step Timeout (seconds)",
"form_timeout_placeholder": "Default: 60",
"form_timeout_hint": "Max seconds for this step (default: 60)"
}, },
"runs": { "runs": {
"title": "Runs", "title": "Runs",
+1 -1
View File
@@ -33,7 +33,7 @@ const STATUS_LABEL: Record<ScenarioRunStatus, string> = {
}; };
type AllRunRow = ScenarioRun & { type AllRunRow = ScenarioRun & {
scenario: { id: number; name: string }; scenario: { id: string; name: string };
}; };
export function AllRunsPage() { export function AllRunsPage() {
@@ -16,6 +16,7 @@ export function CreateStepPage() {
const [scenario, setScenario] = useState<Scenario | null>(null); const [scenario, setScenario] = useState<Scenario | null>(null);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [execCode, setExecCode] = useState(''); const [execCode, setExecCode] = useState('');
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
@@ -33,6 +34,7 @@ export function CreateStepPage() {
await steps.create(id!, { await steps.create(id!, {
title: title.trim() || undefined, title: title.trim() || undefined,
execCode: execCode.trim() || undefined, execCode: execCode.trim() || undefined,
timeoutSeconds: timeoutSeconds.trim() ? Number(timeoutSeconds) : undefined,
}); });
toast.success(t('steps.created')); toast.success(t('steps.created'));
navigate(`/scenarios/${id}`); navigate(`/scenarios/${id}`);
@@ -76,6 +78,15 @@ export function CreateStepPage() {
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label> <label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
<CodeEditor value={execCode} onChange={setExecCode} rows={6} /> <CodeEditor value={execCode} onChange={setExecCode} rows={6} />
</div> </div>
<Input
label={t('steps.form_timeout')}
placeholder={t('steps.form_timeout_placeholder')}
type="number"
min={1}
value={timeoutSeconds}
onChange={(e) => setTimeoutSeconds(e.target.value)}
hint={t('steps.form_timeout_hint')}
/>
</div> </div>
<div className={styles.formActions}> <div className={styles.formActions}>
@@ -17,6 +17,7 @@ export function EditScenarioPage() {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [environmentId, setEnvironmentId] = useState<string>(''); const [environmentId, setEnvironmentId] = useState<string>('');
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
const [envs, setEnvs] = useState<Environment[]>([]); const [envs, setEnvs] = useState<Environment[]>([]);
const [nameError, setNameError] = useState(''); const [nameError, setNameError] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -31,6 +32,7 @@ export function EditScenarioPage() {
setName(data.name); setName(data.name);
setDescription(data.description || ''); setDescription(data.description || '');
setEnvironmentId(data.environmentId ?? ''); setEnvironmentId(data.environmentId ?? '');
setTimeoutSeconds(data.timeoutSeconds != null ? String(data.timeoutSeconds) : '');
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {}); environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {});
@@ -48,6 +50,7 @@ export function EditScenarioPage() {
name: name.trim(), name: name.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
environmentId: environmentId || null, environmentId: environmentId || null,
timeoutSeconds: timeoutSeconds.trim() ? Number(timeoutSeconds) : null,
}); });
toast.success(t('scenarios.updated')); toast.success(t('scenarios.updated'));
navigate(`/scenarios/${id}`); navigate(`/scenarios/${id}`);
@@ -114,6 +117,15 @@ export function EditScenarioPage() {
...envs.map((env) => ({ value: env.id, label: env.name })), ...envs.map((env) => ({ value: env.id, label: env.name })),
]} ]}
/> />
<Input
label={t('scenarios.form_timeout')}
placeholder={t('scenarios.form_timeout_placeholder')}
type="number"
min={1}
value={timeoutSeconds}
onChange={(e) => setTimeoutSeconds(e.target.value)}
hint={t('scenarios.form_timeout_hint')}
/>
</div> </div>
<div className={styles.formActions}> <div className={styles.formActions}>
+13 -1
View File
@@ -17,6 +17,7 @@ export function EditStepPage() {
const [step, setStep] = useState<ScenarioStep | null>(null); const [step, setStep] = useState<ScenarioStep | null>(null);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [execCode, setExecCode] = useState(''); const [execCode, setExecCode] = useState('');
const [timeoutSeconds, setTimeoutSeconds] = useState<string>('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -28,6 +29,7 @@ export function EditStepPage() {
setStep(st); setStep(st);
setTitle(st.title ?? ''); setTitle(st.title ?? '');
setExecCode(st.execCode ?? ''); setExecCode(st.execCode ?? '');
setTimeoutSeconds(st.timeoutSeconds != null ? String(st.timeoutSeconds) : '');
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [id, stepId]); }, [id, stepId]);
@@ -39,6 +41,7 @@ export function EditStepPage() {
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,
timeoutSeconds: timeoutSeconds.trim() ? Number(timeoutSeconds) : null,
}); });
toast.success(t('steps.updated')); toast.success(t('steps.updated'));
navigate(`/scenarios/${id}`); navigate(`/scenarios/${id}`);
@@ -64,7 +67,7 @@ export function EditStepPage() {
label: scenario?.name ?? `#${id}`, label: scenario?.name ?? `#${id}`,
onClick: () => navigate(`/scenarios/${id}`), onClick: () => navigate(`/scenarios/${id}`),
}, },
{ label: t('steps.edit_title', { order: step ? step.order + 1 : stepId }) }, { label: t('steps.edit_title', { order: step ? step.order : stepId }) },
]} ]}
/> />
</div> </div>
@@ -85,6 +88,15 @@ export function EditStepPage() {
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label> <label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
<CodeEditor value={execCode} onChange={setExecCode} rows={6} /> <CodeEditor value={execCode} onChange={setExecCode} rows={6} />
</div> </div>
<Input
label={t('steps.form_timeout')}
placeholder={t('steps.form_timeout_placeholder')}
type="number"
min={1}
value={timeoutSeconds}
onChange={(e) => setTimeoutSeconds(e.target.value)}
hint={t('steps.form_timeout_hint')}
/>
</div> </div>
<div className={styles.formActions}> <div className={styles.formActions}>
@@ -288,7 +288,7 @@ export function ScenarioDetailPage() {
</span> </span>
), ),
}, },
{ key: 'order', header: t('scenarios.step_order'), render: (s) => s.order + 1, width: 60 }, { key: 'order', header: t('scenarios.step_order'), render: (s) => s.order, width: 60 },
{ {
key: 'title', key: 'title',
header: t('scenarios.step_title'), header: t('scenarios.step_title'),
+30 -2
View File
@@ -476,14 +476,21 @@ export class McpService {
.uuid() .uuid()
.optional() .optional()
.describe("Optional linked environment ID"), .describe("Optional linked environment ID"),
timeoutSeconds: z
.number()
.int()
.min(1)
.optional()
.describe("Scenario-level timeout in seconds (default 600)"),
}, },
}, },
async ({ name, description, environmentId }) => { async ({ name, description, environmentId, timeoutSeconds }) => {
try { try {
const scenario = await this.scenarioService.create({ const scenario = await this.scenarioService.create({
name, name,
description, description,
environmentId, environmentId,
timeoutSeconds,
}); });
return { return {
content: [ content: [
@@ -515,14 +522,22 @@ export class McpService {
.nullable() .nullable()
.optional() .optional()
.describe("Linked environment ID (null to unlink)"), .describe("Linked environment ID (null to unlink)"),
timeoutSeconds: z
.number()
.int()
.min(1)
.nullable()
.optional()
.describe("Scenario-level timeout in seconds (null to reset to default 600)"),
}, },
}, },
async ({ id, name, description, environmentId }) => { async ({ id, name, description, environmentId, timeoutSeconds }) => {
try { try {
const scenario = await this.scenarioService.update(id, { const scenario = await this.scenarioService.update(id, {
name, name,
description, description,
environmentId, environmentId,
timeoutSeconds,
}); });
return { return {
content: [ content: [
@@ -580,6 +595,12 @@ export class McpService {
.string() .string()
.optional() .optional()
.describe("Playwright JS code to execute (exec steps)"), .describe("Playwright JS code to execute (exec steps)"),
timeoutSeconds: z
.number()
.int()
.min(1)
.optional()
.describe("Step-level timeout in seconds (default 60, falls back to scenario timeout)"),
}, },
}, },
async ({ scenarioId, ...dto }) => { async ({ scenarioId, ...dto }) => {
@@ -640,6 +661,13 @@ export class McpService {
.describe("New step type"), .describe("New step type"),
title: z.string().optional().describe("New step title"), title: z.string().optional().describe("New step title"),
execCode: z.string().optional().describe("New exec code"), execCode: z.string().optional().describe("New exec code"),
timeoutSeconds: z
.number()
.int()
.min(1)
.nullable()
.optional()
.describe("Step-level timeout in seconds (null to reset to default)"),
}, },
}, },
async ({ scenarioId, stepId, ...dto }) => { async ({ scenarioId, stepId, ...dto }) => {
@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from "@nestjs/swagger"; import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString } from "class-validator"; import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from "class-validator";
export class CreateScenarioStepDto { export class CreateScenarioStepDto {
@ApiPropertyOptional({ example: "Check login page title" }) @ApiPropertyOptional({ example: "Check login page title" })
@@ -12,4 +12,10 @@ export class CreateScenarioStepDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
execCode?: string; execCode?: string;
@ApiPropertyOptional({ description: "Step timeout in seconds (default: 60)" })
@IsOptional()
@IsInt()
@Min(1)
timeoutSeconds?: number;
} }
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator"; import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min } from "class-validator";
export class CreateScenarioDto { export class CreateScenarioDto {
@ApiProperty({ example: "Login and verify cabinet" }) @ApiProperty({ example: "Login and verify cabinet" })
@@ -16,4 +16,10 @@ export class CreateScenarioDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
environmentId?: string; environmentId?: string;
@ApiPropertyOptional({ description: "Scenario timeout in seconds (default: 600)" })
@IsOptional()
@IsInt()
@Min(1)
timeoutSeconds?: number;
} }
@@ -18,4 +18,10 @@ export class UpdateScenarioStepDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
execCode?: string; execCode?: string;
@ApiPropertyOptional({ description: "Step timeout in seconds (null to use default of 60)" })
@IsOptional()
@IsInt()
@Min(1)
timeoutSeconds?: number | null;
} }
@@ -1,5 +1,5 @@
import { ApiPropertyOptional } from "@nestjs/swagger"; import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator"; import { IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Min } from "class-validator";
export class UpdateScenarioDto { export class UpdateScenarioDto {
@ApiPropertyOptional({ example: "Updated scenario name" }) @ApiPropertyOptional({ example: "Updated scenario name" })
@@ -17,4 +17,10 @@ export class UpdateScenarioDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
environmentId?: string | null; environmentId?: string | null;
@ApiPropertyOptional({ description: "Scenario timeout in seconds (null to use default of 600)" })
@IsOptional()
@IsInt()
@Min(1)
timeoutSeconds?: number | null;
} }
@@ -40,6 +40,11 @@ export class ScenarioSchedulerService {
private readonly runSnippets = new Map<string, Record<string, string>>(); private readonly runSnippets = new Map<string, Record<string, string>>();
// Cache environment values per run (built once when a run starts) // Cache environment values per run (built once when a run starts)
private readonly runEnvironments = new Map<string, EnvironmentData>(); private readonly runEnvironments = new Map<string, EnvironmentData>();
// Cache scenario-level timeout (seconds) per run
private readonly runScenarioTimeouts = new Map<string, number | null>();
private static readonly DEFAULT_SCENARIO_TIMEOUT_SEC = 600;
private static readonly DEFAULT_STEP_TIMEOUT_SEC = 60;
constructor( constructor(
@InjectRepository(ScenarioRunEntity) @InjectRepository(ScenarioRunEntity)
@@ -120,6 +125,11 @@ export class ScenarioSchedulerService {
.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);
// Cache scenario-level timeout for the run
const scenario = await this.scenarioService
.findOne(run.scenarioId)
.catch(() => null);
this.runScenarioTimeouts.set(run.id, scenario?.timeoutSeconds ?? null);
const traceId = crypto.randomUUID(); const traceId = crypto.randomUUID();
void traceStorage.run({ traceId }, () => void traceStorage.run({ traceId }, () =>
this.processRunToCompletion(run.id), this.processRunToCompletion(run.id),
@@ -128,6 +138,10 @@ export class ScenarioSchedulerService {
} }
private async processRunToCompletion(runId: string): Promise<void> { private async processRunToCompletion(runId: string): Promise<void> {
const scenarioTimeoutSec =
this.runScenarioTimeouts.get(runId) ??
ScenarioSchedulerService.DEFAULT_SCENARIO_TIMEOUT_SEC;
const scenarioDeadline = Date.now() + scenarioTimeoutSec * 1000;
try { try {
let stepRun = await this.runStepRepo.findOne({ let stepRun = await this.runStepRepo.findOne({
where: { runId, status: "pending" }, where: { runId, status: "pending" },
@@ -135,6 +149,28 @@ export class ScenarioSchedulerService {
order: { order: "ASC" }, order: { order: "ASC" },
}); });
while (stepRun) { while (stepRun) {
if (Date.now() >= scenarioDeadline) {
this.logger.error(
`Run #${runId}: exceeded scenario timeout of ${scenarioTimeoutSec}s`,
);
this.persistLog(
runId,
null,
"error",
`Scenario timed out after ${scenarioTimeoutSec}s`,
);
await this.runRepo.update(runId, { status: "fail" });
await this.runStepRepo
.createQueryBuilder()
.update()
.set({ status: "cancelled" })
.where("runId = :runId AND status IN (:...statuses)", {
runId,
statuses: ["waiting", "pending", "in_progress"],
})
.execute();
return;
}
await this.executeStepRun(stepRun); await this.executeStepRun(stepRun);
stepRun = await this.runStepRepo.findOne({ stepRun = await this.runStepRepo.findOne({
where: { runId, status: "pending" }, where: { runId, status: "pending" },
@@ -158,6 +194,7 @@ export class ScenarioSchedulerService {
this.runCredentials.delete(runId); this.runCredentials.delete(runId);
this.runSnippets.delete(runId); this.runSnippets.delete(runId);
this.runEnvironments.delete(runId); this.runEnvironments.delete(runId);
this.runScenarioTimeouts.delete(runId);
await this.maybePreserveSession(runId); await this.maybePreserveSession(runId);
} }
} }
@@ -245,7 +282,24 @@ export class ScenarioSchedulerService {
.environment(env) .environment(env)
.snippets(snips) .snippets(snips)
.build(); .build();
const { result: execOutput } = await this.codeExecutor.execute(execCtx);
const scenarioTimeoutSec = this.runScenarioTimeouts.get(stepRun.runId) ?? null;
const stepTimeoutSec =
step.timeoutSeconds ??
scenarioTimeoutSec ??
ScenarioSchedulerService.DEFAULT_STEP_TIMEOUT_SEC;
const stepTimeoutMs = stepTimeoutSec * 1000;
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`Step timed out after ${stepTimeoutSec}s`)),
stepTimeoutMs,
),
);
const { result: execOutput } = await Promise.race([
this.codeExecutor.execute(execCtx),
timeoutPromise,
]);
await this.passStepRun(stepRun, null, execOutput); await this.passStepRun(stepRun, null, execOutput);
} catch (err) { } catch (err) {
@@ -32,6 +32,9 @@ export class ScenarioStepEntity {
@Column({ type: "text", nullable: true }) @Column({ type: "text", nullable: true })
execCode: string | null; execCode: string | null;
@Column({ type: "int", nullable: true })
timeoutSeconds: number | null;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
+3
View File
@@ -26,6 +26,9 @@ export class ScenarioEntity {
@Column({ nullable: true, type: "text" }) @Column({ nullable: true, type: "text" })
environmentId: string | null; environmentId: string | null;
@Column({ type: "int", nullable: true })
timeoutSeconds: number | null;
@ManyToOne(() => EnvironmentEntity, { nullable: true, onDelete: "SET NULL", eager: false }) @ManyToOne(() => EnvironmentEntity, { nullable: true, onDelete: "SET NULL", eager: false })
@JoinColumn({ name: "environmentId" }) @JoinColumn({ name: "environmentId" })
environment: EnvironmentEntity | null; environment: EnvironmentEntity | null;
+143
View File
@@ -141,4 +141,147 @@ describe("McpController", () => {
expect(result.isError).toBe(true); expect(result.isError).toBe(true);
}); });
}); });
// ── create_scenario / update_scenario timeoutSeconds ──────────────────────
describe("create_scenario with timeoutSeconds", () => {
it("saves timeoutSeconds on the created scenario", async () => {
const { status, rpc } = await mcpCall("create_scenario", {
name: "mcp-timeout-sc",
timeoutSeconds: 300,
});
expect(status).toBe(200);
const result = rpc.result as { content: { text: string }[] };
const created = JSON.parse(result.content[0].text) as {
name: string;
timeoutSeconds: number | null;
};
expect(created.name).toBe("mcp-timeout-sc");
expect(created.timeoutSeconds).toBe(300);
});
it("stores null timeoutSeconds when not provided", async () => {
const { rpc } = await mcpCall("create_scenario", {
name: "mcp-no-timeout",
});
const result = rpc.result as { content: { text: string }[] };
const created = JSON.parse(result.content[0].text) as {
timeoutSeconds: number | null;
};
expect(created.timeoutSeconds).toBeNull();
});
});
describe("update_scenario timeoutSeconds", () => {
it("updates and clears timeoutSeconds", async () => {
// create
const createRpc = (
await mcpCall("create_scenario", { name: "mcp-upd-timeout" })
).rpc;
const created = JSON.parse(
(createRpc.result as { content: { text: string }[] }).content[0].text,
) as { id: string };
// set
const setRpc = (
await mcpCall("update_scenario", {
id: created.id,
timeoutSeconds: 120,
})
).rpc;
const updated = JSON.parse(
(setRpc.result as { content: { text: string }[] }).content[0].text,
) as { timeoutSeconds: number | null };
expect(updated.timeoutSeconds).toBe(120);
// clear
const clearRpc = (
await mcpCall("update_scenario", {
id: created.id,
timeoutSeconds: null,
})
).rpc;
const cleared = JSON.parse(
(clearRpc.result as { content: { text: string }[] }).content[0].text,
) as { timeoutSeconds: number | null };
expect(cleared.timeoutSeconds).toBeNull();
});
});
// ── create_scenario_step / update_scenario_step timeoutSeconds ────────────
describe("create_scenario_step with timeoutSeconds", () => {
it("saves timeoutSeconds on the created step", async () => {
// create a scenario first
const scRpc = (
await mcpCall("create_scenario", { name: "mcp-step-timeout-parent" })
).rpc;
const sc = JSON.parse(
(scRpc.result as { content: { text: string }[] }).content[0].text,
) as { id: string };
const { status, rpc } = await mcpCall("create_scenario_step", {
scenarioId: sc.id,
order: 0,
type: "exec",
execCode: "return 1;",
timeoutSeconds: 45,
});
expect(status).toBe(200);
const result = rpc.result as { content: { text: string }[] };
const step = JSON.parse(result.content[0].text) as {
timeoutSeconds: number | null;
};
expect(step.timeoutSeconds).toBe(45);
});
});
describe("update_scenario_step timeoutSeconds", () => {
it("updates and clears step timeoutSeconds", async () => {
// create scenario + step
const scRpc = (
await mcpCall("create_scenario", { name: "mcp-step-upd-parent" })
).rpc;
const sc = JSON.parse(
(scRpc.result as { content: { text: string }[] }).content[0].text,
) as { id: string };
const stepRpc = (
await mcpCall("create_scenario_step", {
scenarioId: sc.id,
order: 0,
type: "exec",
})
).rpc;
const step = JSON.parse(
(stepRpc.result as { content: { text: string }[] }).content[0].text,
) as { id: string };
// set
const setRpc = (
await mcpCall("update_scenario_step", {
scenarioId: sc.id,
stepId: step.id,
timeoutSeconds: 90,
})
).rpc;
const updated = JSON.parse(
(setRpc.result as { content: { text: string }[] }).content[0].text,
) as { timeoutSeconds: number | null };
expect(updated.timeoutSeconds).toBe(90);
// clear
const clearRpc = (
await mcpCall("update_scenario_step", {
scenarioId: sc.id,
stepId: step.id,
timeoutSeconds: null,
})
).rpc;
const cleared = JSON.parse(
(clearRpc.result as { content: { text: string }[] }).content[0].text,
) as { timeoutSeconds: number | null };
expect(cleared.timeoutSeconds).toBeNull();
});
});
}); });
+82
View File
@@ -81,6 +81,29 @@ describe("ScenarioController", () => {
.send({}) .send({})
.expect(400); .expect(400);
}); });
it("saves timeoutSeconds when provided", async () => {
const res = await request(app.getHttpServer())
.post("/scenarios")
.send({ name: "timeout-sc", timeoutSeconds: 300 })
.expect(201);
expect(res.body.timeoutSeconds).toBe(300);
});
it("stores null timeoutSeconds when not provided", async () => {
const res = await request(app.getHttpServer())
.post("/scenarios")
.send({ name: "no-timeout-sc" })
.expect(201);
expect(res.body.timeoutSeconds).toBeNull();
});
it("returns 400 for timeoutSeconds below 1", async () => {
await request(app.getHttpServer())
.post("/scenarios")
.send({ name: "bad-timeout", timeoutSeconds: 0 })
.expect(400);
});
}); });
// ── GET /scenarios ───────────────────────────────────────────────────────── // ── GET /scenarios ─────────────────────────────────────────────────────────
@@ -181,6 +204,27 @@ describe("ScenarioController", () => {
expect(res.body.name).toBe("patched"); expect(res.body.name).toBe("patched");
}); });
it("updates timeoutSeconds", async () => {
const sc = await createScenario("timeout-patch");
const res = await request(app.getHttpServer())
.patch(`/scenarios/${sc.id}`)
.send({ timeoutSeconds: 120 })
.expect(200);
expect(res.body.timeoutSeconds).toBe(120);
});
it("clears timeoutSeconds to null", async () => {
const created = await request(app.getHttpServer())
.post("/scenarios")
.send({ name: "clear-timeout", timeoutSeconds: 120 })
.expect(201);
const res = await request(app.getHttpServer())
.patch(`/scenarios/${created.body.id}`)
.send({ timeoutSeconds: null })
.expect(200);
expect(res.body.timeoutSeconds).toBeNull();
});
it("returns 404 for unknown id", async () => { it("returns 404 for unknown id", async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.patch("/scenarios/00000000-0000-0000-0000-000000000001") .patch("/scenarios/00000000-0000-0000-0000-000000000001")
@@ -260,6 +304,24 @@ describe("ScenarioController", () => {
.expect(201); .expect(201);
}); });
it("saves timeoutSeconds on step when provided", async () => {
const sc = await createScenario();
const res = await request(app.getHttpServer())
.post(`/scenarios/${sc.id}/steps`)
.send({ execCode: "return 1;", timeoutSeconds: 45 })
.expect(201);
expect(res.body.timeoutSeconds).toBe(45);
});
it("stores null step timeoutSeconds when not provided", async () => {
const sc = await createScenario();
const res = await request(app.getHttpServer())
.post(`/scenarios/${sc.id}/steps`)
.send({ execCode: "return 1;" })
.expect(201);
expect(res.body.timeoutSeconds).toBeNull();
});
it("returns 404 for unknown scenario", async () => { it("returns 404 for unknown scenario", async () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.post("/scenarios/00000000-0000-0000-0000-000000000001/steps") .post("/scenarios/00000000-0000-0000-0000-000000000001/steps")
@@ -320,6 +382,26 @@ describe("ScenarioController", () => {
expect(res.body.execCode).toBe("return 99;"); expect(res.body.execCode).toBe("return 99;");
}); });
it("updates step timeoutSeconds", async () => {
const sc = await createScenario();
const step = await createStep(sc.id);
const res = await request(app.getHttpServer())
.patch(`/scenarios/${sc.id}/steps/${step.id}`)
.send({ timeoutSeconds: 90 })
.expect(200);
expect(res.body.timeoutSeconds).toBe(90);
});
it("clears step timeoutSeconds to null", async () => {
const sc = await createScenario();
const step = await createStep(sc.id, { timeoutSeconds: 90 });
const res = await request(app.getHttpServer())
.patch(`/scenarios/${sc.id}/steps/${step.id}`)
.send({ timeoutSeconds: null })
.expect(200);
expect(res.body.timeoutSeconds).toBeNull();
});
it("returns 404 for unknown step", async () => { it("returns 404 for unknown step", async () => {
const sc = await createScenario(); const sc = await createScenario();
await request(app.getHttpServer()) await request(app.getHttpServer())