From ad236e9b1107705b8a0e0c0df6ca16a4297b633b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Tue, 23 Jun 2026 19:32:21 +0800 Subject: [PATCH] test(runtime): move unit tests from cli to runtime package --- packages/runtime/tests/args.test.ts | 95 +++++++++++++++++++++ packages/runtime/tests/pipeline.test.ts | 109 ++++++++++++++++++++++++ packages/runtime/tests/proxy.test.ts | 42 +++++++++ 3 files changed, 246 insertions(+) create mode 100644 packages/runtime/tests/args.test.ts create mode 100644 packages/runtime/tests/pipeline.test.ts create mode 100644 packages/runtime/tests/proxy.test.ts diff --git a/packages/runtime/tests/args.test.ts b/packages/runtime/tests/args.test.ts new file mode 100644 index 0000000..b32d208 --- /dev/null +++ b/packages/runtime/tests/args.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from "vite-plus/test"; +import { ExitCode, GLOBAL_OPTIONS } from "bailian-cli-core"; +import { parseFlags } from "../src/args.ts"; +import { BOOL_FLAG_WATERMARK } from "../src/utils/flag-descriptions.ts"; + +const IMAGE_GENERATE_OPTIONS = [ + { flag: "--prompt ", description: "Image description", required: true }, + { flag: "--model ", description: "Model ID" }, + { flag: "--watermark ", description: BOOL_FLAG_WATERMARK }, + { flag: "--no-wait", description: "Return task ID immediately without waiting" }, +]; + +test("parseFlags rejects unknown long flags", () => { + expect(() => + parseFlags(["--prompt", "cat", "--xxxx", "a"], [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]), + ).toThrowError( + expect.objectContaining({ + name: "BailianError", + exitCode: ExitCode.USAGE, + message: expect.stringContaining('Unknown flag "--xxxx"'), + }), + ); +}); + +test("parseFlags rejects unknown flags with = syntax", () => { + expect(() => + parseFlags( + ["--prompt=cat", "--unknown-flag=yes"], + [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS], + ), + ).toThrow(/Unknown flag "--unknown-flag"/); +}); + +test("parseFlags accepts defined command and global flags", () => { + const flags = parseFlags( + ["--quiet", "--prompt", "cat", "--watermark", "false"], + [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS], + ); + expect(flags.quiet).toBe(true); + expect(flags.prompt).toBe("cat"); + expect(flags.watermark).toBe("false"); +}); + +test("parseFlags rejects value flag when next token is another flag", () => { + const opts = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]; + for (const argv of [ + ["--watermark", "--prompt", "cat"], + ["--watermark", "-h"], + ["--prompt", "cat", "--watermark", "--model", "qwen-image-2.0"], + ]) { + expect(() => parseFlags(argv, opts)).toThrowError( + expect.objectContaining({ + name: "BailianError", + exitCode: ExitCode.USAGE, + message: expect.stringContaining("Flag --watermark requires a value"), + }), + ); + } +}); + +test("parseFlags rejects trailing value flag without value", () => { + expect(() => + parseFlags(["--prompt", "cat", "--watermark"], [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]), + ).toThrowError( + expect.objectContaining({ + message: expect.stringContaining("Flag --watermark requires a value"), + }), + ); +}); + +test("parseFlags allows boolean flags without values adjacent to other flags", () => { + const opts = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]; + const flags = parseFlags( + ["--quiet", "--dry-run", "--no-wait", "--prompt", "cat", "--watermark", "false"], + opts, + ); + expect(flags.quiet).toBe(true); + expect(flags.dryRun).toBe(true); + expect(flags.noWait).toBe(true); + expect(flags.prompt).toBe("cat"); + expect(flags.watermark).toBe("false"); +}); + +test("parseFlags does not treat the next flag as a boolean flag value", () => { + const opts = [...GLOBAL_OPTIONS, ...IMAGE_GENERATE_OPTIONS]; + expect(() => parseFlags(["--dry-run", "--prompt"], opts)).toThrowError( + expect.objectContaining({ + message: expect.stringContaining("Flag --prompt requires a value"), + }), + ); + // --dry-run is boolean: no value check; parsing continues to --prompt. + const flags = parseFlags(["--dry-run", "--prompt", "cat"], opts); + expect(flags.dryRun).toBe(true); + expect(flags.prompt).toBe("cat"); +}); diff --git a/packages/runtime/tests/pipeline.test.ts b/packages/runtime/tests/pipeline.test.ts new file mode 100644 index 0000000..f6853c0 --- /dev/null +++ b/packages/runtime/tests/pipeline.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from "vite-plus/test"; +import { createStepDispatcher } from "../src/pipeline/dispatcher.ts"; +import { executePipeline } from "../src/pipeline/executor.ts"; +import { collectPipelineIssues } from "../src/pipeline/validation.ts"; +import { getByJsonPointer } from "../src/pipeline/schema.ts"; +import { normalizeConcurrency } from "../src/pipeline/scheduler.ts"; +import { WORKFLOW_VERSION, type PipelineDefinition } from "../src/pipeline/types.ts"; + +test("cli package skeleton", () => { + expect(true).toBe(true); +}); + +test("pipeline execution can use an isolated step dispatcher", async () => { + const dispatcher = createStepDispatcher(); + dispatcher.registerStep("test/echo", (input, ctx) => ({ + data: { input, hasSignal: !!ctx.signal }, + })); + + const controller = new AbortController(); + const pipeline: PipelineDefinition = { + version: WORKFLOW_VERSION, + steps: [{ id: "echo", type: "test/echo", input: { message: "hello" } }], + }; + + const report = await executePipeline( + pipeline, + {}, + { + stepDispatcher: dispatcher, + signal: controller.signal, + }, + ); + + expect(report.status).toBe("succeeded"); + expect(report.steps[0]?.output?.data).toEqual({ + input: { message: "hello" }, + hasSignal: true, + }); +}); + +test("dry-run never executes $js expressions (preview must not run code)", async () => { + const dispatcher = createStepDispatcher(); + dispatcher.registerStep("test/echo", (input) => ({ data: input })); + const flag = "__bailian_dryrun_should_not_run__"; + delete (globalThis as Record)[flag]; + + const pipeline: PipelineDefinition = { + version: WORKFLOW_VERSION, + steps: [ + { + id: "s1", + type: "test/echo", + input: { probe: { $js: `(globalThis[${JSON.stringify(flag)}] = true), 1` } }, + }, + ], + }; + + const report = await executePipeline(pipeline, {}, { stepDispatcher: dispatcher, dryRun: true }); + expect(report.status).toBe("planned"); + expect((globalThis as Record)[flag]).toBeUndefined(); +}); + +test("script/js rejects non-literal code sourced from another step ($from)", () => { + const dispatcher = createStepDispatcher(); + dispatcher.registerStep("test/echo", (input) => ({ data: input })); + dispatcher.registerStep("script/js", () => ({ data: {} })); + + const pipeline: PipelineDefinition = { + version: WORKFLOW_VERSION, + steps: [ + { id: "gen", type: "test/echo", input: { message: "x" } }, + { + id: "run", + type: "script/js", + input: { code: { $from: "gen", path: "/data/message" } as never }, + }, + ], + }; + + const issues = collectPipelineIssues(pipeline, dispatcher); + expect(issues.some((issue) => issue.includes('literal string "code"'))).toBe(true); +}); + +test("script/js accepts a literal string code", () => { + const dispatcher = createStepDispatcher(); + dispatcher.registerStep("script/js", () => ({ data: {} })); + + const pipeline: PipelineDefinition = { + version: WORKFLOW_VERSION, + steps: [{ id: "run", type: "script/js", input: { code: "return 1" } }], + }; + + expect(collectPipelineIssues(pipeline, dispatcher)).toEqual([]); +}); + +test("getByJsonPointer refuses prototype keys and inherited properties", () => { + const obj = { a: { b: 1 } }; + expect(getByJsonPointer(obj, "/a/b")).toBe(1); + expect(getByJsonPointer(obj, "/__proto__")).toBeUndefined(); + expect(getByJsonPointer(obj, "/constructor")).toBeUndefined(); + expect(getByJsonPointer(obj, "/a/constructor/constructor")).toBeUndefined(); + expect(getByJsonPointer(obj, "/toString")).toBeUndefined(); +}); + +test("normalizeConcurrency clamps to a safe maximum", () => { + expect(normalizeConcurrency(undefined)).toBe(1); + expect(normalizeConcurrency(4)).toBe(4); + expect(normalizeConcurrency(100000)).toBe(64); +}); diff --git a/packages/runtime/tests/proxy.test.ts b/packages/runtime/tests/proxy.test.ts new file mode 100644 index 0000000..1986459 --- /dev/null +++ b/packages/runtime/tests/proxy.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "vite-plus/test"; +import { readProxyEnv } from "../src/proxy.ts"; + +test("readProxyEnv: 未设置任何代理变量时全部为 undefined", () => { + expect(readProxyEnv({})).toEqual({ + httpProxy: undefined, + httpsProxy: undefined, + noProxy: undefined, + }); +}); + +test("readProxyEnv: 空白值视为未设置", () => { + expect(readProxyEnv({ HTTPS_PROXY: "", HTTP_PROXY: " ", NO_PROXY: "" })).toEqual({ + httpProxy: undefined, + httpsProxy: undefined, + noProxy: undefined, + }); +}); + +test("readProxyEnv: 大小写变量均可识别,小写优先", () => { + expect(readProxyEnv({ HTTPS_PROXY: "http://upper:1" }).httpsProxy).toBe("http://upper:1"); + expect(readProxyEnv({ https_proxy: "http://lower:1" }).httpsProxy).toBe("http://lower:1"); + expect( + readProxyEnv({ https_proxy: "http://lower:1", HTTPS_PROXY: "http://upper:1" }).httpsProxy, + ).toBe("http://lower:1"); +}); + +test("readProxyEnv: 空字符串小写变量不屏蔽已设置的大写变量", () => { + expect(readProxyEnv({ https_proxy: "", HTTPS_PROXY: "http://upper:1" }).httpsProxy).toBe( + "http://upper:1", + ); + expect(readProxyEnv({ http_proxy: "", HTTP_PROXY: "http://upper:2" }).httpProxy).toBe( + "http://upper:2", + ); +}); + +test("readProxyEnv: NO_PROXY 独立读取", () => { + const r = readProxyEnv({ NO_PROXY: "*.aliyuncs.com" }); + expect(r.noProxy).toBe("*.aliyuncs.com"); + expect(r.httpProxy).toBeUndefined(); + expect(r.httpsProxy).toBeUndefined(); +});