diff --git a/README.md b/README.md index 998cdc4..2fc4b08 100644 --- a/README.md +++ b/README.md @@ -107,3 +107,10 @@ Gin 内置营销单页,与 /docs 同为 SSR 嵌入交付(`internal/site`) make test # core 21 个单测:导入导出全格式往返、树操作、cURL 解析 cd apps/server && go test ./... ``` + +## P0 自动化演示(本地) + +1. `./run.sh web` → http://127.0.0.1:5173 +2. 进入自动化测试 → **装载回归示例** → **运行**(依赖本机 Gin `:8787/v1/echo`) +3. AI:**保存并运行** 与手动/定时共用同一执行器与报告 +4. 定时:支持 5 段 Cron 或间隔;应用保持打开;见 `docs/HANDTEST-P0.md` diff --git a/apps/server/internal/echo/echo.go b/apps/server/internal/echo/echo.go new file mode 100644 index 0000000..5cfd67f --- /dev/null +++ b/apps/server/internal/echo/echo.go @@ -0,0 +1,154 @@ +// Package echo 提供本地 HTTP 回显,供自动化回归套件使用(不依赖外网 httpbin)。 +package echo + +import ( + "encoding/base64" + "io" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// Mount registers /v1/echo/* routes. +func Mount(r *gin.Engine) { + e := r.Group("/v1/echo") + e.GET("/get", getEcho) + e.POST("/json", jsonEcho) + e.POST("/form", formEcho) + e.POST("/multipart", multipartEcho) + e.GET("/headers", headersEcho) + e.GET("/cookies", cookiesEcho) + e.GET("/bearer", bearerEcho) + e.GET("/basic", basicEcho) +} + +func baseMeta(c *gin.Context) gin.H { + hdrs := gin.H{} + for k, v := range c.Request.Header { + if len(v) == 1 { + hdrs[k] = v[0] + } else { + hdrs[k] = v + } + } + q := gin.H{} + for k, v := range c.Request.URL.Query() { + if len(v) == 1 { + q[k] = v[0] + } else { + q[k] = v + } + } + return gin.H{ + "ok": true, + "service": "opsxapi-echo", + "method": c.Request.Method, + "path": c.Request.URL.Path, + "query": q, + "headers": hdrs, + } +} + +func getEcho(c *gin.Context) { + c.JSON(http.StatusOK, baseMeta(c)) +} + +func jsonEcho(c *gin.Context) { + body, _ := io.ReadAll(c.Request.Body) + out := baseMeta(c) + out["bodyRaw"] = string(body) + c.JSON(http.StatusOK, out) +} + +func formEcho(c *gin.Context) { + _ = c.Request.ParseForm() + form := gin.H{} + for k, v := range c.Request.PostForm { + if len(v) == 1 { + form[k] = v[0] + } else { + form[k] = v + } + } + out := baseMeta(c) + out["form"] = form + c.JSON(http.StatusOK, out) +} + +func multipartEcho(c *gin.Context) { + _ = c.Request.ParseMultipartForm(8 << 20) + form := gin.H{} + if c.Request.MultipartForm != nil { + for k, v := range c.Request.MultipartForm.Value { + if len(v) == 1 { + form[k] = v[0] + } else { + form[k] = v + } + } + } + out := baseMeta(c) + out["form"] = form + c.JSON(http.StatusOK, out) +} + +func headersEcho(c *gin.Context) { + out := baseMeta(c) + out["xOpsxProbe"] = c.GetHeader("X-Opsx-Probe") + c.JSON(http.StatusOK, out) +} + +func cookiesEcho(c *gin.Context) { + out := baseMeta(c) + cookies := gin.H{} + for _, ck := range c.Request.Cookies() { + cookies[ck.Name] = ck.Value + } + // 浏览器禁止脚本设置 Cookie 头;回归用例改走 X-Opsx-Cookie + if raw := c.GetHeader("X-Opsx-Cookie"); raw != "" { + for _, part := range strings.Split(raw, ";") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + kv := strings.SplitN(part, "=", 2) + if len(kv) == 2 { + cookies[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + } + } + } + out["cookies"] = cookies + c.JSON(http.StatusOK, out) +} + +func bearerEcho(c *gin.Context) { + auth := c.GetHeader("Authorization") + if !strings.HasPrefix(auth, "Bearer ") || strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) == "" { + c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "missing bearer"}) + return + } + out := baseMeta(c) + out["authenticated"] = true + out["token"] = strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")) + c.JSON(http.StatusOK, out) +} + +func basicEcho(c *gin.Context) { + auth := c.GetHeader("Authorization") + if !strings.HasPrefix(auth, "Basic ") { + c.Header("WWW-Authenticate", `Basic realm="opsxapi"`) + c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "missing basic"}) + return + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(strings.TrimPrefix(auth, "Basic "))) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"ok": false, "error": "bad basic"}) + return + } + parts := strings.SplitN(string(raw), ":", 2) + out := baseMeta(c) + out["authenticated"] = true + out["user"] = parts[0] + c.JSON(http.StatusOK, out) +} diff --git a/docs/HANDTEST-P0.md b/docs/HANDTEST-P0.md new file mode 100644 index 0000000..16dc36e --- /dev/null +++ b/docs/HANDTEST-P0.md @@ -0,0 +1,16 @@ +# OpsxAPI P0 手测脚本(10 步) + +前置:`export PATH="$HOME/.nvm/versions/node/v24.19.0/bin:$PATH"`,在仓库根目录 `./run.sh web`,打开 http://127.0.0.1:5173 。 + +1. 打开工作台,确认左上可切到「自动化测试」(或顶栏进入测试视图)。 +2. 点 **装载回归示例**;环境应切到「本地」;出现用例「HTTP 八组合回归(本地 echo)」。 +3. 点该用例右上 **运行**;8 步应全绿(需 :8787 已起)。失败则看报告里哪一步断言挂了。 +4. 在报告区分别点 **导出 MD**、**导出 JSON**,确认文件可打开且 JSON 含 `schema: opsxapi.test-run.v1`。 +5. 设置 → AI 助手:添加供应商(可用 Ollama),点测试连接(无 Key 的本地模型可过)。**未配置时可跳过 6。** +6. AI 生成:选「示例集合」,生成后点 **保存并运行**;报告触发方式应显示「AI」。 +7. 对任一用例点定时图标:填 Cron `*/5 * * * *`(或短间隔),启用;保持页面打开。 +8. 等到命中分钟后,报告出现 trigger=定时;定时对话框可见「日志 …」一行;控制台有 `[OpsxAPI 定时]`。 +9. 手工再跑一遍八组合,确认与定时/AI 共用同一报告列表。 +10. 刷新页面:用例与最近报告仍在(localStorage);`./run.sh status` 仍显示 server/web UP。 + +回滚演示数据:设置里如有「重置工作区」则用之;或清站点 localStorage。 diff --git a/packages/core/src/cron.ts b/packages/core/src/cron.ts new file mode 100644 index 0000000..464a62f --- /dev/null +++ b/packages/core/src/cron.ts @@ -0,0 +1,83 @@ +/** + * 极简 5 段 Cron 匹配(分 时 日 月 周),本地 Date。 + * 支持:*、数字、逗号列表、A-B 范围、* /n 步长。不支持 L W # 与英文月名。 + * 自研实现,非抄竞品调度器。 + */ + +function parseField(field: string, min: number, max: number): Set { + const out = new Set(); + for (const part of field.split(",")) { + const stepMatch = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part.trim()); + if (!stepMatch) continue; + const [, base, stepStr] = stepMatch; + const step = stepStr ? Number(stepStr) : 1; + let start = min; + let end = max; + if (base !== "*") { + if (base.includes("-")) { + const [a, b] = base.split("-").map(Number); + start = a; + end = b; + } else { + start = end = Number(base); + } + } + for (let i = start; i <= end; i += step) { + if (i >= min && i <= max) out.add(i); + } + } + return out; +} + +/** 校验表达式粗格式 */ +export function isValidCron(expr: string): boolean { + const parts = expr.trim().split(/\s+/); + if (parts.length !== 5) return false; + return parts.every((p) => /^[\d*,\-\/]+$/.test(p)); +} + +/** date 是否命中 cron(精确到分钟) */ +export function cronMatches(expr: string, date: Date = new Date()): boolean { + const parts = expr.trim().split(/\s+/); + if (parts.length !== 5) return false; + const [minF, hourF, domF, monF, dowF] = parts; + const minute = date.getMinutes(); + const hour = date.getHours(); + const dom = date.getDate(); + const mon = date.getMonth() + 1; + // JS: 0=Sun..6=Sat;Cron 常 0=Sun;也接受 7=Sun + let dow = date.getDay(); + const mins = parseField(minF, 0, 59); + const hours = parseField(hourF, 0, 23); + const doms = parseField(domF, 1, 31); + const mons = parseField(monF, 1, 12); + const dows = parseField(dowF, 0, 7); + if (dows.has(7)) dows.add(0); + return mins.has(minute) && hours.has(hour) && doms.has(dom) && mons.has(mon) && dows.has(dow); +} + +/** + * 距 lastRunAt 是否应在「当前分钟」再跑: + * - 有 cron:表达式命中当前分钟,且上次不在同一分钟 + * - 无 cron:沿用间隔分钟 + */ +export function scheduleIsDue( + schedule: { intervalMinutes: number; cron?: string | null; lastRunAt: number | null }, + now: Date = new Date(), +): boolean { + const cron = schedule.cron?.trim(); + if (cron) { + if (!isValidCron(cron) || !cronMatches(cron, now)) return false; + if (schedule.lastRunAt == null) return true; + const last = new Date(schedule.lastRunAt); + return !( + last.getFullYear() === now.getFullYear() && + last.getMonth() === now.getMonth() && + last.getDate() === now.getDate() && + last.getHours() === now.getHours() && + last.getMinutes() === now.getMinutes() + ); + } + const intervalMs = Math.max(1, schedule.intervalMinutes) * 60_000; + return schedule.lastRunAt === null || now.getTime() - schedule.lastRunAt >= intervalMs; +} diff --git a/packages/core/src/factory.regression.test.ts b/packages/core/src/factory.regression.test.ts new file mode 100644 index 0000000..14454ee --- /dev/null +++ b/packages/core/src/factory.regression.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { createSeedWorkspace } from "./factory"; + +describe("createSeedWorkspace HTTP8 regression", () => { + it("seeds eight echo steps bound to real node ids", () => { + const ws = createSeedWorkspace(); + const tc = ws.testCases.find((c) => c.id === "tc-opsx-http8-regression"); + expect(tc).toBeTruthy(); + expect(tc!.steps).toHaveLength(8); + const ids = new Set(); + const walk = (nodes: { type: string; id: string; children?: unknown[] }[]) => { + for (const n of nodes) { + ids.add(n.id); + if (n.type === "folder" && Array.isArray(n.children)) walk(n.children as typeof nodes); + } + }; + for (const c of ws.collections) walk(c.children as { type: string; id: string; children?: unknown[] }[]); + for (const s of tc!.steps) { + expect(ids.has(s.nodeId)).toBe(true); + } + expect(ws.activeEnvironmentId).toBe(ws.environments.find((e) => e.name === "本地")?.id); + }); +}); diff --git a/packages/core/src/factory.ts b/packages/core/src/factory.ts index df5e0ed..33f60ce 100644 --- a/packages/core/src/factory.ts +++ b/packages/core/src/factory.ts @@ -19,6 +19,8 @@ import type { SseRequestData, WebsocketRequestData, WorkspaceData, + TestCase, + TestStep, } from "./model"; import { newId } from "./tree"; @@ -183,7 +185,80 @@ export function createSeedWorkspace(): WorkspaceData { description, }); + const localBase = "http://127.0.0.1:8787/v1/echo"; + + // 固定引用,供种子场景用例 steps.nodeId 绑定 + const echoGet = http( + "回归 · GET Query", + "GET", + `${localBase}/get?ping=opsx`, + { query: [kv("ping", "opsx")] }, + "本地回显:Query 参数。需 Gin :8787。", + ); + const echoJson = http( + "回归 · POST JSON", + "POST", + `${localBase}/json`, + { + bodyMode: "json", + bodyRaw: '{\n "name": "opsxapi",\n "lang": "zh-CN"\n}', + headers: [kv("Content-Type", "application/json")], + }, + ); + const echoForm = http( + "回归 · POST urlencoded", + "POST", + `${localBase}/form`, + { + bodyMode: "urlencoded", + bodyForm: [kv("username", "logan"), kv("role", "ops")], + }, + ); + const echoMultipart = http( + "回归 · POST form-data", + "POST", + `${localBase}/multipart`, + { + bodyMode: "form-data", + bodyForm: [kv("fileMeta", "demo"), kv("env", "local")], + }, + ); + const echoHeaders = http( + "回归 · 自定义 Header", + "GET", + `${localBase}/headers`, + { headers: [kv("X-Opsx-Probe", "ok")] }, + ); + const echoCookies = http( + "回归 · Cookie", + "GET", + `${localBase}/cookies`, + { headers: [kv("X-Opsx-Cookie", "opsx_session=demo-cookie")] }, + ); + const echoBearer = http( + "回归 · Bearer", + "GET", + `${localBase}/bearer`, + { auth: { mode: "bearer", bearer: "{{token}}" } }, + ); + const echoBasic = http( + "回归 · Basic Auth", + "GET", + `${localBase}/basic`, + { auth: { mode: "basic", basicUser: "opsx", basicPass: "demo" } }, + ); + const demo = createCollection("示例集合", [ + createFolder("HTTP · 本地回归(P0)", [ + echoGet, + echoJson, + echoForm, + echoMultipart, + echoHeaders, + echoCookies, + echoBearer, + echoBasic, + ]), createFolder("HTTP · httpbin", [ http("获取请求回显", "GET", "https://httpbin.org/get", undefined, "经典回显接口,返回请求头 / IP / 参数。"), http( @@ -253,19 +328,59 @@ export function createSeedWorkspace(): WorkspaceData { ]), ]); + const step = (nodeId: string, assertions: TestStep["assertions"], extracts: TestStep["extracts"] = []): TestStep => ({ + id: newId(), + enabled: true, + nodeId, + assertions, + extracts, + }); + const as = ( + target: "status" | "timeMs" | "header" | "body", + op: TestStep["assertions"][number]["op"], + expected: string, + locator = "", + ) => ({ + id: newId(), + enabled: true, + target, + locator, + op, + expected, + }); + + const regressionCase: TestCase = { + id: "tc-opsx-http8-regression", + name: "HTTP 八组合回归(本地 echo)", + description: "对 Gin /v1/echo 的 8 种请求组合:Query / JSON / urlencoded / form-data / Header / Cookie / Bearer / Basic。请先 ./run.sh web 并切换环境到「本地」。", + continueOnFailure: true, + steps: [ + step(echoGet.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "true", "$.ok"), as("body", "jsonpath_eq", "opsx", "$.query.ping")]), + step(echoJson.id, [as("status", "eq", "200"), as("body", "contains", "opsxapi")]), + step(echoForm.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "logan", "$.form.username")]), + step(echoMultipart.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "demo", "$.form.fileMeta")]), + step(echoHeaders.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "ok", "$.xOpsxProbe")]), + step(echoCookies.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "demo-cookie", "$.cookies.opsx_session")]), + step(echoBearer.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "true", "$.authenticated")]), + step(echoBasic.id, [as("status", "eq", "200"), as("body", "jsonpath_eq", "opsx", "$.user")]), + ], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + const envs = [ createEnvironment("正式", [{ key: "token", value: "demo-token" }], "https://httpbin.org"), - createEnvironment("本地", [{ key: "token", value: "dev-token" }], "http://127.0.0.1:8787"), + createEnvironment("本地", [{ key: "token", value: "demo-token" }], "http://127.0.0.1:8787"), ]; return { version: 2, collections: [demo], environments: envs, - activeEnvironmentId: envs[0]?.id ?? null, + activeEnvironmentId: envs[1]?.id ?? envs[0]?.id ?? null, history: [], settings: { theme: "dark", timeoutMs: 30000, mock: normalizeMockSettings(null), ai: { providers: [], activeProviderId: null } }, - testCases: [], + testCases: [regressionCase], testRuns: [], schedules: [], }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bdd99a8..346c479 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -125,6 +125,7 @@ export { suggestAssertions, extractVariables, exportRunMarkdown, + exportRunJson, newAssertion, newExtract, newStep, @@ -163,3 +164,6 @@ export type { ExportFormat, OpenApiExportOptions } from "./exporters"; export { exportCurl, shellQuote, buildUrlWithQuery, authHeader } from "./exporters/curl"; export { generateCode } from "./exporters/codegen"; export type { CodeTarget } from "./exporters/codegen"; + +// 定时 Cron +export { isValidCron, cronMatches, scheduleIsDue } from "./cron"; diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 64d9e34..9f5a68c 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -390,9 +390,16 @@ export type TestRun = { export type TestSchedule = { caseId: string; enabled: boolean; - /** 间隔分钟 */ + /** 间隔分钟(未设置 cron 时使用) */ intervalMinutes: number; + /** + * 可选 5 段 Cron(分 时 日 月 周),本地时区。 + * 设置后优先于 intervalMinutes。例:每 5 分钟一次(分 位写 星号斜杠 5) + */ + cron?: string | null; lastRunAt: number | null; + /** 最近一次调度日志(环形,最多保留若干条由调度器截断) */ + runLog?: { at: number; ok: boolean; message: string }[]; }; /* ---------------- AI 助手 ---------------- */ diff --git a/packages/core/src/testing.test.ts b/packages/core/src/testing.test.ts index 6e2ac26..aaf2137 100644 --- a/packages/core/src/testing.test.ts +++ b/packages/core/src/testing.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { describeAssertion, evaluateAssertion, + exportRunJson, exportRunMarkdown, extractVariables, newTestCase, @@ -144,3 +145,48 @@ describe("用例构造", () => { expect(tc.id).toMatch(/^tc-/); }); }); + +import { cronMatches, isValidCron, scheduleIsDue } from "./cron"; + +describe("exportRunJson", () => { + it("emits opsxapi schema", () => { + const json = JSON.parse( + exportRunJson({ + caseName: "demo", + at: 1, + trigger: "manual", + totalMs: 10, + passed: true, + stepResults: [], + }), + ); + expect(json.schema).toBe("opsxapi.test-run.v1"); + expect(json.caseName).toBe("demo"); + }); +}); + +describe("cron", () => { + it("validates 5-field expressions", () => { + expect(isValidCron("*/5 * * * *")).toBe(true); + expect(isValidCron("* * *")).toBe(false); + }); + + it("matches */5 minutes", () => { + const d = new Date(2026, 0, 1, 12, 10, 0); // min=10 + expect(cronMatches("*/5 * * * *", d)).toBe(true); + expect(cronMatches("*/5 * * * *", new Date(2026, 0, 1, 12, 11, 0))).toBe(false); + }); + + it("scheduleIsDue prefers cron over interval", () => { + const d = new Date(2026, 0, 1, 12, 0, 0); + expect( + scheduleIsDue({ intervalMinutes: 999, cron: "0 * * * *", lastRunAt: null }, d), + ).toBe(true); + expect( + scheduleIsDue( + { intervalMinutes: 1, cron: "0 * * * *", lastRunAt: d.getTime() }, + d, + ), + ).toBe(false); + }); +}); diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts index 71183e6..ca37d65 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -255,6 +255,45 @@ export function exportRunMarkdown(run: { /* ---------------- 用例构造 ---------------- */ + +/** JSON 报告(便于 CI / 二次处理) */ +export function exportRunJson(run: { + id?: string; + caseId?: string; + caseName: string; + at: number; + trigger: string; + totalMs: number; + passed: boolean; + stepResults: { + name: string; + method: string; + url: string; + ok: boolean; + httpStatus: number | null; + durationMs: number | null; + error: string | null; + assertionResults: AssertionResult[]; + }[]; +}): string { + return JSON.stringify( + { + schema: "opsxapi.test-run.v1", + id: run.id ?? null, + caseId: run.caseId ?? null, + caseName: run.caseName, + at: run.at, + trigger: run.trigger, + totalMs: run.totalMs, + passed: run.passed, + stats: runStats(run), + stepResults: run.stepResults, + }, + null, + 2, + ); +} + export function newAssertion(): TestAssertion { return { id: `as-${Math.random().toString(36).slice(2, 10)}`, diff --git a/packages/ui/src/components/TestingView.tsx b/packages/ui/src/components/TestingView.tsx index 254bcc0..e96606e 100644 --- a/packages/ui/src/components/TestingView.tsx +++ b/packages/ui/src/components/TestingView.tsx @@ -13,7 +13,10 @@ import type { } from "@opsxapi/core"; import { ASSERT_OP_LABELS, + createSeedWorkspace, + exportRunJson, exportRunMarkdown, + isValidCron, newAssertion, newExtract, newStep, @@ -54,6 +57,38 @@ function useHttpNodeOptions(): { id: string; label: string; method: string }[] { }, [collections]); } + +const REGRESSION_CASE_ID = "tc-opsx-http8-regression"; + +/** 装载 / 刷新本地 Gin echo 八组合回归用例(不整库清空) */ +function installHttp8Regression(): string { + const seed = createSeedWorkspace(); + const state = useWorkspace.getState(); + const seedCase = seed.testCases.find((c) => c.id === REGRESSION_CASE_ID) ?? seed.testCases[0]!; + const localEnv = seed.environments.find((e) => e.name === "本地") ?? seed.environments[0]; + const hasCase = state.testCases.some((c) => c.id === REGRESSION_CASE_ID); + const hasCollection = state.collections.some((c) => c.name === "示例集合"); + const localExisting = state.environments.find((e) => e.name === "本地"); + state.loadWorkspaceData({ + version: 2, + collections: hasCollection + ? state.collections.map((c) => (c.name === "示例集合" ? seed.collections[0]! : c)) + : [...state.collections, ...seed.collections], + environments: localExisting + ? state.environments + : [...state.environments, ...seed.environments.filter((e) => e.name === "本地")], + activeEnvironmentId: localExisting?.id ?? localEnv?.id ?? state.activeEnvironmentId, + history: state.history, + settings: state.settings, + testCases: hasCase + ? state.testCases.map((c) => (c.id === REGRESSION_CASE_ID ? seedCase : c)) + : [...state.testCases, seedCase], + testRuns: state.testRuns, + schedules: state.schedules, + }); + return seedCase.id; +} + export function TestingView() { const testCases = useWorkspace((s) => s.testCases); const testRuns = useWorkspace((s) => s.testRuns); @@ -96,6 +131,13 @@ export function TestingView() { AI 生成 +
{testCases.length === 0 && ( @@ -498,13 +540,28 @@ function ScheduleInline({ caseId }: { caseId: string }) { ); } + const cron = sc.cron ?? ""; + const cronOk = !cron.trim() || isValidCron(cron.trim()); return ( - + + upsertSchedule(caseId, { cron: e.target.value.trim() || null })} + style={!cronOk ? { borderColor: "var(--danger)" } : undefined} + /> { + const v = e.target.value.trim(); + upsertSchedule(tc.id, { cron: v || null }); + }} + style={sc?.cron && !isValidCron(sc.cron) ? { borderColor: "var(--danger)" } : undefined} + />