@pulse-editor/cli 0.1.10 → 0.1.11

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.
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  export default function Header() {
4
- return (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { color: 'whiteBright', children: "Pulse Editor CLI" }), _jsx(Text, { children: "Version: 0.0.1" }), _jsx(Text, { color: 'blueBright', children: "https://pulse-editor.com" })] }));
4
+ return (_jsxs(Box, { flexDirection: "column", alignItems: "center", children: [_jsx(Text, { color: 'whiteBright', children: "Palmos CLI" }), _jsx(Text, { children: "Version: 0.0.1" }), _jsx(Text, { color: 'blueBright', children: "https://pulse-editor.com" })] }));
5
5
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { getBackendUrl } from "../backend-url.js";
3
+ describe("getBackendUrl", () => {
4
+ it("should return localhost URL when stage is true", () => {
5
+ expect(getBackendUrl(true)).toBe("https://localhost:8080");
6
+ });
7
+ it("should return production URL when stage is false", () => {
8
+ expect(getBackendUrl(false)).toBe("https://pulse-editor.com");
9
+ });
10
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { flags } from "../cli-flags.js";
3
+ describe("flags", () => {
4
+ it("should define all expected flags", () => {
5
+ expect(flags.token).toBeDefined();
6
+ expect(flags.flow).toBeDefined();
7
+ expect(flags.framework).toBeDefined();
8
+ expect(flags.stage).toBeDefined();
9
+ expect(flags.name).toBeDefined();
10
+ expect(flags.visibility).toBeDefined();
11
+ expect(flags.target).toBeDefined();
12
+ expect(flags.beta).toBeDefined();
13
+ expect(flags.build).toBeDefined();
14
+ expect(flags.path).toBeDefined();
15
+ expect(flags.displayName).toBeDefined();
16
+ expect(flags.description).toBeDefined();
17
+ expect(flags.continue).toBeDefined();
18
+ });
19
+ it("should have correct types", () => {
20
+ expect(flags.token.type).toBe("boolean");
21
+ expect(flags.framework.type).toBe("string");
22
+ expect(flags.stage.type).toBe("boolean");
23
+ expect(flags.name.type).toBe("string");
24
+ });
25
+ it("should have correct defaults", () => {
26
+ expect(flags.stage.default).toBe(false);
27
+ expect(flags.build.default).toBe(true);
28
+ expect(flags.continue.default).toBe(false);
29
+ });
30
+ it("should have correct short flags", () => {
31
+ expect(flags.framework.shortFlag).toBe("f");
32
+ expect(flags.name.shortFlag).toBe("n");
33
+ expect(flags.visibility.shortFlag).toBe("v");
34
+ expect(flags.target.shortFlag).toBe("t");
35
+ expect(flags.path.shortFlag).toBe("p");
36
+ expect(flags.description.shortFlag).toBe("d");
37
+ });
38
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { commandsManual } from "../manual.js";
3
+ describe("commandsManual", () => {
4
+ it("should have all expected commands", () => {
5
+ const expectedCommands = [
6
+ "help",
7
+ "chat",
8
+ "code",
9
+ "login",
10
+ "logout",
11
+ "publish",
12
+ "create",
13
+ "preview",
14
+ "dev",
15
+ "build",
16
+ "start",
17
+ "clean",
18
+ "upgrade",
19
+ "skill",
20
+ ];
21
+ for (const cmd of expectedCommands) {
22
+ expect(commandsManual).toHaveProperty(cmd);
23
+ }
24
+ });
25
+ it("should have non-empty string values", () => {
26
+ for (const [key, value] of Object.entries(commandsManual)) {
27
+ expect(typeof value).toBe("string");
28
+ expect(value.length).toBeGreaterThan(0);
29
+ }
30
+ });
31
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { saveToken, getToken, isTokenInEnv, checkToken } from "../token.js";
3
+ vi.mock("fs", () => ({
4
+ default: {
5
+ existsSync: vi.fn(),
6
+ readFileSync: vi.fn(),
7
+ writeFileSync: vi.fn(),
8
+ mkdirSync: vi.fn(),
9
+ },
10
+ }));
11
+ vi.mock("os", () => ({
12
+ default: {
13
+ homedir: vi.fn(() => "/home/testuser"),
14
+ },
15
+ }));
16
+ vi.mock("../backend-url.js", () => ({
17
+ getBackendUrl: vi.fn((stage) => stage ? "https://localhost:8080" : "https://pulse-editor.com"),
18
+ }));
19
+ import fs from "fs";
20
+ describe("saveToken", () => {
21
+ beforeEach(() => {
22
+ vi.clearAllMocks();
23
+ });
24
+ it("should create config dir if it does not exist and save prod token", () => {
25
+ vi.mocked(fs.existsSync).mockReturnValueOnce(false).mockReturnValueOnce(false);
26
+ vi.mocked(fs.readFileSync).mockReturnValue("{}");
27
+ saveToken("my-token", false);
28
+ expect(fs.mkdirSync).toHaveBeenCalled();
29
+ expect(fs.writeFileSync).toHaveBeenCalledWith(expect.stringContaining("config.json"), expect.stringContaining('"accessToken": "my-token"'));
30
+ });
31
+ it("should save dev token when devMode is true", () => {
32
+ vi.mocked(fs.existsSync).mockReturnValueOnce(false).mockReturnValueOnce(true);
33
+ saveToken("dev-token", true);
34
+ expect(fs.writeFileSync).toHaveBeenCalledWith(expect.any(String), expect.stringContaining('"devAccessToken": "dev-token"'));
35
+ });
36
+ it("should merge with existing config", () => {
37
+ vi.mocked(fs.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(true);
38
+ vi.mocked(fs.readFileSync).mockReturnValue('{"existingKey": "value"}');
39
+ saveToken("new-token", false);
40
+ const written = vi.mocked(fs.writeFileSync).mock.calls[0][1];
41
+ const parsed = JSON.parse(written);
42
+ expect(parsed.existingKey).toBe("value");
43
+ expect(parsed.accessToken).toBe("new-token");
44
+ });
45
+ });
46
+ describe("getToken", () => {
47
+ const originalEnv = process.env;
48
+ beforeEach(() => {
49
+ vi.clearAllMocks();
50
+ process.env = { ...originalEnv };
51
+ });
52
+ afterEach(() => {
53
+ process.env = originalEnv;
54
+ });
55
+ it("should return env var PE_ACCESS_TOKEN when set", () => {
56
+ process.env["PE_ACCESS_TOKEN"] = "env-token";
57
+ expect(getToken(false)).toBe("env-token");
58
+ });
59
+ it("should return env var PE_DEV_ACCESS_TOKEN when devMode", () => {
60
+ process.env["PE_DEV_ACCESS_TOKEN"] = "dev-env-token";
61
+ expect(getToken(true)).toBe("dev-env-token");
62
+ });
63
+ it("should read from config file when env not set", () => {
64
+ delete process.env["PE_ACCESS_TOKEN"];
65
+ vi.mocked(fs.existsSync).mockReturnValue(true);
66
+ vi.mocked(fs.readFileSync).mockReturnValue('{"accessToken": "file-token"}');
67
+ expect(getToken(false)).toBe("file-token");
68
+ });
69
+ it("should return undefined when no env and no file", () => {
70
+ delete process.env["PE_ACCESS_TOKEN"];
71
+ vi.mocked(fs.existsSync).mockReturnValue(false);
72
+ expect(getToken(false)).toBeUndefined();
73
+ });
74
+ it("should handle malformed JSON gracefully", () => {
75
+ delete process.env["PE_ACCESS_TOKEN"];
76
+ vi.mocked(fs.existsSync).mockReturnValue(true);
77
+ vi.mocked(fs.readFileSync).mockReturnValue("not json");
78
+ vi.spyOn(console, "error").mockImplementation(() => { });
79
+ expect(getToken(false)).toBeUndefined();
80
+ });
81
+ it("should return devAccessToken in dev mode from file", () => {
82
+ delete process.env["PE_DEV_ACCESS_TOKEN"];
83
+ vi.mocked(fs.existsSync).mockReturnValue(true);
84
+ vi.mocked(fs.readFileSync).mockReturnValue('{"devAccessToken": "dev-file-token"}');
85
+ expect(getToken(true)).toBe("dev-file-token");
86
+ });
87
+ });
88
+ describe("isTokenInEnv", () => {
89
+ const originalEnv = process.env;
90
+ beforeEach(() => {
91
+ process.env = { ...originalEnv };
92
+ });
93
+ afterEach(() => {
94
+ process.env = originalEnv;
95
+ });
96
+ it("should return true when PE_ACCESS_TOKEN is set", () => {
97
+ process.env["PE_ACCESS_TOKEN"] = "token";
98
+ expect(isTokenInEnv(false)).toBe(true);
99
+ });
100
+ it("should return false when PE_ACCESS_TOKEN is not set", () => {
101
+ delete process.env["PE_ACCESS_TOKEN"];
102
+ expect(isTokenInEnv(false)).toBe(false);
103
+ });
104
+ it("should check PE_DEV_ACCESS_TOKEN in dev mode", () => {
105
+ process.env["PE_DEV_ACCESS_TOKEN"] = "dev-token";
106
+ expect(isTokenInEnv(true)).toBe(true);
107
+ });
108
+ });
109
+ describe("checkToken", () => {
110
+ beforeEach(() => {
111
+ vi.clearAllMocks();
112
+ });
113
+ it("should return true on 200 status", async () => {
114
+ globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 });
115
+ const result = await checkToken("valid-token", false);
116
+ expect(result).toBe(true);
117
+ expect(fetch).toHaveBeenCalledWith("https://pulse-editor.com/api/api-keys/check", expect.objectContaining({
118
+ method: "POST",
119
+ body: JSON.stringify({ token: "valid-token" }),
120
+ }));
121
+ });
122
+ it("should return false on non-200 status", async () => {
123
+ globalThis.fetch = vi.fn().mockResolvedValue({ status: 401 });
124
+ const result = await checkToken("bad-token", false);
125
+ expect(result).toBe(false);
126
+ });
127
+ it("should use stage URL in dev mode", async () => {
128
+ globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 });
129
+ await checkToken("token", true);
130
+ expect(fetch).toHaveBeenCalledWith("https://localhost:8080/api/api-keys/check", expect.any(Object));
131
+ });
132
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ import { describe, it, expect } from "vitest";
2
+ // types.ts only exports a type, which doesn't generate runtime code.
3
+ // Import something from the file to register it for coverage.
4
+ describe("types", () => {
5
+ it("should export Item type (compile-time only)", async () => {
6
+ // This is a type-only file, importing it registers coverage
7
+ const mod = await import("../types.js");
8
+ expect(mod).toBeDefined();
9
+ });
10
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { publishApp } from "../publish-app.js";
3
+ vi.mock("fs", () => ({
4
+ default: {
5
+ readFileSync: vi.fn(),
6
+ },
7
+ }));
8
+ vi.mock("../../token.js", () => ({
9
+ getToken: vi.fn(() => "test-token"),
10
+ }));
11
+ vi.mock("../../backend-url.js", () => ({
12
+ getBackendUrl: vi.fn((stage) => stage ? "https://localhost:8080" : "https://pulse-editor.com"),
13
+ }));
14
+ import fs from "fs";
15
+ describe("publishApp", () => {
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ globalThis.fetch = vi.fn().mockResolvedValue({ status: 200 });
19
+ });
20
+ it("should read config and zip, then POST to publish endpoint", async () => {
21
+ vi.mocked(fs.readFileSync)
22
+ .mockReturnValueOnce('{"visibility": "public"}')
23
+ .mockReturnValueOnce(Buffer.from("fake-zip"));
24
+ const res = await publishApp(false);
25
+ expect(fetch).toHaveBeenCalledWith("https://pulse-editor.com/api/app/publish", expect.objectContaining({
26
+ method: "POST",
27
+ headers: expect.objectContaining({
28
+ Authorization: "Bearer test-token",
29
+ }),
30
+ }));
31
+ expect(res.status).toBe(200);
32
+ });
33
+ it("should use stage URL when isStage is true", async () => {
34
+ vi.mocked(fs.readFileSync)
35
+ .mockReturnValueOnce('{"visibility": "private"}')
36
+ .mockReturnValueOnce(Buffer.from("fake-zip"));
37
+ await publishApp(true);
38
+ expect(fetch).toHaveBeenCalledWith("https://localhost:8080/api/app/publish", expect.any(Object));
39
+ });
40
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { cleanDist } from "../clean.js";
3
+ vi.mock("execa", () => ({
4
+ execa: vi.fn().mockResolvedValue(undefined),
5
+ }));
6
+ vi.mock("../deps.js", () => ({
7
+ getDepsBinPath: vi.fn(() => "npx rimraf"),
8
+ }));
9
+ import { execa } from "execa";
10
+ describe("cleanDist", () => {
11
+ beforeEach(() => {
12
+ vi.clearAllMocks();
13
+ vi.spyOn(console, "log").mockImplementation(() => { });
14
+ });
15
+ it("should call execa with rimraf dist", async () => {
16
+ await cleanDist();
17
+ expect(execa).toHaveBeenCalledWith("npx rimraf dist", { shell: true });
18
+ });
19
+ it("should log cleaning messages", async () => {
20
+ await cleanDist();
21
+ expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Cleaning dist"));
22
+ });
23
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { getDepsBinPath } from "../deps.js";
3
+ vi.mock("fs", () => ({
4
+ default: {
5
+ existsSync: vi.fn(),
6
+ },
7
+ }));
8
+ import fs from "fs";
9
+ describe("getDepsBinPath", () => {
10
+ beforeEach(() => {
11
+ vi.clearAllMocks();
12
+ });
13
+ it("should return npx when package exists in local node_modules", () => {
14
+ vi.mocked(fs.existsSync).mockReturnValueOnce(true);
15
+ expect(getDepsBinPath("rimraf")).toBe("npx rimraf");
16
+ });
17
+ it("should return full path with .cmd on Windows", () => {
18
+ vi.mocked(fs.existsSync).mockReturnValueOnce(false).mockReturnValueOnce(true);
19
+ const originalPlatform = process.platform;
20
+ Object.defineProperty(process, "platform", { value: "win32" });
21
+ const result = getDepsBinPath("rimraf");
22
+ expect(result).toContain("rimraf.cmd");
23
+ expect(result).toContain("@pulse-editor/cli/node_modules/.bin/");
24
+ Object.defineProperty(process, "platform", { value: originalPlatform });
25
+ });
26
+ it("should return full path without .cmd on non-Windows", () => {
27
+ vi.mocked(fs.existsSync).mockReturnValueOnce(false).mockReturnValueOnce(true);
28
+ const originalPlatform = process.platform;
29
+ Object.defineProperty(process, "platform", { value: "linux" });
30
+ const result = getDepsBinPath("rimraf");
31
+ expect(result).toContain("@pulse-editor/cli/node_modules/.bin/rimraf");
32
+ expect(result).not.toContain(".cmd");
33
+ Object.defineProperty(process, "platform", { value: originalPlatform });
34
+ });
35
+ it("should throw when dependency not found", () => {
36
+ vi.mocked(fs.existsSync).mockReturnValue(false);
37
+ expect(() => getDepsBinPath("nonexistent")).toThrow("Dependency nonexistent not found.");
38
+ });
39
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { webpackCompile } from "../compile.js";
3
+ vi.mock("webpack", () => {
4
+ const mockCompiler = {
5
+ run: vi.fn((cb) => cb(null)),
6
+ watch: vi.fn((_opts, cb) => cb(null)),
7
+ };
8
+ const webpack = vi.fn(() => mockCompiler);
9
+ return { default: webpack, __mockCompiler: mockCompiler };
10
+ });
11
+ vi.mock("../configs/utils.js", () => ({
12
+ generateTempTsConfig: vi.fn(),
13
+ }));
14
+ vi.mock("../webpack-config.js", () => ({
15
+ createWebpackConfig: vi.fn().mockResolvedValue([{ name: "config" }]),
16
+ }));
17
+ import webpack from "webpack";
18
+ import { generateTempTsConfig } from "../configs/utils.js";
19
+ import { createWebpackConfig } from "../webpack-config.js";
20
+ describe("webpackCompile", () => {
21
+ beforeEach(() => {
22
+ vi.clearAllMocks();
23
+ });
24
+ it("should call generateTempTsConfig", async () => {
25
+ await webpackCompile("production");
26
+ expect(generateTempTsConfig).toHaveBeenCalled();
27
+ });
28
+ it("should pass correct params for production mode", async () => {
29
+ await webpackCompile("production");
30
+ expect(createWebpackConfig).toHaveBeenCalledWith(false, "both", "production");
31
+ });
32
+ it("should pass correct params for development mode", async () => {
33
+ await webpackCompile("development");
34
+ expect(createWebpackConfig).toHaveBeenCalledWith(false, "both", "development");
35
+ });
36
+ it("should pass correct params for preview mode", async () => {
37
+ await webpackCompile("preview");
38
+ expect(createWebpackConfig).toHaveBeenCalledWith(true, "both", "development");
39
+ });
40
+ it("should pass buildTarget when specified", async () => {
41
+ await webpackCompile("production", "client");
42
+ expect(createWebpackConfig).toHaveBeenCalledWith(false, "client", "production");
43
+ });
44
+ it("should return compiler in watch mode", async () => {
45
+ const result = await webpackCompile("development", undefined, true);
46
+ expect(result).toBeDefined();
47
+ const mockCompiler = webpack().__proto__; // just verify it returns
48
+ });
49
+ it("should handle webpack run errors", async () => {
50
+ const { __mockCompiler } = await import("webpack");
51
+ __mockCompiler.run.mockImplementationOnce((cb) => cb(new Error("build error")));
52
+ await expect(webpackCompile("production")).rejects.toThrow("build error");
53
+ });
54
+ it("should log error when watch mode has errors", async () => {
55
+ vi.spyOn(console, "error").mockImplementation(() => { });
56
+ const { __mockCompiler } = await import("webpack");
57
+ __mockCompiler.watch.mockImplementationOnce((_opts, cb) => cb(new Error("watch error")));
58
+ const result = await webpackCompile("development", undefined, true);
59
+ expect(result).toBeDefined();
60
+ expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Webpack build failed"), expect.any(Error));
61
+ });
62
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { createWebpackConfig } from "../webpack-config.js";
3
+ vi.mock("../configs/mf-client.js", () => ({
4
+ makeMFClientConfig: vi.fn().mockResolvedValue({ name: "client-config" }),
5
+ }));
6
+ vi.mock("../configs/mf-server.js", () => ({
7
+ makeMFServerConfig: vi.fn().mockResolvedValue({ name: "server-config" }),
8
+ }));
9
+ vi.mock("../configs/preview.js", () => ({
10
+ makePreviewClientConfig: vi
11
+ .fn()
12
+ .mockResolvedValue({ name: "preview-config" }),
13
+ }));
14
+ import { makeMFClientConfig } from "../configs/mf-client.js";
15
+ import { makeMFServerConfig } from "../configs/mf-server.js";
16
+ import { makePreviewClientConfig } from "../configs/preview.js";
17
+ describe("createWebpackConfig", () => {
18
+ beforeEach(() => {
19
+ vi.clearAllMocks();
20
+ });
21
+ it("should return preview + server configs when isPreview", async () => {
22
+ const result = await createWebpackConfig(true, "both", "development");
23
+ expect(result).toHaveLength(2);
24
+ expect(makePreviewClientConfig).toHaveBeenCalledWith("development");
25
+ expect(makeMFServerConfig).toHaveBeenCalledWith("development");
26
+ });
27
+ it("should return only server config when buildTarget is server", async () => {
28
+ const result = await createWebpackConfig(false, "server", "production");
29
+ expect(result).toHaveLength(1);
30
+ expect(makeMFServerConfig).toHaveBeenCalledWith("production");
31
+ expect(makeMFClientConfig).not.toHaveBeenCalled();
32
+ });
33
+ it("should return only client config when buildTarget is client", async () => {
34
+ const result = await createWebpackConfig(false, "client", "production");
35
+ expect(result).toHaveLength(1);
36
+ expect(makeMFClientConfig).toHaveBeenCalledWith("production");
37
+ expect(makeMFServerConfig).not.toHaveBeenCalled();
38
+ });
39
+ it("should return both configs when buildTarget is both", async () => {
40
+ const result = await createWebpackConfig(false, "both", "development");
41
+ expect(result).toHaveLength(2);
42
+ expect(makeMFClientConfig).toHaveBeenCalledWith("development");
43
+ expect(makeMFServerConfig).toHaveBeenCalledWith("development");
44
+ });
45
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,691 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { getLocalNetworkIP, discoverServerFunctions, normalizeJSDocPropertyName, getActionType, isPromiseLikeType, unwrapPromiseLikeType, parseTypeDefs, generateTempTsConfig, readConfigFile, discoverAppSkillActions, compileAppActionSkills, } from "../utils.js";
3
+ vi.mock("os", () => ({
4
+ networkInterfaces: vi.fn(),
5
+ }));
6
+ vi.mock("glob", () => ({
7
+ globSync: vi.fn(),
8
+ }));
9
+ vi.mock("fs", () => ({
10
+ existsSync: vi.fn(),
11
+ writeFileSync: vi.fn(),
12
+ }));
13
+ vi.mock("fs/promises", () => ({
14
+ default: {
15
+ access: vi.fn(),
16
+ readFile: vi.fn(),
17
+ copyFile: vi.fn(),
18
+ rm: vi.fn(),
19
+ },
20
+ }));
21
+ // Mock ts-morph for discoverAppSkillActions and compileAppActionSkills
22
+ const mockGetName = vi.fn().mockReturnValue("testAction");
23
+ const mockGetText = vi.fn().mockReturnValue("initializer");
24
+ const mockGetInitializer = vi.fn().mockReturnValue({ getText: mockGetText });
25
+ const mockBindingElement = {
26
+ getName: mockGetName,
27
+ getInitializer: mockGetInitializer,
28
+ getKind: vi.fn().mockReturnValue(208), // BindingElement
29
+ };
30
+ const mockNameNode = {
31
+ getElements: vi.fn().mockReturnValue([]),
32
+ };
33
+ const mockFuncParam = {
34
+ getNameNode: vi.fn().mockReturnValue(mockNameNode),
35
+ getType: vi.fn().mockReturnValue({
36
+ getProperties: vi.fn().mockReturnValue([]),
37
+ }),
38
+ };
39
+ const mockFuncDecl = {
40
+ getName: vi.fn().mockReturnValue("testAction"),
41
+ getJsDocs: vi.fn().mockReturnValue([
42
+ {
43
+ getDescription: vi.fn().mockReturnValue("A test action"),
44
+ getFullText: vi.fn().mockReturnValue("/** A test action */"),
45
+ },
46
+ ]),
47
+ getParameters: vi.fn().mockReturnValue([]),
48
+ getReturnType: vi.fn().mockReturnValue({
49
+ isObject: vi.fn().mockReturnValue(true),
50
+ getProperties: vi.fn().mockReturnValue([]),
51
+ getSymbol: vi.fn().mockReturnValue(null),
52
+ getTypeArguments: vi.fn().mockReturnValue([]),
53
+ }),
54
+ getKind: vi.fn().mockReturnValue(272), // FunctionDeclaration
55
+ asKind: vi.fn().mockReturnThis(),
56
+ asKindOrThrow: vi.fn().mockReturnThis(),
57
+ };
58
+ const mockSourceFile = {
59
+ getDefaultExportSymbol: vi.fn().mockReturnValue({
60
+ getDeclarations: vi.fn().mockReturnValue([mockFuncDecl]),
61
+ }),
62
+ getDescendantsOfKind: vi.fn().mockReturnValue([]),
63
+ };
64
+ vi.mock("ts-morph", () => {
65
+ class MockProject {
66
+ addSourceFileAtPath = vi.fn().mockReturnValue(mockSourceFile);
67
+ constructor(_opts) { }
68
+ }
69
+ return {
70
+ Project: MockProject,
71
+ Node: {
72
+ isObjectBindingPattern: vi.fn().mockReturnValue(false),
73
+ isBindingElement: vi.fn().mockReturnValue(false),
74
+ },
75
+ SyntaxKind: {
76
+ FunctionDeclaration: 272,
77
+ JSDoc: 348,
78
+ },
79
+ };
80
+ });
81
+ import { networkInterfaces } from "os";
82
+ import { globSync } from "glob";
83
+ import { existsSync, writeFileSync } from "fs";
84
+ describe("getLocalNetworkIP", () => {
85
+ beforeEach(() => {
86
+ vi.clearAllMocks();
87
+ });
88
+ it("should return first non-internal IPv4 address", () => {
89
+ vi.mocked(networkInterfaces).mockReturnValue({
90
+ eth0: [
91
+ {
92
+ address: "192.168.1.100",
93
+ family: "IPv4",
94
+ internal: false,
95
+ netmask: "255.255.255.0",
96
+ mac: "00:00:00:00:00:00",
97
+ cidr: "192.168.1.100/24",
98
+ },
99
+ ],
100
+ });
101
+ expect(getLocalNetworkIP()).toBe("192.168.1.100");
102
+ });
103
+ it("should skip internal addresses", () => {
104
+ vi.mocked(networkInterfaces).mockReturnValue({
105
+ lo: [
106
+ {
107
+ address: "127.0.0.1",
108
+ family: "IPv4",
109
+ internal: true,
110
+ netmask: "255.0.0.0",
111
+ mac: "00:00:00:00:00:00",
112
+ cidr: "127.0.0.1/8",
113
+ },
114
+ ],
115
+ });
116
+ expect(getLocalNetworkIP()).toBe("localhost");
117
+ });
118
+ it("should skip IPv6 addresses", () => {
119
+ vi.mocked(networkInterfaces).mockReturnValue({
120
+ eth0: [
121
+ {
122
+ address: "::1",
123
+ family: "IPv6",
124
+ internal: false,
125
+ netmask: "ffff:ffff:ffff:ffff::",
126
+ mac: "00:00:00:00:00:00",
127
+ cidr: "::1/128",
128
+ scopeid: 0,
129
+ },
130
+ ],
131
+ });
132
+ expect(getLocalNetworkIP()).toBe("localhost");
133
+ });
134
+ it("should return localhost when no interfaces", () => {
135
+ vi.mocked(networkInterfaces).mockReturnValue({});
136
+ expect(getLocalNetworkIP()).toBe("localhost");
137
+ });
138
+ });
139
+ describe("discoverServerFunctions", () => {
140
+ beforeEach(() => {
141
+ vi.clearAllMocks();
142
+ });
143
+ it("should map server function files to entry points", () => {
144
+ vi.mocked(globSync).mockReturnValue([
145
+ "src/server-function/hello.ts",
146
+ "src/server-function/utils/helper.ts",
147
+ ]);
148
+ const result = discoverServerFunctions();
149
+ expect(result).toEqual({
150
+ "./hello": "./src/server-function/hello.ts",
151
+ "./utils/helper": "./src/server-function/utils/helper.ts",
152
+ });
153
+ });
154
+ it("should handle backslash paths (Windows)", () => {
155
+ vi.mocked(globSync).mockReturnValue([
156
+ "src\\server-function\\hello.ts",
157
+ ]);
158
+ const result = discoverServerFunctions();
159
+ expect(result).toEqual({
160
+ "./hello": "./src/server-function/hello.ts",
161
+ });
162
+ });
163
+ it("should return empty object when no files", () => {
164
+ vi.mocked(globSync).mockReturnValue([]);
165
+ const result = discoverServerFunctions();
166
+ expect(result).toEqual({});
167
+ });
168
+ });
169
+ describe("normalizeJSDocPropertyName", () => {
170
+ it("should remove brackets and defaults", () => {
171
+ expect(normalizeJSDocPropertyName("[name=default]")).toBe("name");
172
+ });
173
+ it("should handle plain name", () => {
174
+ expect(normalizeJSDocPropertyName("name")).toBe("name");
175
+ });
176
+ it("should return empty for undefined", () => {
177
+ expect(normalizeJSDocPropertyName(undefined)).toBe("");
178
+ });
179
+ it("should handle brackets without default", () => {
180
+ expect(normalizeJSDocPropertyName("[optional]")).toBe("optional");
181
+ });
182
+ it("should trim whitespace", () => {
183
+ expect(normalizeJSDocPropertyName(" name ")).toBe("name");
184
+ });
185
+ });
186
+ describe("getActionType", () => {
187
+ beforeEach(() => {
188
+ vi.spyOn(console, "warn").mockImplementation(() => { });
189
+ });
190
+ it("should return string for 'string'", () => {
191
+ expect(getActionType("string")).toBe("string");
192
+ });
193
+ it("should return number for 'number'", () => {
194
+ expect(getActionType("number")).toBe("number");
195
+ });
196
+ it("should return boolean for 'boolean'", () => {
197
+ expect(getActionType("boolean")).toBe("boolean");
198
+ });
199
+ it("should return object for 'any'", () => {
200
+ expect(getActionType("any")).toBe("object");
201
+ });
202
+ it("should handle arrays", () => {
203
+ expect(getActionType("string[]")).toEqual(["string"]);
204
+ });
205
+ it("should handle nested arrays", () => {
206
+ expect(getActionType("number[][]")).toEqual([["number"]]);
207
+ });
208
+ it("should return undefined for empty string", () => {
209
+ expect(getActionType("")).toBe("undefined");
210
+ });
211
+ it("should return the text and warn for unknown types", () => {
212
+ expect(getActionType("CustomType")).toBe("CustomType");
213
+ expect(console.warn).toHaveBeenCalled();
214
+ });
215
+ });
216
+ describe("isPromiseLikeType", () => {
217
+ it("should return true for Promise type", () => {
218
+ const mockType = {
219
+ getSymbol: () => ({ getName: () => "Promise" }),
220
+ };
221
+ expect(isPromiseLikeType(mockType)).toBe(true);
222
+ });
223
+ it("should return true for PromiseLike type", () => {
224
+ const mockType = {
225
+ getSymbol: () => ({ getName: () => "PromiseLike" }),
226
+ };
227
+ expect(isPromiseLikeType(mockType)).toBe(true);
228
+ });
229
+ it("should return false for other types", () => {
230
+ const mockType = {
231
+ getSymbol: () => ({ getName: () => "Observable" }),
232
+ };
233
+ expect(isPromiseLikeType(mockType)).toBe(false);
234
+ });
235
+ it("should return false when no symbol", () => {
236
+ const mockType = { getSymbol: () => undefined };
237
+ expect(isPromiseLikeType(mockType)).toBe(false);
238
+ });
239
+ });
240
+ describe("unwrapPromiseLikeType", () => {
241
+ it("should unwrap Promise type argument", () => {
242
+ const innerType = { text: "string" };
243
+ const mockType = {
244
+ getSymbol: () => ({ getName: () => "Promise" }),
245
+ getTypeArguments: () => [innerType],
246
+ };
247
+ expect(unwrapPromiseLikeType(mockType)).toBe(innerType);
248
+ });
249
+ it("should return original type if not Promise", () => {
250
+ const mockType = {
251
+ getSymbol: () => ({ getName: () => "Observable" }),
252
+ getTypeArguments: () => [],
253
+ };
254
+ expect(unwrapPromiseLikeType(mockType)).toBe(mockType);
255
+ });
256
+ it("should return original type if Promise has no type args", () => {
257
+ const mockType = {
258
+ getSymbol: () => ({ getName: () => "Promise" }),
259
+ getTypeArguments: () => [],
260
+ };
261
+ expect(unwrapPromiseLikeType(mockType)).toBe(mockType);
262
+ });
263
+ });
264
+ describe("parseTypeDefs", () => {
265
+ it("should parse typedef and property from JSDoc", () => {
266
+ const mockDoc = {
267
+ getFullText: () => `/**
268
+ * @typedef {Object} input
269
+ * @property {string} name - The name of the user
270
+ * @property {number} age - The age
271
+ */`,
272
+ };
273
+ const result = parseTypeDefs([mockDoc]);
274
+ expect(result["input"]).toBeDefined();
275
+ expect(result["input"]["name"]).toEqual({
276
+ type: "string",
277
+ description: "The name of the user",
278
+ });
279
+ expect(result["input"]["age"]).toEqual({
280
+ type: "number",
281
+ description: "The age",
282
+ });
283
+ });
284
+ it("should handle optional properties with brackets", () => {
285
+ const mockDoc = {
286
+ getFullText: () => `/**
287
+ * @typedef {Object} input
288
+ * @property {string} [name=default] - Optional name
289
+ */`,
290
+ };
291
+ const result = parseTypeDefs([mockDoc]);
292
+ expect(result["input"]["name"]).toEqual({
293
+ type: "string",
294
+ description: "Optional name",
295
+ });
296
+ });
297
+ it("should return empty for no typedefs", () => {
298
+ const mockDoc = {
299
+ getFullText: () => `/** Just a comment */`,
300
+ };
301
+ const result = parseTypeDefs([mockDoc]);
302
+ expect(result).toEqual({});
303
+ });
304
+ it("should handle multiple typedefs in multiple docs", () => {
305
+ const mockDoc1 = {
306
+ getFullText: () => `/**
307
+ * @typedef {Object} input
308
+ * @property {string} name - The name
309
+ */`,
310
+ };
311
+ const mockDoc2 = {
312
+ getFullText: () => `/**
313
+ * @typedef {Object} output
314
+ * @property {number} count - The count
315
+ */`,
316
+ };
317
+ const result = parseTypeDefs([mockDoc1, mockDoc2]);
318
+ expect(result["input"]).toBeDefined();
319
+ expect(result["output"]).toBeDefined();
320
+ });
321
+ });
322
+ describe("generateTempTsConfig", () => {
323
+ beforeEach(() => {
324
+ vi.clearAllMocks();
325
+ });
326
+ it("should skip if config already exists", () => {
327
+ vi.mocked(existsSync).mockReturnValue(true);
328
+ generateTempTsConfig();
329
+ expect(writeFileSync).not.toHaveBeenCalled();
330
+ });
331
+ it("should write tsconfig if it does not exist", () => {
332
+ vi.mocked(existsSync).mockReturnValue(false);
333
+ generateTempTsConfig();
334
+ expect(writeFileSync).toHaveBeenCalledWith(expect.stringContaining("tsconfig.server.json"), expect.stringContaining("ES2020"));
335
+ });
336
+ });
337
+ describe("readConfigFile", () => {
338
+ beforeEach(() => {
339
+ vi.clearAllMocks();
340
+ });
341
+ it("should read and parse config file", async () => {
342
+ const fsPromises = (await import("fs/promises")).default;
343
+ vi.mocked(fsPromises.access).mockResolvedValue(undefined);
344
+ vi.mocked(fsPromises.readFile).mockResolvedValue('{"id": "test-app", "version": "1.0.0"}');
345
+ const result = await readConfigFile();
346
+ expect(result).toEqual({ id: "test-app", version: "1.0.0" });
347
+ });
348
+ it("should retry when file does not exist yet", async () => {
349
+ const fsPromises = (await import("fs/promises")).default;
350
+ let callCount = 0;
351
+ vi.mocked(fsPromises.access).mockImplementation(async () => {
352
+ callCount++;
353
+ if (callCount < 3)
354
+ throw new Error("ENOENT");
355
+ });
356
+ vi.mocked(fsPromises.readFile).mockResolvedValue('{"id": "test"}');
357
+ const result = await readConfigFile();
358
+ expect(result).toEqual({ id: "test" });
359
+ expect(callCount).toBe(3);
360
+ });
361
+ });
362
+ describe("discoverAppSkillActions", () => {
363
+ beforeEach(() => {
364
+ vi.clearAllMocks();
365
+ vi.spyOn(console, "warn").mockImplementation(() => { });
366
+ });
367
+ it("should map skill action files to entry points", () => {
368
+ vi.mocked(globSync).mockReturnValue([
369
+ "src/skill/myAction/action.ts",
370
+ ]);
371
+ const result = discoverAppSkillActions();
372
+ expect(result).toEqual({
373
+ "./skill/myAction": "./src/skill/myAction/action.ts",
374
+ });
375
+ });
376
+ it("should return empty object when no skill files", () => {
377
+ vi.mocked(globSync).mockReturnValue([]);
378
+ const result = discoverAppSkillActions();
379
+ expect(result).toEqual({});
380
+ });
381
+ it("should skip files without default export", () => {
382
+ vi.mocked(globSync).mockReturnValue([
383
+ "src/skill/myAction/action.ts",
384
+ ]);
385
+ mockSourceFile.getDefaultExportSymbol.mockReturnValueOnce(null);
386
+ const result = discoverAppSkillActions();
387
+ expect(result).toEqual({});
388
+ });
389
+ });
390
+ describe("compileAppActionSkills", () => {
391
+ beforeEach(() => {
392
+ vi.clearAllMocks();
393
+ vi.spyOn(console, "warn").mockImplementation(() => { });
394
+ });
395
+ it("should compile simple action with no params and no returns", () => {
396
+ vi.mocked(globSync).mockReturnValue([
397
+ "src/skill/testAction/action.ts",
398
+ ]);
399
+ // Reset mockFuncDecl for this test
400
+ mockFuncDecl.getJsDocs.mockReturnValue([
401
+ {
402
+ getDescription: vi.fn().mockReturnValue("A test action"),
403
+ getFullText: vi.fn().mockReturnValue("/** A test action */"),
404
+ },
405
+ ]);
406
+ mockFuncDecl.getParameters.mockReturnValue([]);
407
+ mockFuncDecl.getReturnType.mockReturnValue({
408
+ isObject: vi.fn().mockReturnValue(true),
409
+ getProperties: vi.fn().mockReturnValue([]),
410
+ getSymbol: vi.fn().mockReturnValue(null),
411
+ getTypeArguments: vi.fn().mockReturnValue([]),
412
+ });
413
+ mockFuncDecl.getKind.mockReturnValue(272);
414
+ mockSourceFile.getDescendantsOfKind.mockReturnValue([]);
415
+ const config = {};
416
+ const result = compileAppActionSkills(config);
417
+ expect(result.actions).toHaveLength(1);
418
+ expect(result.actions[0].name).toBe("testAction");
419
+ expect(result.actions[0].description).toContain("test action");
420
+ });
421
+ it("should throw when action has no JSDoc description", () => {
422
+ vi.mocked(globSync).mockReturnValue([
423
+ "src/skill/noDoc/action.ts",
424
+ ]);
425
+ mockFuncDecl.getJsDocs.mockReturnValue([]);
426
+ expect(() => compileAppActionSkills({})).toThrow("missing a JSDoc description");
427
+ });
428
+ it("should skip files without default export", () => {
429
+ vi.mocked(globSync).mockReturnValue([
430
+ "src/skill/noExport/action.ts",
431
+ ]);
432
+ mockSourceFile.getDefaultExportSymbol.mockReturnValueOnce(null);
433
+ const config = {};
434
+ const result = compileAppActionSkills(config);
435
+ expect(result.actions).toHaveLength(0);
436
+ });
437
+ it("should skip when return type is not object", () => {
438
+ vi.mocked(globSync).mockReturnValue([
439
+ "src/skill/testAction/action.ts",
440
+ ]);
441
+ mockFuncDecl.getJsDocs.mockReturnValue([
442
+ {
443
+ getDescription: vi.fn().mockReturnValue("desc"),
444
+ getFullText: vi.fn().mockReturnValue("/** desc */"),
445
+ },
446
+ ]);
447
+ mockFuncDecl.getReturnType.mockReturnValue({
448
+ isObject: vi.fn().mockReturnValue(false),
449
+ getProperties: vi.fn().mockReturnValue([]),
450
+ getSymbol: vi.fn().mockReturnValue(null),
451
+ getTypeArguments: vi.fn().mockReturnValue([]),
452
+ });
453
+ const config = {};
454
+ const result = compileAppActionSkills(config);
455
+ expect(result.actions).toHaveLength(0);
456
+ });
457
+ it("should throw when params exist but no input typedef", () => {
458
+ vi.mocked(globSync).mockReturnValue(["src/skill/testAction/action.ts"]);
459
+ mockFuncDecl.getJsDocs.mockReturnValue([
460
+ {
461
+ getDescription: vi.fn().mockReturnValue("desc"),
462
+ getFullText: vi.fn().mockReturnValue("/** desc */"),
463
+ },
464
+ ]);
465
+ const mockProp = {
466
+ getName: vi.fn().mockReturnValue("name"),
467
+ isOptional: vi.fn().mockReturnValue(false),
468
+ getTypeAtLocation: vi.fn().mockReturnValue({ getText: () => "string" }),
469
+ };
470
+ const mockParam = {
471
+ getNameNode: vi.fn().mockReturnValue({ getElements: vi.fn().mockReturnValue([]) }),
472
+ getType: vi.fn().mockReturnValue({
473
+ getProperties: vi.fn().mockReturnValue([mockProp]),
474
+ }),
475
+ };
476
+ mockFuncDecl.getParameters.mockReturnValue([mockParam]);
477
+ mockSourceFile.getDescendantsOfKind.mockReturnValue([]); // no typedefs
478
+ expect(() => compileAppActionSkills({})).toThrow('missing an');
479
+ });
480
+ it("should compile action with params and input typedef", () => {
481
+ vi.mocked(globSync).mockReturnValue(["src/skill/testAction/action.ts"]);
482
+ const jsdocText = `/**
483
+ * A test action
484
+ * @typedef {Object} input
485
+ * @property {string} name - User name
486
+ */`;
487
+ mockFuncDecl.getJsDocs.mockReturnValue([
488
+ {
489
+ getDescription: vi.fn().mockReturnValue("A test action"),
490
+ getFullText: vi.fn().mockReturnValue(jsdocText),
491
+ },
492
+ ]);
493
+ mockSourceFile.getDescendantsOfKind.mockReturnValue([
494
+ { getFullText: () => jsdocText },
495
+ ]);
496
+ const mockProp = {
497
+ getName: vi.fn().mockReturnValue("name"),
498
+ isOptional: vi.fn().mockReturnValue(false),
499
+ getTypeAtLocation: vi.fn().mockReturnValue({ getText: () => "string" }),
500
+ };
501
+ const mockParam = {
502
+ getNameNode: vi.fn().mockReturnValue({ getElements: vi.fn().mockReturnValue([]) }),
503
+ getType: vi.fn().mockReturnValue({
504
+ getProperties: vi.fn().mockReturnValue([mockProp]),
505
+ }),
506
+ };
507
+ mockFuncDecl.getParameters.mockReturnValue([mockParam]);
508
+ mockFuncDecl.getReturnType.mockReturnValue({
509
+ isObject: vi.fn().mockReturnValue(true),
510
+ getProperties: vi.fn().mockReturnValue([]),
511
+ getSymbol: vi.fn().mockReturnValue(null),
512
+ getTypeArguments: vi.fn().mockReturnValue([]),
513
+ });
514
+ const config = {};
515
+ const result = compileAppActionSkills(config);
516
+ expect(result.actions).toHaveLength(1);
517
+ expect(result.actions[0].parameters).toHaveProperty("name");
518
+ expect(result.actions[0].parameters.name.type).toBe("string");
519
+ });
520
+ it("should throw on missing property in input typedef", () => {
521
+ vi.mocked(globSync).mockReturnValue(["src/skill/testAction/action.ts"]);
522
+ const jsdocText = `/**
523
+ * A test action
524
+ * @typedef {Object} input
525
+ * @property {string} other - Other field
526
+ */`;
527
+ mockFuncDecl.getJsDocs.mockReturnValue([
528
+ {
529
+ getDescription: vi.fn().mockReturnValue("A test action"),
530
+ getFullText: vi.fn().mockReturnValue(jsdocText),
531
+ },
532
+ ]);
533
+ mockSourceFile.getDescendantsOfKind.mockReturnValue([
534
+ { getFullText: () => jsdocText },
535
+ ]);
536
+ const mockProp = {
537
+ getName: vi.fn().mockReturnValue("name"),
538
+ isOptional: vi.fn().mockReturnValue(false),
539
+ };
540
+ const mockParam = {
541
+ getNameNode: vi.fn().mockReturnValue({ getElements: vi.fn().mockReturnValue([]) }),
542
+ getType: vi.fn().mockReturnValue({
543
+ getProperties: vi.fn().mockReturnValue([mockProp]),
544
+ }),
545
+ };
546
+ mockFuncDecl.getParameters.mockReturnValue([mockParam]);
547
+ expect(() => compileAppActionSkills({})).toThrow('missing');
548
+ });
549
+ it("should compile action with return properties and output typedef", () => {
550
+ vi.mocked(globSync).mockReturnValue(["src/skill/testAction/action.ts"]);
551
+ const jsdocText = `/**
552
+ * A test action
553
+ * @typedef {Object} output
554
+ * @property {string} result - The result
555
+ */`;
556
+ mockFuncDecl.getJsDocs.mockReturnValue([
557
+ {
558
+ getDescription: vi.fn().mockReturnValue("A test action"),
559
+ getFullText: vi.fn().mockReturnValue(jsdocText),
560
+ },
561
+ ]);
562
+ mockSourceFile.getDescendantsOfKind.mockReturnValue([
563
+ { getFullText: () => jsdocText },
564
+ ]);
565
+ mockFuncDecl.getParameters.mockReturnValue([]);
566
+ const retProp = {
567
+ getName: vi.fn().mockReturnValue("result"),
568
+ isOptional: vi.fn().mockReturnValue(false),
569
+ getTypeAtLocation: vi.fn().mockReturnValue({ getText: () => "string" }),
570
+ };
571
+ mockFuncDecl.getReturnType.mockReturnValue({
572
+ isObject: vi.fn().mockReturnValue(true),
573
+ getProperties: vi.fn().mockReturnValue([retProp]),
574
+ getSymbol: vi.fn().mockReturnValue(null),
575
+ getTypeArguments: vi.fn().mockReturnValue([]),
576
+ });
577
+ const config = {};
578
+ const result = compileAppActionSkills(config);
579
+ expect(result.actions).toHaveLength(1);
580
+ expect(result.actions[0].returns).toHaveProperty("result");
581
+ });
582
+ it("should throw on return props without output typedef (non-promise)", () => {
583
+ vi.mocked(globSync).mockReturnValue(["src/skill/testAction/action.ts"]);
584
+ mockFuncDecl.getJsDocs.mockReturnValue([
585
+ {
586
+ getDescription: vi.fn().mockReturnValue("desc"),
587
+ getFullText: vi.fn().mockReturnValue("/** desc */"),
588
+ },
589
+ ]);
590
+ mockSourceFile.getDescendantsOfKind.mockReturnValue([]);
591
+ mockFuncDecl.getParameters.mockReturnValue([]);
592
+ const retProp = {
593
+ getName: vi.fn().mockReturnValue("result"),
594
+ isOptional: vi.fn().mockReturnValue(false),
595
+ getTypeAtLocation: vi.fn().mockReturnValue({ getText: () => "string" }),
596
+ };
597
+ mockFuncDecl.getReturnType.mockReturnValue({
598
+ isObject: vi.fn().mockReturnValue(true),
599
+ getProperties: vi.fn().mockReturnValue([retProp]),
600
+ getSymbol: vi.fn().mockReturnValue(null),
601
+ getTypeArguments: vi.fn().mockReturnValue([]),
602
+ });
603
+ expect(() => compileAppActionSkills({})).toThrow('missing');
604
+ });
605
+ it("should warn and fallback for Promise return without output typedef", () => {
606
+ vi.mocked(globSync).mockReturnValue(["src/skill/testAction/action.ts"]);
607
+ mockFuncDecl.getJsDocs.mockReturnValue([
608
+ {
609
+ getDescription: vi.fn().mockReturnValue("desc"),
610
+ getFullText: vi.fn().mockReturnValue("/** desc */"),
611
+ },
612
+ ]);
613
+ mockSourceFile.getDescendantsOfKind.mockReturnValue([]);
614
+ mockFuncDecl.getParameters.mockReturnValue([]);
615
+ const retProp = {
616
+ getName: vi.fn().mockReturnValue("data"),
617
+ isOptional: vi.fn().mockReturnValue(true),
618
+ getTypeAtLocation: vi.fn().mockReturnValue({ getText: () => "string" }),
619
+ };
620
+ mockFuncDecl.getReturnType.mockReturnValue({
621
+ isObject: vi.fn().mockReturnValue(true),
622
+ getProperties: vi.fn().mockReturnValue([retProp]),
623
+ getSymbol: vi.fn().mockReturnValue({ getName: () => "Promise" }),
624
+ getTypeArguments: vi.fn().mockReturnValue([
625
+ {
626
+ isObject: vi.fn().mockReturnValue(true),
627
+ getProperties: vi.fn().mockReturnValue([retProp]),
628
+ getSymbol: vi.fn().mockReturnValue(null),
629
+ getTypeArguments: vi.fn().mockReturnValue([]),
630
+ },
631
+ ]),
632
+ });
633
+ const config = {};
634
+ const result = compileAppActionSkills(config);
635
+ expect(result.actions).toHaveLength(1);
636
+ expect(result.actions[0].returns).toHaveProperty("data");
637
+ expect(result.actions[0].returns.data.optional).toBe(true);
638
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Falling back to TypeScript-inferred"));
639
+ });
640
+ it("should throw on missing output property in output typedef", () => {
641
+ vi.mocked(globSync).mockReturnValue(["src/skill/testAction/action.ts"]);
642
+ const jsdocText = `/**
643
+ * A test action
644
+ * @typedef {Object} output
645
+ * @property {string} other - Other field
646
+ */`;
647
+ mockFuncDecl.getJsDocs.mockReturnValue([
648
+ {
649
+ getDescription: vi.fn().mockReturnValue("desc"),
650
+ getFullText: vi.fn().mockReturnValue(jsdocText),
651
+ },
652
+ ]);
653
+ mockSourceFile.getDescendantsOfKind.mockReturnValue([
654
+ { getFullText: () => jsdocText },
655
+ ]);
656
+ mockFuncDecl.getParameters.mockReturnValue([]);
657
+ const retProp = {
658
+ getName: vi.fn().mockReturnValue("missing"),
659
+ isOptional: vi.fn().mockReturnValue(false),
660
+ getTypeAtLocation: vi.fn().mockReturnValue({ getText: () => "string" }),
661
+ };
662
+ mockFuncDecl.getReturnType.mockReturnValue({
663
+ isObject: vi.fn().mockReturnValue(true),
664
+ getProperties: vi.fn().mockReturnValue([retProp]),
665
+ getSymbol: vi.fn().mockReturnValue(null),
666
+ getTypeArguments: vi.fn().mockReturnValue([]),
667
+ });
668
+ expect(() => compileAppActionSkills({})).toThrow('return property "missing" is missing');
669
+ });
670
+ it("should throw on duplicate action names", () => {
671
+ vi.mocked(globSync).mockReturnValue([
672
+ "src/skill/testAction/action.ts",
673
+ "src/skill/testAction/action.ts",
674
+ ]);
675
+ mockFuncDecl.getJsDocs.mockReturnValue([
676
+ {
677
+ getDescription: vi.fn().mockReturnValue("desc"),
678
+ getFullText: vi.fn().mockReturnValue("/** desc */"),
679
+ },
680
+ ]);
681
+ mockFuncDecl.getParameters.mockReturnValue([]);
682
+ mockFuncDecl.getReturnType.mockReturnValue({
683
+ isObject: vi.fn().mockReturnValue(true),
684
+ getProperties: vi.fn().mockReturnValue([]),
685
+ getSymbol: vi.fn().mockReturnValue(null),
686
+ getTypeArguments: vi.fn().mockReturnValue([]),
687
+ });
688
+ mockSourceFile.getDescendantsOfKind.mockReturnValue([]);
689
+ expect(() => compileAppActionSkills({})).toThrow('Duplicate action name');
690
+ });
691
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulse-editor/cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "pulse": "dist/cli.js"
@@ -12,7 +12,8 @@
12
12
  "scripts": {
13
13
  "build": "tsx build.ts",
14
14
  "dev": "tsx watch --include \"./src/**/*\" build.ts",
15
- "test": "prettier --check . && xo && ava",
15
+ "test": "vitest run",
16
+ "test:coverage": "vitest run --coverage",
16
17
  "link": "npm link",
17
18
  "pack": "npm run build && npm pack --pack-destination dist"
18
19
  },
@@ -58,6 +59,7 @@
58
59
  "@types/livereload": "^0.9.5",
59
60
  "@types/react": "^19.2.14",
60
61
  "@types/react-dom": "^19.2.3",
62
+ "@vitest/coverage-v8": "^4.1.2",
61
63
  "ava": "^6.4.1",
62
64
  "chalk": "^5.6.2",
63
65
  "eslint-config-xo-react": "^0.29.0",
@@ -68,6 +70,7 @@
68
70
  "react-dom": "19.2.4",
69
71
  "ts-node": "^10.9.2",
70
72
  "typescript": "^5.9.3",
73
+ "vitest": "^4.1.2",
71
74
  "xo": "^1.2.3"
72
75
  },
73
76
  "peerDependencies": {