diff --git a/.gitignore b/.gitignore index a5f1fe5..d6e86c8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,11 @@ client/dist *storybook.log storybook-static tsconfig.tsbuildinfo + +# organization +TODO.md + +# IDE/editor +.vscode/ +.idea/ +.github/ \ No newline at end of file diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 2188fdd..7cfcfd7 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -193,7 +193,7 @@ export const scenarios = { body: JSON.stringify({ name, description, environmentId }), }); }, - update(id: string, patch: Partial>): Promise { + update(id: string, patch: Partial>): Promise { return request(`/scenarios/${id}`, { method: 'PATCH', body: JSON.stringify(patch), @@ -254,7 +254,7 @@ export const runs = { orderDir: 'ASC' | 'DESC' = 'DESC', ): Promise< 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 }); @@ -299,12 +299,14 @@ export const scenarioCredentials = { export interface CreateStepPayload { title?: string; execCode?: string; + timeoutSeconds?: number | null; } export interface UpdateStepPayload { title?: string; order?: number; execCode?: string; + timeoutSeconds?: number | null; } export const steps = { diff --git a/client/src/api/types.ts b/client/src/api/types.ts index 14db7ca..c7b9bef 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -67,6 +67,7 @@ export interface ScenarioStep { order: number; title: string | null; execCode: string | null; + timeoutSeconds: number | null; createdAt: string; updatedAt: string; } @@ -85,6 +86,7 @@ export interface Scenario { name: string; description?: string; environmentId?: string | null; + timeoutSeconds?: number | null; environment?: Pick; steps?: ScenarioStep[]; scenarioCredentials?: ScenarioCredential[]; diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index d52e216..216a06a 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -185,6 +185,9 @@ "export_modal_title": "Export Scenario", "export_include_environment": "Include Environment", "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_id": "ID" }, @@ -222,7 +225,10 @@ "form_session_placeholder": "e.g. my-session", "form_session_required": "Session name is required", "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": { "title": "Runs", diff --git a/client/src/pages/run/AllRunsPage.tsx b/client/src/pages/run/AllRunsPage.tsx index f153cd5..8ead479 100644 --- a/client/src/pages/run/AllRunsPage.tsx +++ b/client/src/pages/run/AllRunsPage.tsx @@ -33,7 +33,7 @@ const STATUS_LABEL: Record = { }; type AllRunRow = ScenarioRun & { - scenario: { id: number; name: string }; + scenario: { id: string; name: string }; }; export function AllRunsPage() { diff --git a/client/src/pages/scenario/CreateStepPage.tsx b/client/src/pages/scenario/CreateStepPage.tsx index e0d41c3..65a9621 100644 --- a/client/src/pages/scenario/CreateStepPage.tsx +++ b/client/src/pages/scenario/CreateStepPage.tsx @@ -16,6 +16,7 @@ export function CreateStepPage() { const [scenario, setScenario] = useState(null); const [title, setTitle] = useState(''); const [execCode, setExecCode] = useState(''); + const [timeoutSeconds, setTimeoutSeconds] = useState(''); const [saving, setSaving] = useState(false); useEffect(() => { @@ -33,6 +34,7 @@ export function CreateStepPage() { await steps.create(id!, { title: title.trim() || undefined, execCode: execCode.trim() || undefined, + timeoutSeconds: timeoutSeconds.trim() ? Number(timeoutSeconds) : undefined, }); toast.success(t('steps.created')); navigate(`/scenarios/${id}`); @@ -76,6 +78,15 @@ export function CreateStepPage() { + setTimeoutSeconds(e.target.value)} + hint={t('steps.form_timeout_hint')} + />
diff --git a/client/src/pages/scenario/EditScenarioPage.tsx b/client/src/pages/scenario/EditScenarioPage.tsx index 1d8b474..695e9e9 100644 --- a/client/src/pages/scenario/EditScenarioPage.tsx +++ b/client/src/pages/scenario/EditScenarioPage.tsx @@ -17,6 +17,7 @@ export function EditScenarioPage() { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [environmentId, setEnvironmentId] = useState(''); + const [timeoutSeconds, setTimeoutSeconds] = useState(''); const [envs, setEnvs] = useState([]); const [nameError, setNameError] = useState(''); const [loading, setLoading] = useState(true); @@ -31,6 +32,7 @@ export function EditScenarioPage() { setName(data.name); setDescription(data.description || ''); setEnvironmentId(data.environmentId ?? ''); + setTimeoutSeconds(data.timeoutSeconds != null ? String(data.timeoutSeconds) : ''); }) .finally(() => setLoading(false)); environments.list(1, 200).then((r) => setEnvs(r?.data ?? [])).catch(() => {}); @@ -48,6 +50,7 @@ export function EditScenarioPage() { name: name.trim(), description: description.trim() || undefined, environmentId: environmentId || null, + timeoutSeconds: timeoutSeconds.trim() ? Number(timeoutSeconds) : null, }); toast.success(t('scenarios.updated')); navigate(`/scenarios/${id}`); @@ -114,6 +117,15 @@ export function EditScenarioPage() { ...envs.map((env) => ({ value: env.id, label: env.name })), ]} /> + setTimeoutSeconds(e.target.value)} + hint={t('scenarios.form_timeout_hint')} + />
diff --git a/client/src/pages/scenario/EditStepPage.tsx b/client/src/pages/scenario/EditStepPage.tsx index 2e9a453..9f310ae 100644 --- a/client/src/pages/scenario/EditStepPage.tsx +++ b/client/src/pages/scenario/EditStepPage.tsx @@ -17,6 +17,7 @@ export function EditStepPage() { const [step, setStep] = useState(null); const [title, setTitle] = useState(''); const [execCode, setExecCode] = useState(''); + const [timeoutSeconds, setTimeoutSeconds] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -28,6 +29,7 @@ export function EditStepPage() { setStep(st); setTitle(st.title ?? ''); setExecCode(st.execCode ?? ''); + setTimeoutSeconds(st.timeoutSeconds != null ? String(st.timeoutSeconds) : ''); }) .finally(() => setLoading(false)); }, [id, stepId]); @@ -39,6 +41,7 @@ export function EditStepPage() { await steps.update(id!, stepId!, { title: title.trim() || undefined, execCode: execCode.trim() || undefined, + timeoutSeconds: timeoutSeconds.trim() ? Number(timeoutSeconds) : null, }); toast.success(t('steps.updated')); navigate(`/scenarios/${id}`); @@ -64,7 +67,7 @@ export function EditStepPage() { label: scenario?.name ?? `#${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 }) }, ]} />
@@ -85,6 +88,15 @@ export function EditStepPage() { + setTimeoutSeconds(e.target.value)} + hint={t('steps.form_timeout_hint')} + />
diff --git a/client/src/pages/scenario/ScenarioDetailPage.tsx b/client/src/pages/scenario/ScenarioDetailPage.tsx index bdc4721..7cfebf8 100644 --- a/client/src/pages/scenario/ScenarioDetailPage.tsx +++ b/client/src/pages/scenario/ScenarioDetailPage.tsx @@ -288,7 +288,7 @@ export function ScenarioDetailPage() { ), }, - { 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', header: t('scenarios.step_title'), diff --git a/server/src/mcp/mcp.service.ts b/server/src/mcp/mcp.service.ts index d6ad2da..d69d22d 100644 --- a/server/src/mcp/mcp.service.ts +++ b/server/src/mcp/mcp.service.ts @@ -476,14 +476,21 @@ export class McpService { .uuid() .optional() .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 { const scenario = await this.scenarioService.create({ name, description, environmentId, + timeoutSeconds, }); return { content: [ @@ -515,14 +522,22 @@ export class McpService { .nullable() .optional() .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 { const scenario = await this.scenarioService.update(id, { name, description, environmentId, + timeoutSeconds, }); return { content: [ @@ -580,6 +595,12 @@ export class McpService { .string() .optional() .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 }) => { @@ -640,6 +661,13 @@ export class McpService { .describe("New step type"), title: z.string().optional().describe("New step title"), 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 }) => { diff --git a/server/src/scenario/dto/create-scenario-step.dto.ts b/server/src/scenario/dto/create-scenario-step.dto.ts index 0548597..ce2f73a 100644 --- a/server/src/scenario/dto/create-scenario-step.dto.ts +++ b/server/src/scenario/dto/create-scenario-step.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsNotEmpty, IsOptional, IsString } from "class-validator"; +import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from "class-validator"; export class CreateScenarioStepDto { @ApiPropertyOptional({ example: "Check login page title" }) @@ -12,4 +12,10 @@ export class CreateScenarioStepDto { @IsString() @IsNotEmpty() execCode?: string; + + @ApiPropertyOptional({ description: "Step timeout in seconds (default: 60)" }) + @IsOptional() + @IsInt() + @Min(1) + timeoutSeconds?: number; } diff --git a/server/src/scenario/dto/create-scenario.dto.ts b/server/src/scenario/dto/create-scenario.dto.ts index 62d6422..5b2e56d 100644 --- a/server/src/scenario/dto/create-scenario.dto.ts +++ b/server/src/scenario/dto/create-scenario.dto.ts @@ -1,5 +1,5 @@ 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 { @ApiProperty({ example: "Login and verify cabinet" }) @@ -16,4 +16,10 @@ export class CreateScenarioDto { @IsOptional() @IsUUID() environmentId?: string; + + @ApiPropertyOptional({ description: "Scenario timeout in seconds (default: 600)" }) + @IsOptional() + @IsInt() + @Min(1) + timeoutSeconds?: number; } diff --git a/server/src/scenario/dto/update-scenario-step.dto.ts b/server/src/scenario/dto/update-scenario-step.dto.ts index e0c6b7e..e46b055 100644 --- a/server/src/scenario/dto/update-scenario-step.dto.ts +++ b/server/src/scenario/dto/update-scenario-step.dto.ts @@ -18,4 +18,10 @@ export class UpdateScenarioStepDto { @IsString() @IsNotEmpty() execCode?: string; + + @ApiPropertyOptional({ description: "Step timeout in seconds (null to use default of 60)" }) + @IsOptional() + @IsInt() + @Min(1) + timeoutSeconds?: number | null; } diff --git a/server/src/scenario/dto/update-scenario.dto.ts b/server/src/scenario/dto/update-scenario.dto.ts index f013562..df66ed5 100644 --- a/server/src/scenario/dto/update-scenario.dto.ts +++ b/server/src/scenario/dto/update-scenario.dto.ts @@ -1,5 +1,5 @@ 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 { @ApiPropertyOptional({ example: "Updated scenario name" }) @@ -17,4 +17,10 @@ export class UpdateScenarioDto { @IsOptional() @IsUUID() environmentId?: string | null; + + @ApiPropertyOptional({ description: "Scenario timeout in seconds (null to use default of 600)" }) + @IsOptional() + @IsInt() + @Min(1) + timeoutSeconds?: number | null; } diff --git a/server/src/scenario/scenario-scheduler.service.ts b/server/src/scenario/scenario-scheduler.service.ts index 7b72881..47d7d51 100644 --- a/server/src/scenario/scenario-scheduler.service.ts +++ b/server/src/scenario/scenario-scheduler.service.ts @@ -40,6 +40,11 @@ export class ScenarioSchedulerService { private readonly runSnippets = new Map>(); // Cache environment values per run (built once when a run starts) private readonly runEnvironments = new Map(); + // Cache scenario-level timeout (seconds) per run + private readonly runScenarioTimeouts = new Map(); + + private static readonly DEFAULT_SCENARIO_TIMEOUT_SEC = 600; + private static readonly DEFAULT_STEP_TIMEOUT_SEC = 60; constructor( @InjectRepository(ScenarioRunEntity) @@ -120,6 +125,11 @@ export class ScenarioSchedulerService { .then((env) => env?.data ?? {}) .catch(() => ({}) as 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(); void traceStorage.run({ traceId }, () => this.processRunToCompletion(run.id), @@ -128,6 +138,10 @@ export class ScenarioSchedulerService { } private async processRunToCompletion(runId: string): Promise { + const scenarioTimeoutSec = + this.runScenarioTimeouts.get(runId) ?? + ScenarioSchedulerService.DEFAULT_SCENARIO_TIMEOUT_SEC; + const scenarioDeadline = Date.now() + scenarioTimeoutSec * 1000; try { let stepRun = await this.runStepRepo.findOne({ where: { runId, status: "pending" }, @@ -135,6 +149,28 @@ export class ScenarioSchedulerService { order: { order: "ASC" }, }); 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); stepRun = await this.runStepRepo.findOne({ where: { runId, status: "pending" }, @@ -158,6 +194,7 @@ export class ScenarioSchedulerService { this.runCredentials.delete(runId); this.runSnippets.delete(runId); this.runEnvironments.delete(runId); + this.runScenarioTimeouts.delete(runId); await this.maybePreserveSession(runId); } } @@ -245,7 +282,24 @@ export class ScenarioSchedulerService { .environment(env) .snippets(snips) .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((_, 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); } catch (err) { diff --git a/server/src/scenario/scenario-step.entity.ts b/server/src/scenario/scenario-step.entity.ts index 9b7c0a9..331a44c 100644 --- a/server/src/scenario/scenario-step.entity.ts +++ b/server/src/scenario/scenario-step.entity.ts @@ -32,6 +32,9 @@ export class ScenarioStepEntity { @Column({ type: "text", nullable: true }) execCode: string | null; + @Column({ type: "int", nullable: true }) + timeoutSeconds: number | null; + @CreateDateColumn() createdAt: Date; diff --git a/server/src/scenario/scenario.entity.ts b/server/src/scenario/scenario.entity.ts index 98cd3de..bf15d5f 100644 --- a/server/src/scenario/scenario.entity.ts +++ b/server/src/scenario/scenario.entity.ts @@ -26,6 +26,9 @@ export class ScenarioEntity { @Column({ nullable: true, type: "text" }) environmentId: string | null; + @Column({ type: "int", nullable: true }) + timeoutSeconds: number | null; + @ManyToOne(() => EnvironmentEntity, { nullable: true, onDelete: "SET NULL", eager: false }) @JoinColumn({ name: "environmentId" }) environment: EnvironmentEntity | null; diff --git a/server/test/mcp.controller.spec.ts b/server/test/mcp.controller.spec.ts index 9751c59..5f947f1 100644 --- a/server/test/mcp.controller.spec.ts +++ b/server/test/mcp.controller.spec.ts @@ -141,4 +141,147 @@ describe("McpController", () => { 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(); + }); + }); }); diff --git a/server/test/scenario.controller.spec.ts b/server/test/scenario.controller.spec.ts index b15f545..d02bc7c 100644 --- a/server/test/scenario.controller.spec.ts +++ b/server/test/scenario.controller.spec.ts @@ -81,6 +81,29 @@ describe("ScenarioController", () => { .send({}) .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 ───────────────────────────────────────────────────────── @@ -181,6 +204,27 @@ describe("ScenarioController", () => { 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 () => { await request(app.getHttpServer()) .patch("/scenarios/00000000-0000-0000-0000-000000000001") @@ -260,6 +304,24 @@ describe("ScenarioController", () => { .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 () => { await request(app.getHttpServer()) .post("/scenarios/00000000-0000-0000-0000-000000000001/steps") @@ -320,6 +382,26 @@ describe("ScenarioController", () => { 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 () => { const sc = await createScenario(); await request(app.getHttpServer())