svmc-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { parse, stringify } from "@iarna/toml";
5
+ import type { RuntimeConfig } from "../types";
6
+ import { CliError, EXIT_CODES } from "./errors";
7
+
8
+ const DEFAULT_CONFIG_PATH = join(homedir(), ".svmc", "config.toml");
9
+ export const DEFAULT_BASE_URL = "https://media.sailvan.com/api/v1";
10
+
11
+ const EMPTY_CONFIG: RuntimeConfig = {
12
+ auth: { access_token: "" },
13
+ server: {},
14
+ };
15
+
16
+ export function getConfigPath(overridePath?: string): string {
17
+ return overridePath ?? process.env.SVMC_CONFIG_PATH ?? DEFAULT_CONFIG_PATH;
18
+ }
19
+
20
+ export async function loadConfig(configPath?: string): Promise<RuntimeConfig> {
21
+ const resolved = getConfigPath(configPath);
22
+ try {
23
+ const content = await readFile(resolved, "utf8");
24
+ const raw = parse(content) as Partial<RuntimeConfig>;
25
+ return {
26
+ auth: {
27
+ access_token: raw.auth?.access_token ?? "",
28
+ cookie: raw.auth?.cookie ?? undefined,
29
+ },
30
+ server: { base_url: raw.server?.base_url ?? undefined },
31
+ };
32
+ } catch (error) {
33
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
34
+ return { ...EMPTY_CONFIG };
35
+ }
36
+ throw new CliError(`读取配置失败: ${(error as Error).message}`, EXIT_CODES.IO);
37
+ }
38
+ }
39
+
40
+ export async function saveConfig(config: RuntimeConfig, configPath?: string): Promise<string> {
41
+ const resolved = getConfigPath(configPath);
42
+ await mkdir(dirname(resolved), { recursive: true });
43
+ const content = stringify(config as any);
44
+ await writeFile(resolved, content, "utf8");
45
+ return resolved;
46
+ }
47
+
48
+ export async function getAccessToken(configPath?: string): Promise<string> {
49
+ const cfg = await loadConfig(configPath);
50
+ const token = cfg.auth.access_token?.trim();
51
+ if (!token) {
52
+ throw new CliError(
53
+ "未找到 SVMC_ACCESS_TOKEN,请先执行 `svmc auth` 完成鉴权。",
54
+ EXIT_CODES.AUTH,
55
+ );
56
+ }
57
+ return stripBearerPrefix(token);
58
+ }
59
+
60
+ export async function getSessionCookie(cookieFlag?: string, configPath?: string): Promise<string | undefined> {
61
+ const fromFlag = cookieFlag?.trim();
62
+ if (fromFlag) {
63
+ return fromFlag;
64
+ }
65
+ const fromEnv = process.env.SVMC_COOKIE?.trim();
66
+ if (fromEnv) {
67
+ return fromEnv;
68
+ }
69
+ const cfg = await loadConfig(configPath);
70
+ const fromConfig = cfg.auth.cookie?.trim();
71
+ return fromConfig || undefined;
72
+ }
73
+
74
+ export async function getBaseUrl(baseUrlFlag?: string, configPath?: string): Promise<string> {
75
+ const fromFlag = baseUrlFlag?.trim();
76
+ if (fromFlag) {
77
+ return normalizeBaseUrl(fromFlag);
78
+ }
79
+ const fromEnv = process.env.SVMC_BASE_URL?.trim();
80
+ if (fromEnv) {
81
+ return normalizeBaseUrl(fromEnv);
82
+ }
83
+ const config = await loadConfig(configPath);
84
+ const fromConfig = config.server.base_url?.trim();
85
+ if (fromConfig) {
86
+ return normalizeBaseUrl(fromConfig);
87
+ }
88
+ return DEFAULT_BASE_URL;
89
+ }
90
+
91
+ export function normalizeBaseUrl(url: string): string {
92
+ return url.replace(/\/+$/, "");
93
+ }
94
+
95
+ function stripBearerPrefix(token: string): string {
96
+ const normalized = token.trim();
97
+ if (normalized.toLowerCase().startsWith("bearer ")) {
98
+ return normalized.slice(7).trim();
99
+ }
100
+ return normalized;
101
+ }
@@ -0,0 +1,42 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { basename, extname, join, resolve } from "node:path";
3
+ import { CliError, EXIT_CODES } from "./errors";
4
+
5
+ export async function downloadToLocal(
6
+ url: string,
7
+ outputDir: string,
8
+ preferredName?: string,
9
+ ): Promise<string> {
10
+ await mkdir(outputDir, { recursive: true });
11
+ const response = await fetch(url);
12
+ if (!response.ok) {
13
+ throw new CliError(`下载失败(${response.status}): ${url}`, EXIT_CODES.DOWNLOAD_FAILED);
14
+ }
15
+ const bytes = Buffer.from(await response.arrayBuffer());
16
+ const fileName = sanitizeFileName(
17
+ preferredName ?? (basename(new URL(url).pathname) || "image.jpg"),
18
+ );
19
+ const fullPath = resolve(join(outputDir, ensureExt(fileName, response.headers.get("content-type"))));
20
+ await writeFile(fullPath, bytes);
21
+ return fullPath;
22
+ }
23
+
24
+ function ensureExt(fileName: string, contentType: string | null): string {
25
+ if (extname(fileName)) {
26
+ return fileName;
27
+ }
28
+ if (!contentType) {
29
+ return `${fileName}.jpg`;
30
+ }
31
+ if (contentType.includes("png")) {
32
+ return `${fileName}.png`;
33
+ }
34
+ if (contentType.includes("webp")) {
35
+ return `${fileName}.webp`;
36
+ }
37
+ return `${fileName}.jpg`;
38
+ }
39
+
40
+ function sanitizeFileName(input: string): string {
41
+ return input.replace(/[^\w.-]+/g, "_");
42
+ }
@@ -0,0 +1,19 @@
1
+ export class CliError extends Error {
2
+ public readonly exitCode: number;
3
+
4
+ constructor(message: string, exitCode = 1) {
5
+ super(message);
6
+ this.name = "CliError";
7
+ this.exitCode = exitCode;
8
+ }
9
+ }
10
+
11
+ export const EXIT_CODES = {
12
+ OK: 0,
13
+ USAGE: 2,
14
+ AUTH: 3,
15
+ API: 4,
16
+ TASK_FAILED: 5,
17
+ DOWNLOAD_FAILED: 6,
18
+ IO: 7,
19
+ } as const;
@@ -0,0 +1,147 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import yargsParser from "yargs-parser";
3
+ import { CliError, EXIT_CODES } from "./errors";
4
+
5
+ export interface GenerateCliArgs {
6
+ provider: string;
7
+ input: string[];
8
+ prompt?: string;
9
+ ratio?: string;
10
+ outputDir?: string;
11
+ timeoutSec: number;
12
+ verbose: boolean;
13
+ baseUrl?: string;
14
+ cookie?: string;
15
+ dynamicParams: Record<string, string | number | boolean | string[]>;
16
+ }
17
+
18
+ export function parseGenerateCliArgs(rawArgs: string[]): GenerateCliArgs {
19
+ const parsed = yargsParser(rawArgs, {
20
+ array: ["input"],
21
+ string: ["provider", "prompt", "ratio", "output-dir", "base-url", "cookie", "config-path"],
22
+ boolean: ["verbose"],
23
+ number: ["timeout-sec"],
24
+ configuration: {
25
+ "camel-case-expansion": false,
26
+ "dot-notation": false,
27
+ "unknown-options-as-args": false,
28
+ "strip-aliased": true,
29
+ "strip-dashed": false,
30
+ },
31
+ });
32
+
33
+ const provider = asString(parsed.provider);
34
+ if (!provider) {
35
+ throw new CliError("缺少 `--provider` 参数。", EXIT_CODES.USAGE);
36
+ }
37
+
38
+ const input = toStringArray(parsed.input);
39
+ if (input.length === 0) {
40
+ throw new CliError("至少传入一个 `--input` 图片路径。", EXIT_CODES.USAGE);
41
+ }
42
+
43
+ const timeoutSec = typeof parsed["timeout-sec"] === "number" ? parsed["timeout-sec"] : 600;
44
+ if (!Number.isFinite(timeoutSec) || timeoutSec <= 0) {
45
+ throw new CliError("`--timeout-sec` 必须为正整数。", EXIT_CODES.USAGE);
46
+ }
47
+
48
+ const dynamicParams = collectDynamicParams(parsed, [
49
+ "_",
50
+ "$0",
51
+ "provider",
52
+ "input",
53
+ "prompt",
54
+ "ratio",
55
+ "output-dir",
56
+ "timeout-sec",
57
+ "verbose",
58
+ "base-url",
59
+ "cookie",
60
+ "config-path",
61
+ ]);
62
+
63
+ return {
64
+ provider,
65
+ input,
66
+ prompt: asString(parsed.prompt),
67
+ ratio: asString(parsed.ratio),
68
+ outputDir: asString(parsed["output-dir"]),
69
+ timeoutSec,
70
+ verbose: Boolean(parsed.verbose),
71
+ baseUrl: asString(parsed["base-url"]),
72
+ cookie: asString(parsed.cookie),
73
+ dynamicParams,
74
+ };
75
+ }
76
+
77
+ export async function ensureInputsReadable(paths: string[]): Promise<void> {
78
+ for (const path of paths) {
79
+ try {
80
+ await access(path);
81
+ } catch {
82
+ throw new CliError(`输入文件不存在或不可读: ${path}`, EXIT_CODES.USAGE);
83
+ }
84
+ }
85
+ }
86
+
87
+ export async function readImagesAsDataUrls(paths: string[]): Promise<string[]> {
88
+ const urls: string[] = [];
89
+ for (const path of paths) {
90
+ const content = await readFile(path);
91
+ const ext = path.split(".").pop()?.toLowerCase() ?? "jpg";
92
+ const mime = toMime(ext);
93
+ urls.push(`data:${mime};base64,${content.toString("base64")}`);
94
+ }
95
+ return urls;
96
+ }
97
+
98
+ function toMime(ext: string): string {
99
+ switch (ext) {
100
+ case "png":
101
+ return "image/png";
102
+ case "webp":
103
+ return "image/webp";
104
+ case "gif":
105
+ return "image/gif";
106
+ case "jpeg":
107
+ case "jpg":
108
+ default:
109
+ return "image/jpeg";
110
+ }
111
+ }
112
+
113
+ function collectDynamicParams(
114
+ parsed: Record<string, unknown>,
115
+ excludedKeys: string[],
116
+ ): Record<string, string | number | boolean | string[]> {
117
+ const excluded = new Set(excludedKeys);
118
+ const result: Record<string, string | number | boolean | string[]> = {};
119
+ for (const [key, value] of Object.entries(parsed)) {
120
+ if (excluded.has(key)) {
121
+ continue;
122
+ }
123
+ if (
124
+ typeof value === "string" ||
125
+ typeof value === "number" ||
126
+ typeof value === "boolean" ||
127
+ (Array.isArray(value) && value.every((item) => typeof item === "string"))
128
+ ) {
129
+ result[key] = value as string | number | boolean | string[];
130
+ }
131
+ }
132
+ return result;
133
+ }
134
+
135
+ function asString(value: unknown): string | undefined {
136
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
137
+ }
138
+
139
+ function toStringArray(value: unknown): string[] {
140
+ if (!value) {
141
+ return [];
142
+ }
143
+ if (Array.isArray(value)) {
144
+ return value.filter((item): item is string => typeof item === "string");
145
+ }
146
+ return typeof value === "string" ? [value] : [];
147
+ }
@@ -0,0 +1,46 @@
1
+ import { CliError, EXIT_CODES } from "./errors";
2
+ import type { ImageGenerationTask } from "../types";
3
+
4
+ interface PollOptions {
5
+ intervalMs?: number;
6
+ timeoutMs?: number;
7
+ verbose?: boolean;
8
+ }
9
+
10
+ const RUNNING_STATUSES = new Set(["pending", "processing", "running"]);
11
+
12
+ export async function pollTaskUntilDone(
13
+ fetchTask: () => Promise<ImageGenerationTask>,
14
+ options: PollOptions = {},
15
+ ): Promise<ImageGenerationTask> {
16
+ const intervalMs = options.intervalMs ?? 5000;
17
+ const timeoutMs = options.timeoutMs ?? 600_000;
18
+ const start = Date.now();
19
+
20
+ while (true) {
21
+ const task = await fetchTask();
22
+ const status = task.status;
23
+ if (options.verbose) {
24
+ process.stdout.write(`[poll] status=${status} task_uuid=${task.task_uuid}\n`);
25
+ }
26
+
27
+ if (status === "completed") {
28
+ return task;
29
+ }
30
+ if (status === "failed") {
31
+ const reason = task.error_message ?? "任务失败(无错误详情)";
32
+ throw new CliError(reason, EXIT_CODES.TASK_FAILED);
33
+ }
34
+ if (!RUNNING_STATUSES.has(status)) {
35
+ throw new CliError(`未知任务状态: ${status}`, EXIT_CODES.API);
36
+ }
37
+ if (Date.now() - start > timeoutMs) {
38
+ throw new CliError("轮询超时,请稍后重试。", EXIT_CODES.API);
39
+ }
40
+ await sleep(intervalMs);
41
+ }
42
+ }
43
+
44
+ function sleep(ms: number): Promise<void> {
45
+ return new Promise((resolve) => setTimeout(resolve, ms));
46
+ }
package/src/index.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { Command } from "commander";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { runAuthCommand, runAuthStatusCommand } from "./commands/auth";
5
+ import { runImageGenerateCommand, runImageSpecsCommand } from "./commands/image";
6
+ import { CliError } from "./core/errors";
7
+
8
+ export async function run(argv: string[] = process.argv): Promise<void> {
9
+ const program = new Command();
10
+
11
+ program
12
+ .name("svmc")
13
+ .description("SVMC CLI for image workflows")
14
+ .helpOption("-h, --help", "显示帮助")
15
+ .version(getCliVersion(), "-v, --version", "显示版本号")
16
+ .showHelpAfterError();
17
+
18
+ const auth = program
19
+ .command("auth")
20
+ .description("鉴权相关命令(默认执行 token/cookie 写入)")
21
+ .option("--config-path <path>", "指定配置文件路径")
22
+ .option("--token <token>", "直接传入 token(可选)")
23
+ .option("--cookie <cookie>", "保存会话 Cookie,可粘贴 k1=v1; k2=v2")
24
+ .action(
25
+ async (options: { configPath?: string; token?: string; cookie?: string }) => {
26
+ await runAuthCommand(options);
27
+ },
28
+ );
29
+
30
+ auth
31
+ .command("status")
32
+ .description("验证当前认证信息是否可用")
33
+ .option("--config-path <path>", "指定配置文件路径")
34
+ .option("--cookie <cookie>", "会话 Cookie,可覆盖配置")
35
+ .action(async (options: { configPath?: string; cookie?: string }) => {
36
+ await runAuthStatusCommand(options);
37
+ });
38
+
39
+ const image = program.command("image").description("图像相关命令");
40
+
41
+ image
42
+ .command("specs")
43
+ .description("获取 img2img 参数规格")
44
+ .option("--provider <provider>", "指定供应商,仅输出该供应商规格")
45
+ .option("--base-url <url>", "服务地址")
46
+ .option("--cookie <cookie>", "会话 Cookie,可覆盖配置")
47
+ .option("--config-path <path>", "指定配置文件路径")
48
+ .action(
49
+ async (options: { provider?: string; baseUrl?: string; cookie?: string; configPath?: string }) =>
50
+ runImageSpecsCommand(options),
51
+ );
52
+
53
+ image
54
+ .command("generate")
55
+ .description("提交 img2img 任务并轮询下载结果")
56
+ .allowUnknownOption(true)
57
+ .option("--config-path <path>", "指定配置文件路径")
58
+ .action(async (options: { configPath?: string }) => {
59
+ const rawArgs = getRawArgsAfterToken(argv, "generate");
60
+ await runImageGenerateCommand({
61
+ rawArgs,
62
+ configPath: options.configPath,
63
+ });
64
+ });
65
+
66
+ try {
67
+ await program.parseAsync(argv);
68
+ } catch (error) {
69
+ if (error instanceof CliError) {
70
+ process.stderr.write(`${error.message}\n`);
71
+ process.exit(error.exitCode);
72
+ }
73
+ const message = error instanceof Error ? error.message : String(error);
74
+ process.stderr.write(`${message}\n`);
75
+ process.exit(1);
76
+ }
77
+ }
78
+
79
+ function getRawArgsAfterToken(argv: string[], token: string): string[] {
80
+ const index = argv.findIndex((item) => item === token);
81
+ if (index === -1) {
82
+ return [];
83
+ }
84
+ return argv.slice(index + 1);
85
+ }
86
+
87
+ if (require.main === module) {
88
+ run(process.argv).catch((error) => {
89
+ const message = error instanceof Error ? error.message : String(error);
90
+ process.stderr.write(`${message}\n`);
91
+ process.exit(1);
92
+ });
93
+ }
94
+
95
+ function getCliVersion(): string {
96
+ try {
97
+ const candidates = [join(__dirname, "../package.json"), join(__dirname, "../../package.json")];
98
+ const target = candidates.find((path) => existsSync(path));
99
+ if (!target) {
100
+ return "0.0.0";
101
+ }
102
+ const content = readFileSync(target, "utf8");
103
+ const pkg = JSON.parse(content) as { version?: string };
104
+ return pkg.version ?? "0.0.0";
105
+ } catch {
106
+ return "0.0.0";
107
+ }
108
+ }
@@ -0,0 +1,47 @@
1
+ export type TaskStatus = "pending" | "processing" | "running" | "completed" | "failed";
2
+
3
+ export interface ResultItem {
4
+ result_url: string;
5
+ thumbnail_url?: string;
6
+ format?: string;
7
+ width?: number;
8
+ height?: number;
9
+ size?: number;
10
+ }
11
+
12
+ export interface ImageGenerationTask {
13
+ id?: number;
14
+ task_uuid: string;
15
+ status: TaskStatus;
16
+ supplier?: string;
17
+ error_message?: string;
18
+ results?: ResultItem[];
19
+ request_payload?: Record<string, unknown>;
20
+ }
21
+
22
+ export interface ModuleParamsConfig {
23
+ supplier?: Record<string, unknown>;
24
+ for_supplier?: Record<string, Record<string, unknown>>;
25
+ }
26
+
27
+ export interface ImageParamsResponse {
28
+ img2img?: ModuleParamsConfig;
29
+ [key: string]: unknown;
30
+ }
31
+
32
+ export interface RuntimeConfig {
33
+ auth: {
34
+ access_token: string;
35
+ cookie?: string;
36
+ };
37
+ server: {
38
+ base_url?: string;
39
+ };
40
+ }
41
+
42
+ export interface ApiEnvelope<T> {
43
+ code: number;
44
+ message?: string;
45
+ success?: boolean;
46
+ data: T;
47
+ }
@@ -0,0 +1,53 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { ApiClient } from "../src/core/apiClient";
3
+ import { CliError } from "../src/core/errors";
4
+
5
+ describe("ApiClient", () => {
6
+ afterEach(() => {
7
+ vi.unstubAllGlobals();
8
+ });
9
+
10
+ it("returns parsed json when request succeeds", async () => {
11
+ vi.stubGlobal(
12
+ "fetch",
13
+ vi.fn(async () =>
14
+ new Response(JSON.stringify({ code: 200, message: "ok", data: { ok: true } }), {
15
+ status: 200,
16
+ headers: { "Content-Type": "application/json" },
17
+ }),
18
+ ),
19
+ );
20
+
21
+ const client = new ApiClient("https://example.com", "token");
22
+ const res = await client.get<{ ok: boolean }>("/ping");
23
+ expect(res.ok).toBe(true);
24
+ });
25
+
26
+ it("throws CliError on 401", async () => {
27
+ vi.stubGlobal(
28
+ "fetch",
29
+ vi.fn(async () =>
30
+ new Response(JSON.stringify({ message: "unauthorized" }), {
31
+ status: 401,
32
+ headers: { "Content-Type": "application/json" },
33
+ }),
34
+ ),
35
+ );
36
+ const client = new ApiClient("https://example.com", "token");
37
+ await expect(client.get("/ping")).rejects.toBeInstanceOf(CliError);
38
+ });
39
+
40
+ it("throws CliError when business code is 401", async () => {
41
+ vi.stubGlobal(
42
+ "fetch",
43
+ vi.fn(async () =>
44
+ new Response(JSON.stringify({ code: 401, message: "未登录或登录已过期", data: null }), {
45
+ status: 200,
46
+ headers: { "Content-Type": "application/json" },
47
+ }),
48
+ ),
49
+ );
50
+ const client = new ApiClient("https://example.com", "token");
51
+ await expect(client.get("/ping")).rejects.toBeInstanceOf(CliError);
52
+ });
53
+ });
@@ -0,0 +1,50 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import {
6
+ DEFAULT_BASE_URL,
7
+ getAccessToken,
8
+ getBaseUrl,
9
+ getSessionCookie,
10
+ loadConfig,
11
+ saveConfig,
12
+ } from "../src/core/config";
13
+
14
+ let tempDir = "";
15
+
16
+ afterEach(async () => {
17
+ if (tempDir) {
18
+ await rm(tempDir, { recursive: true, force: true });
19
+ }
20
+ tempDir = "";
21
+ });
22
+
23
+ describe("config", () => {
24
+ it("saves and loads token", async () => {
25
+ tempDir = await mkdtemp(join(tmpdir(), "svmc-cli-"));
26
+ const configPath = join(tempDir, "config.toml");
27
+ await saveConfig(
28
+ {
29
+ auth: { access_token: "Bearer abc", cookie: "session=1" },
30
+ server: { base_url: "https://example.com" },
31
+ },
32
+ configPath,
33
+ );
34
+
35
+ const config = await loadConfig(configPath);
36
+ expect(config.auth.access_token).toBe("Bearer abc");
37
+ expect(config.auth.cookie).toBe("session=1");
38
+ expect(config.server.base_url).toBe("https://example.com");
39
+
40
+ await expect(getAccessToken(configPath)).resolves.toBe("abc");
41
+ await expect(getSessionCookie(undefined, configPath)).resolves.toBe("session=1");
42
+ });
43
+
44
+ it("uses production base url by default", async () => {
45
+ tempDir = await mkdtemp(join(tmpdir(), "svmc-cli-"));
46
+ const configPath = join(tempDir, "empty.toml");
47
+ const baseUrl = await getBaseUrl(undefined, configPath);
48
+ expect(baseUrl).toBe(DEFAULT_BASE_URL);
49
+ });
50
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseGenerateCliArgs } from "../src/core/parser";
3
+
4
+ describe("parseGenerateCliArgs", () => {
5
+ it("parses standard and dynamic options", () => {
6
+ const args = parseGenerateCliArgs([
7
+ "--provider",
8
+ "nano-banana",
9
+ "--input",
10
+ "a.jpg",
11
+ "--input",
12
+ "b.png",
13
+ "--prompt",
14
+ "cyberpunk",
15
+ "--ratio",
16
+ "16:9",
17
+ "--steps",
18
+ "20",
19
+ "--negative_prompt",
20
+ "low quality",
21
+ ]);
22
+
23
+ expect(args.provider).toBe("nano-banana");
24
+ expect(args.input).toEqual(["a.jpg", "b.png"]);
25
+ expect(args.prompt).toBe("cyberpunk");
26
+ expect(args.ratio).toBe("16:9");
27
+ expect(args.dynamicParams.steps).toBe(20);
28
+ expect(args.dynamicParams.negative_prompt).toBe("low quality");
29
+ });
30
+ });
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { pollTaskUntilDone } from "../src/core/poller";
3
+
4
+ describe("pollTaskUntilDone", () => {
5
+ it("returns completed task", async () => {
6
+ const statuses = ["pending", "processing", "completed"];
7
+ const task = await pollTaskUntilDone(
8
+ async () => ({
9
+ task_uuid: "t-1",
10
+ status: statuses.shift() as "pending" | "processing" | "completed",
11
+ }),
12
+ { intervalMs: 1, timeoutMs: 1000 },
13
+ );
14
+ expect(task.status).toBe("completed");
15
+ });
16
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "CommonJS",
5
+ "moduleResolution": "Node",
6
+ "rootDir": ".",
7
+ "outDir": "dist",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "resolveJsonModule": true,
12
+ "types": ["node"]
13
+ },
14
+ "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"]
15
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["tests/**/*.test.ts"],
6
+ exclude: ["dist/**", "node_modules/**"],
7
+ },
8
+ });