🚀 Hello World
This commit is contained in:
commit
0172c07623
8 changed files with 11896 additions and 0 deletions
80
test/cli.test.ts
Normal file
80
test/cli.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
interface ExecSyncError extends Error {
|
||||
stdout: Buffer;
|
||||
stderr: Buffer;
|
||||
status: number | null;
|
||||
signal: string | null;
|
||||
}
|
||||
|
||||
describe("DXT CLI", () => {
|
||||
const cliPath = join(__dirname, "../dist/cli/cli.js");
|
||||
const validManifestPath = join(__dirname, "valid-manifest.json");
|
||||
const invalidManifestPath = join(__dirname, "invalid-manifest.json");
|
||||
|
||||
beforeAll(() => {
|
||||
// Ensure the CLI is built
|
||||
execSync("yarn build", { cwd: join(__dirname, "..") });
|
||||
});
|
||||
|
||||
it("should validate a valid manifest", () => {
|
||||
const result = execSync(`node ${cliPath} validate ${validManifestPath}`, {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
expect(result).toContain("Manifest is valid!");
|
||||
});
|
||||
|
||||
it("should reject an invalid manifest", () => {
|
||||
expect(() => {
|
||||
execSync(`node ${cliPath} validate ${invalidManifestPath}`, {
|
||||
encoding: "utf-8",
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("should show usage when no arguments provided", () => {
|
||||
try {
|
||||
execSync(`node ${cliPath}`, { encoding: "utf-8", stdio: "pipe" });
|
||||
} catch (error) {
|
||||
// Commander outputs help to stderr when no command is provided
|
||||
const execError = error as ExecSyncError;
|
||||
expect(execError.stderr.toString()).toContain(
|
||||
"Usage: dxt [options] [command]",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle non-existent files", () => {
|
||||
try {
|
||||
execSync(`node ${cliPath} validate /non/existent/file.json`, {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
fail("Should have thrown an error");
|
||||
} catch (error) {
|
||||
const execError = error as ExecSyncError;
|
||||
expect(execError.stderr.toString()).toContain("ERROR: File not found");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle invalid JSON", () => {
|
||||
const invalidJsonPath = join(__dirname, "invalid-json.json");
|
||||
fs.writeFileSync(invalidJsonPath, "{ invalid json }");
|
||||
|
||||
try {
|
||||
execSync(`node ${cliPath} validate ${invalidJsonPath}`, {
|
||||
encoding: "utf-8",
|
||||
stdio: "pipe",
|
||||
});
|
||||
fail("Should have thrown an error");
|
||||
} catch (error) {
|
||||
const execError = error as ExecSyncError;
|
||||
expect(execError.stderr.toString()).toContain("ERROR: Invalid JSON");
|
||||
}
|
||||
|
||||
// Clean up
|
||||
fs.unlinkSync(invalidJsonPath);
|
||||
});
|
||||
});
|
||||
483
test/config.test.ts
Normal file
483
test/config.test.ts
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
import {
|
||||
getMcpConfigForManifest,
|
||||
hasRequiredConfigMissing,
|
||||
replaceVariables,
|
||||
} from "../src/shared/config";
|
||||
import type { DxtManifest, Logger } from "../src/types";
|
||||
|
||||
describe("replaceVariables", () => {
|
||||
it("should replace variables in strings", () => {
|
||||
const result = replaceVariables("Hello ${name}!", { name: "World" });
|
||||
expect(result).toBe("Hello World!");
|
||||
});
|
||||
|
||||
it("should replace multiple variables in strings", () => {
|
||||
const result = replaceVariables("${greeting} ${name}!", {
|
||||
greeting: "Hello",
|
||||
name: "World",
|
||||
});
|
||||
expect(result).toBe("Hello World!");
|
||||
});
|
||||
|
||||
it("should warn and not replace arrays in string context", () => {
|
||||
const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
|
||||
const result = replaceVariables("Hello ${names}!", {
|
||||
names: ["Alice", "Bob"],
|
||||
});
|
||||
expect(result).toBe("Hello ${names}!");
|
||||
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||
consoleWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should replace variables in objects", () => {
|
||||
const result = replaceVariables(
|
||||
{ message: "Hello ${name}!" },
|
||||
{ name: "World" },
|
||||
);
|
||||
expect(result).toEqual({ message: "Hello World!" });
|
||||
});
|
||||
|
||||
it("should handle user config variables in arrays with string expansion", () => {
|
||||
const result = replaceVariables(["${user_config.path}"], {
|
||||
"user_config.path": "/some/path",
|
||||
});
|
||||
expect(result).toEqual(["/some/path"]);
|
||||
});
|
||||
|
||||
it("should handle user config variables in arrays with array expansion", () => {
|
||||
const result = replaceVariables(["${user_config.paths}"], {
|
||||
"user_config.paths": ["/path1", "/path2"],
|
||||
});
|
||||
expect(result).toEqual(["/path1", "/path2"]);
|
||||
});
|
||||
|
||||
it("should recursively process arrays", () => {
|
||||
const result = replaceVariables(["Hello ${name}!", "Goodbye ${name}!"], {
|
||||
name: "World",
|
||||
});
|
||||
expect(result).toEqual(["Hello World!", "Goodbye World!"]);
|
||||
});
|
||||
|
||||
it("should handle mixed arrays with variable expansion", () => {
|
||||
const result = replaceVariables(["start", "${user_config.items}", "end"], {
|
||||
"user_config.items": ["item1", "item2"],
|
||||
});
|
||||
expect(result).toEqual(["start", "item1", "item2", "end"]);
|
||||
});
|
||||
|
||||
it("should return primitive values unchanged", () => {
|
||||
expect(replaceVariables(42, {})).toBe(42);
|
||||
expect(replaceVariables(true, {})).toBe(true);
|
||||
expect(replaceVariables(null, {})).toBe(null);
|
||||
});
|
||||
|
||||
it("should handle undefined variables by keeping them as-is", () => {
|
||||
const result = replaceVariables("Hello ${unknown}!", {});
|
||||
expect(result).toBe("Hello ${unknown}!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMcpConfigForManifest", () => {
|
||||
const mockLogger: Logger = {
|
||||
log: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSystemDirs = {
|
||||
home: "/home/user",
|
||||
data: "/data",
|
||||
};
|
||||
|
||||
const baseManifest: DxtManifest = {
|
||||
dxt_version: "1.0.0",
|
||||
name: "test-extension",
|
||||
version: "1.0.0",
|
||||
description: "Test extension",
|
||||
author: { name: "Test Author" },
|
||||
server: {
|
||||
type: "node",
|
||||
entry_point: "server.js",
|
||||
mcp_config: {
|
||||
command: "node",
|
||||
args: ["server.js", "${__dirname}"],
|
||||
},
|
||||
},
|
||||
compatibility: { claude_desktop: "0.1.0" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return undefined when manifest has no mcp_config", async () => {
|
||||
const manifest = {
|
||||
...baseManifest,
|
||||
server: undefined,
|
||||
} as unknown as DxtManifest;
|
||||
|
||||
const result = await getMcpConfigForManifest({
|
||||
manifest,
|
||||
extensionPath: "/ext/path",
|
||||
systemDirs: mockSystemDirs,
|
||||
userConfig: {},
|
||||
pathSeparator: "/",
|
||||
logger: mockLogger,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined when required config is missing", async () => {
|
||||
const manifest: DxtManifest = {
|
||||
...baseManifest,
|
||||
user_config: {
|
||||
apiKey: {
|
||||
type: "string",
|
||||
title: "API Key",
|
||||
description: "API Key",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getMcpConfigForManifest({
|
||||
manifest,
|
||||
extensionPath: "/ext/path",
|
||||
systemDirs: mockSystemDirs,
|
||||
userConfig: {},
|
||||
pathSeparator: "/",
|
||||
logger: mockLogger,
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockLogger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should replace variables in basic config", async () => {
|
||||
const result = await getMcpConfigForManifest({
|
||||
manifest: baseManifest,
|
||||
extensionPath: "/ext/path",
|
||||
systemDirs: mockSystemDirs,
|
||||
userConfig: {},
|
||||
pathSeparator: "/",
|
||||
logger: mockLogger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
command: "node",
|
||||
args: ["server.js", "/ext/path"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should apply platform overrides", async () => {
|
||||
const manifest: DxtManifest = {
|
||||
...baseManifest,
|
||||
server: {
|
||||
type: "node",
|
||||
entry_point: "server.js",
|
||||
mcp_config: {
|
||||
command: "default-command",
|
||||
args: ["default"],
|
||||
platform_overrides: {
|
||||
[process.platform]: {
|
||||
command: "platform-command",
|
||||
args: ["platform-specific"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getMcpConfigForManifest({
|
||||
manifest,
|
||||
extensionPath: "/ext/path",
|
||||
systemDirs: mockSystemDirs,
|
||||
userConfig: {},
|
||||
pathSeparator: "/",
|
||||
logger: mockLogger,
|
||||
});
|
||||
|
||||
expect(result?.command).toBe("platform-command");
|
||||
expect(result?.args).toEqual(["platform-specific"]);
|
||||
});
|
||||
|
||||
it("should handle user config variable replacement with defaults", async () => {
|
||||
const manifest: DxtManifest = {
|
||||
...baseManifest,
|
||||
user_config: {
|
||||
port: {
|
||||
type: "number",
|
||||
title: "Port Number",
|
||||
description: "Port number",
|
||||
default: 8080,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
type: "node",
|
||||
entry_point: "server.js",
|
||||
mcp_config: {
|
||||
command: "node",
|
||||
args: ["server.js", "--port=${user_config.port}"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getMcpConfigForManifest({
|
||||
manifest,
|
||||
extensionPath: "/ext/path",
|
||||
systemDirs: mockSystemDirs,
|
||||
userConfig: {},
|
||||
pathSeparator: "/",
|
||||
logger: mockLogger,
|
||||
});
|
||||
|
||||
expect(result?.args).toEqual(["server.js", "--port=8080"]);
|
||||
});
|
||||
|
||||
it("should handle user config variable replacement with user values", async () => {
|
||||
const manifest: DxtManifest = {
|
||||
...baseManifest,
|
||||
user_config: {
|
||||
paths: {
|
||||
type: "string",
|
||||
title: "File Paths",
|
||||
description: "File paths",
|
||||
default: ["/default"],
|
||||
multiple: true,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
type: "node",
|
||||
entry_point: "server.js",
|
||||
mcp_config: {
|
||||
command: "node",
|
||||
args: ["server.js", "${user_config.paths}"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getMcpConfigForManifest({
|
||||
manifest,
|
||||
extensionPath: "/ext/path",
|
||||
systemDirs: mockSystemDirs,
|
||||
userConfig: { paths: ["/user/path1", "/user/path2"] },
|
||||
pathSeparator: "/",
|
||||
logger: mockLogger,
|
||||
});
|
||||
|
||||
expect(result?.args).toEqual(["server.js", "/user/path1", "/user/path2"]);
|
||||
});
|
||||
|
||||
it("should convert boolean user config values to strings", async () => {
|
||||
const manifest: DxtManifest = {
|
||||
...baseManifest,
|
||||
user_config: {
|
||||
verbose: {
|
||||
type: "boolean",
|
||||
title: "Verbose Mode",
|
||||
description: "Verbose output",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
type: "node",
|
||||
entry_point: "server.js",
|
||||
mcp_config: {
|
||||
command: "node",
|
||||
args: ["server.js", "--verbose=${user_config.verbose}"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getMcpConfigForManifest({
|
||||
manifest,
|
||||
extensionPath: "/ext/path",
|
||||
systemDirs: mockSystemDirs,
|
||||
userConfig: { verbose: true },
|
||||
pathSeparator: "/",
|
||||
logger: mockLogger,
|
||||
});
|
||||
|
||||
expect(result?.args).toEqual(["server.js", "--verbose=true"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasRequiredConfigMissing", () => {
|
||||
const baseManifest: DxtManifest = {
|
||||
dxt_version: "1.0.0",
|
||||
name: "test-extension",
|
||||
version: "1.0.0",
|
||||
description: "Test extension",
|
||||
author: { name: "Test Author" },
|
||||
server: {
|
||||
type: "node",
|
||||
entry_point: "server.js",
|
||||
mcp_config: {
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("should return false when manifest has no user_config", () => {
|
||||
const result = hasRequiredConfigMissing({
|
||||
manifest: baseManifest,
|
||||
userConfig: {},
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when no config fields are required", () => {
|
||||
const manifest: DxtManifest = {
|
||||
...baseManifest,
|
||||
user_config: {
|
||||
port: {
|
||||
type: "number",
|
||||
title: "Port",
|
||||
description: "Port number",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = hasRequiredConfigMissing({
|
||||
manifest,
|
||||
userConfig: {},
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when required config is provided", () => {
|
||||
const manifest: DxtManifest = {
|
||||
...baseManifest,
|
||||
user_config: {
|
||||
apiKey: {
|
||||
type: "string",
|
||||
title: "API Key",
|
||||
description: "API Key",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = hasRequiredConfigMissing({
|
||||
manifest,
|
||||
userConfig: { apiKey: "test-key" },
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when required config is undefined", () => {
|
||||
const manifest: DxtManifest = {
|
||||
...baseManifest,
|
||||
user_config: {
|
||||
apiKey: {
|
||||
type: "string",
|
||||
title: "API Key",
|
||||
description: "API Key",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = hasRequiredConfigMissing({
|
||||
manifest,
|
||||
userConfig: {},
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when required config is empty string", () => {
|
||||
const manifest: DxtManifest = {
|
||||
...baseManifest,
|
||||
user_config: {
|
||||
apiKey: {
|
||||
type: "string",
|
||||
title: "API Key",
|
||||
description: "API Key",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = hasRequiredConfigMissing({
|
||||
manifest,
|
||||
userConfig: { apiKey: "" },
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when required config is empty array", () => {
|
||||
const manifest: DxtManifest = {
|
||||
...baseManifest,
|
||||
user_config: {
|
||||
paths: {
|
||||
type: "string",
|
||||
title: "Paths",
|
||||
description: "File paths",
|
||||
required: true,
|
||||
multiple: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = hasRequiredConfigMissing({
|
||||
manifest,
|
||||
userConfig: { paths: [] },
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when no user config is provided but nothing is required", () => {
|
||||
const result = hasRequiredConfigMissing({
|
||||
manifest: baseManifest,
|
||||
// userConfig not provided
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle multiple required config fields", () => {
|
||||
const manifest: DxtManifest = {
|
||||
...baseManifest,
|
||||
user_config: {
|
||||
apiKey: {
|
||||
type: "string",
|
||||
title: "API Key",
|
||||
description: "API Key",
|
||||
required: true,
|
||||
},
|
||||
database: {
|
||||
type: "string",
|
||||
title: "Database",
|
||||
description: "Database connection",
|
||||
required: true,
|
||||
},
|
||||
port: {
|
||||
type: "number",
|
||||
title: "Port",
|
||||
description: "Port number",
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resultMissingBoth = hasRequiredConfigMissing({
|
||||
manifest,
|
||||
userConfig: {},
|
||||
});
|
||||
expect(resultMissingBoth).toBe(true);
|
||||
|
||||
const resultMissingOne = hasRequiredConfigMissing({
|
||||
manifest,
|
||||
userConfig: { apiKey: "test-key" },
|
||||
});
|
||||
expect(resultMissingOne).toBe(true);
|
||||
|
||||
const resultHasBoth = hasRequiredConfigMissing({
|
||||
manifest,
|
||||
userConfig: { apiKey: "test-key", database: "test-db" },
|
||||
});
|
||||
expect(resultHasBoth).toBe(false);
|
||||
});
|
||||
});
|
||||
261
test/dxtignore.test.ts
Normal file
261
test/dxtignore.test.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
|
||||
import {
|
||||
getAllFiles,
|
||||
readDxtIgnorePatterns,
|
||||
shouldExclude,
|
||||
} from "../src/node/files.js";
|
||||
|
||||
describe("DxtIgnore functionality", () => {
|
||||
let tempDir: string;
|
||||
let dxtIgnorePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a temp directory for each test
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dxt-test-"));
|
||||
dxtIgnorePath = path.join(tempDir, ".dxtignore");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory after each test
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("readDxtIgnorePatterns", () => {
|
||||
it("should return empty array when .dxtignore doesn't exist", () => {
|
||||
const patterns = readDxtIgnorePatterns(tempDir);
|
||||
expect(patterns).toEqual([]);
|
||||
});
|
||||
|
||||
it("should read patterns from .dxtignore file", () => {
|
||||
const content = `*.log
|
||||
node_modules/
|
||||
temp/
|
||||
.env`;
|
||||
fs.writeFileSync(dxtIgnorePath, content);
|
||||
|
||||
const patterns = readDxtIgnorePatterns(tempDir);
|
||||
expect(patterns).toEqual(["*.log", "node_modules/", "temp/", ".env"]);
|
||||
});
|
||||
|
||||
it("should ignore empty lines and comments", () => {
|
||||
const content = `# Comment line
|
||||
*.log
|
||||
|
||||
# Another comment
|
||||
node_modules/
|
||||
|
||||
.env`;
|
||||
fs.writeFileSync(dxtIgnorePath, content);
|
||||
|
||||
const patterns = readDxtIgnorePatterns(tempDir);
|
||||
expect(patterns).toEqual(["*.log", "node_modules/", ".env"]);
|
||||
});
|
||||
|
||||
it("should trim whitespace from lines", () => {
|
||||
const content = ` *.log
|
||||
node_modules/
|
||||
temp/`;
|
||||
fs.writeFileSync(dxtIgnorePath, content);
|
||||
|
||||
const patterns = readDxtIgnorePatterns(tempDir);
|
||||
expect(patterns).toEqual(["*.log", "node_modules/", "temp/"]);
|
||||
});
|
||||
|
||||
it("should handle Windows line endings", () => {
|
||||
const content = `*.log\r\nnode_modules/\r\ntemp/`;
|
||||
fs.writeFileSync(dxtIgnorePath, content);
|
||||
|
||||
const patterns = readDxtIgnorePatterns(tempDir);
|
||||
expect(patterns).toEqual(["*.log", "node_modules/", "temp/"]);
|
||||
});
|
||||
|
||||
it("should return empty array if file cannot be read", () => {
|
||||
const content = "*.log";
|
||||
fs.writeFileSync(dxtIgnorePath, content);
|
||||
fs.chmodSync(dxtIgnorePath, 0); // Make file unreadable
|
||||
|
||||
const patterns = readDxtIgnorePatterns(tempDir);
|
||||
expect(patterns).toEqual([]);
|
||||
|
||||
// Restore permissions for cleanup
|
||||
fs.chmodSync(dxtIgnorePath, 0o644);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldExclude with additional patterns", () => {
|
||||
it("should exclude files matching additional patterns", () => {
|
||||
const additionalPatterns = ["*.test.js", "coverage/"];
|
||||
|
||||
expect(shouldExclude("test.test.js", additionalPatterns)).toBe(true);
|
||||
expect(shouldExclude("coverage/file.txt", additionalPatterns)).toBe(true);
|
||||
expect(shouldExclude("src/index.js", additionalPatterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("should still exclude default patterns", () => {
|
||||
const additionalPatterns = ["*.test.js"];
|
||||
|
||||
// Test default pattern
|
||||
expect(shouldExclude(".DS_Store", additionalPatterns)).toBe(true);
|
||||
// Test additional pattern
|
||||
expect(shouldExclude("test.test.js", additionalPatterns)).toBe(true);
|
||||
// Test non-excluded file
|
||||
expect(shouldExclude("src/index.js", additionalPatterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle glob patterns correctly", () => {
|
||||
const additionalPatterns = ["*.log", "temp/*"];
|
||||
|
||||
expect(shouldExclude("debug.log", additionalPatterns)).toBe(true);
|
||||
expect(shouldExclude("temp/file.txt", additionalPatterns)).toBe(true);
|
||||
expect(shouldExclude("src/main.js", additionalPatterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle exact path matches", () => {
|
||||
const additionalPatterns = ["node_modules", "dist"];
|
||||
|
||||
expect(shouldExclude("node_modules", additionalPatterns)).toBe(true);
|
||||
expect(shouldExclude("dist", additionalPatterns)).toBe(true);
|
||||
expect(shouldExclude("src/node_modules", additionalPatterns)).toBe(true);
|
||||
expect(shouldExclude("src", additionalPatterns)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllFiles with .dxtignore", () => {
|
||||
let testStructure: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create test directory structure
|
||||
testStructure = path.join(tempDir, "test-extension");
|
||||
fs.mkdirSync(testStructure);
|
||||
fs.mkdirSync(path.join(testStructure, "src"));
|
||||
fs.mkdirSync(path.join(testStructure, "tests"));
|
||||
fs.mkdirSync(path.join(testStructure, "coverage"));
|
||||
fs.mkdirSync(path.join(testStructure, "logs"));
|
||||
|
||||
// Create test files
|
||||
fs.writeFileSync(path.join(testStructure, "manifest.json"), "{}");
|
||||
fs.writeFileSync(
|
||||
path.join(testStructure, "src/index.js"),
|
||||
"console.log('hi')",
|
||||
);
|
||||
fs.writeFileSync(path.join(testStructure, "tests/test.js"), "test()");
|
||||
fs.writeFileSync(
|
||||
path.join(testStructure, "coverage/report.txt"),
|
||||
"coverage",
|
||||
);
|
||||
fs.writeFileSync(path.join(testStructure, "logs/debug.log"), "log");
|
||||
fs.writeFileSync(path.join(testStructure, "debug.log"), "log");
|
||||
});
|
||||
|
||||
it("should exclude files matching .dxtignore patterns", () => {
|
||||
// Create .dxtignore
|
||||
const dxtIgnoreContent = `*.log
|
||||
tests/
|
||||
coverage/`;
|
||||
fs.writeFileSync(
|
||||
path.join(testStructure, ".dxtignore"),
|
||||
dxtIgnoreContent,
|
||||
);
|
||||
|
||||
const patterns = readDxtIgnorePatterns(testStructure);
|
||||
const files = getAllFiles(testStructure, testStructure, {}, patterns);
|
||||
|
||||
const fileNames = Object.keys(files);
|
||||
|
||||
// Should include
|
||||
expect(fileNames).toContain("manifest.json");
|
||||
expect(fileNames).toContain("src/index.js");
|
||||
|
||||
// Should exclude
|
||||
expect(fileNames).not.toContain("debug.log");
|
||||
expect(fileNames).not.toContain("logs/debug.log");
|
||||
expect(fileNames).not.toContain("tests/test.js");
|
||||
expect(fileNames).not.toContain("coverage/report.txt");
|
||||
expect(fileNames).not.toContain(".dxtignore");
|
||||
});
|
||||
|
||||
it("should exclude both default patterns and .dxtignore patterns", () => {
|
||||
// Create .dxtignore
|
||||
const dxtIgnoreContent = `tests/`;
|
||||
fs.writeFileSync(
|
||||
path.join(testStructure, ".dxtignore"),
|
||||
dxtIgnoreContent,
|
||||
);
|
||||
|
||||
// Create a .git file (default exclusion)
|
||||
fs.writeFileSync(path.join(testStructure, ".gitignore"), "ignore");
|
||||
|
||||
const patterns = readDxtIgnorePatterns(testStructure);
|
||||
const files = getAllFiles(testStructure, testStructure, {}, patterns);
|
||||
|
||||
const fileNames = Object.keys(files);
|
||||
|
||||
// Should include
|
||||
expect(fileNames).toContain("manifest.json");
|
||||
expect(fileNames).toContain("src/index.js");
|
||||
|
||||
// Should exclude (from .dxtignore)
|
||||
expect(fileNames).not.toContain("tests/test.js");
|
||||
|
||||
// Should exclude (from default patterns)
|
||||
expect(fileNames).not.toContain(".gitignore");
|
||||
});
|
||||
|
||||
it("should work without .dxtignore file", () => {
|
||||
const files = getAllFiles(testStructure, testStructure, {}, []);
|
||||
const fileNames = Object.keys(files);
|
||||
|
||||
// Should include all files except default exclusions
|
||||
expect(fileNames).toContain("manifest.json");
|
||||
expect(fileNames).toContain("src/index.js");
|
||||
expect(fileNames).toContain("tests/test.js");
|
||||
expect(fileNames).toContain("coverage/report.txt");
|
||||
// Note: *.log files are excluded by default, so these won't be included
|
||||
expect(fileNames).not.toContain("logs/debug.log");
|
||||
expect(fileNames).not.toContain("debug.log");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pattern matching edge cases", () => {
|
||||
it("should handle nested directories", () => {
|
||||
const additionalPatterns = ["node_modules/"];
|
||||
|
||||
expect(
|
||||
shouldExclude("node_modules/package/index.js", additionalPatterns),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldExclude("src/node_modules/package/index.js", additionalPatterns),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle paths with directory patterns", () => {
|
||||
const additionalPatterns = ["tests/"];
|
||||
|
||||
expect(shouldExclude("tests/unit/test.js", additionalPatterns)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
shouldExclude("tests/integration/test.js", additionalPatterns),
|
||||
).toBe(true);
|
||||
// Note: "tests" appears anywhere in path due to current implementation
|
||||
expect(shouldExclude("src/tests/test.js", additionalPatterns)).toBe(true);
|
||||
// But paths without "tests" should not be excluded
|
||||
expect(shouldExclude("src/main.js", additionalPatterns)).toBe(false);
|
||||
});
|
||||
|
||||
it("should match patterns (implementation details)", () => {
|
||||
const additionalPatterns = ["specific-file.LOG"];
|
||||
|
||||
// Test that the pattern is matched
|
||||
expect(shouldExclude("specific-file.LOG", additionalPatterns)).toBe(true);
|
||||
|
||||
// Test completely different file name
|
||||
expect(shouldExclude("totally-different.txt", additionalPatterns)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
363
test/init.test.ts
Normal file
363
test/init.test.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import { existsSync, readFileSync } from "fs";
|
||||
|
||||
import {
|
||||
buildManifest,
|
||||
createMcpConfig,
|
||||
getDefaultAuthorEmail,
|
||||
getDefaultAuthorName,
|
||||
getDefaultAuthorUrl,
|
||||
getDefaultEntryPoint,
|
||||
getDefaultRepositoryUrl,
|
||||
readPackageJson,
|
||||
} from "../src/cli/init.js";
|
||||
|
||||
// Mock the fs module
|
||||
jest.mock("fs", () => ({
|
||||
existsSync: jest.fn(),
|
||||
readFileSync: jest.fn(),
|
||||
writeFileSync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("init functions", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("readPackageJson", () => {
|
||||
it("should return empty object when package.json doesn't exist", () => {
|
||||
(existsSync as jest.Mock).mockReturnValue(false);
|
||||
const result = readPackageJson("/test/path");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("should parse package.json when it exists", () => {
|
||||
(existsSync as jest.Mock).mockReturnValue(true);
|
||||
const mockPackageData = { name: "test-package", version: "1.0.0" };
|
||||
(readFileSync as jest.Mock).mockReturnValue(
|
||||
JSON.stringify(mockPackageData),
|
||||
);
|
||||
|
||||
const result = readPackageJson("/test/path");
|
||||
expect(result).toEqual(mockPackageData);
|
||||
});
|
||||
|
||||
it("should return empty object when package.json is invalid", () => {
|
||||
(existsSync as jest.Mock).mockReturnValue(true);
|
||||
(readFileSync as jest.Mock).mockReturnValue("invalid json");
|
||||
|
||||
const result = readPackageJson("/test/path");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultAuthorName", () => {
|
||||
it("should return string author when author is string", () => {
|
||||
const result = getDefaultAuthorName({ author: "John Doe" });
|
||||
expect(result).toBe("John Doe");
|
||||
});
|
||||
|
||||
it("should return author.name when author is object", () => {
|
||||
const result = getDefaultAuthorName({ author: { name: "John Doe" } });
|
||||
expect(result).toBe("John Doe");
|
||||
});
|
||||
|
||||
it("should return empty string when no author", () => {
|
||||
const result = getDefaultAuthorName({});
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultAuthorEmail", () => {
|
||||
it("should return author.email when author is object with email", () => {
|
||||
const result = getDefaultAuthorEmail({
|
||||
author: { email: "john@example.com" },
|
||||
});
|
||||
expect(result).toBe("john@example.com");
|
||||
});
|
||||
|
||||
it("should return empty string when author is string", () => {
|
||||
const result = getDefaultAuthorEmail({ author: "John Doe" });
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty string when no email", () => {
|
||||
const result = getDefaultAuthorEmail({ author: { name: "John" } });
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultAuthorUrl", () => {
|
||||
it("should return author.url when author is object with url", () => {
|
||||
const result = getDefaultAuthorUrl({
|
||||
author: { url: "https://example.com" },
|
||||
});
|
||||
expect(result).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("should return empty string when author is string", () => {
|
||||
const result = getDefaultAuthorUrl({ author: "John Doe" });
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty string when no url", () => {
|
||||
const result = getDefaultAuthorUrl({ author: { name: "John" } });
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultRepositoryUrl", () => {
|
||||
it("should return string repository when repository is string", () => {
|
||||
const result = getDefaultRepositoryUrl({
|
||||
repository: "https://github.com/user/repo",
|
||||
});
|
||||
expect(result).toBe("https://github.com/user/repo");
|
||||
});
|
||||
|
||||
it("should return repository.url when repository is object", () => {
|
||||
const result = getDefaultRepositoryUrl({
|
||||
repository: { type: "git", url: "https://github.com/user/repo" },
|
||||
});
|
||||
expect(result).toBe("https://github.com/user/repo");
|
||||
});
|
||||
|
||||
it("should return empty string when no repository", () => {
|
||||
const result = getDefaultRepositoryUrl({});
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMcpConfig", () => {
|
||||
it("should create node config", () => {
|
||||
const result = createMcpConfig("node", "server/index.js");
|
||||
expect(result).toEqual({
|
||||
command: "node",
|
||||
args: ["${__dirname}/server/index.js"],
|
||||
env: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create python config", () => {
|
||||
const result = createMcpConfig("python", "server/main.py");
|
||||
expect(result).toEqual({
|
||||
command: "python",
|
||||
args: ["${__dirname}/server/main.py"],
|
||||
env: {
|
||||
PYTHONPATH: "server/lib",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create binary config", () => {
|
||||
const result = createMcpConfig("binary", "server/my-server");
|
||||
expect(result).toEqual({
|
||||
command: "${__dirname}/server/my-server",
|
||||
args: [],
|
||||
env: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultEntryPoint", () => {
|
||||
it("should return node default", () => {
|
||||
expect(getDefaultEntryPoint("node")).toBe("server/index.js");
|
||||
});
|
||||
|
||||
it("should return python default", () => {
|
||||
expect(getDefaultEntryPoint("python")).toBe("server/main.py");
|
||||
});
|
||||
|
||||
it("should return binary default", () => {
|
||||
expect(getDefaultEntryPoint("binary")).toBe("server/my-server");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildManifest", () => {
|
||||
it("should build minimal manifest", () => {
|
||||
const manifest = buildManifest(
|
||||
{
|
||||
name: "test-extension",
|
||||
displayName: "test-extension",
|
||||
version: "1.0.0",
|
||||
description: "Test description",
|
||||
authorName: "John Doe",
|
||||
},
|
||||
undefined, // longDescription
|
||||
{
|
||||
authorEmail: "",
|
||||
authorUrl: "",
|
||||
},
|
||||
{
|
||||
homepage: "",
|
||||
documentation: "",
|
||||
support: "",
|
||||
},
|
||||
{
|
||||
icon: "",
|
||||
screenshots: [],
|
||||
},
|
||||
{
|
||||
serverType: "node",
|
||||
entryPoint: "server/index.js",
|
||||
mcp_config: {
|
||||
command: "node",
|
||||
args: ["${__dirname}/server/index.js"],
|
||||
env: {},
|
||||
},
|
||||
},
|
||||
[], // tools
|
||||
false, // toolsGenerated
|
||||
[], // prompts
|
||||
false, // promptsGenerated
|
||||
undefined, // compatibility
|
||||
{}, // userConfig
|
||||
{
|
||||
keywords: "",
|
||||
license: "",
|
||||
},
|
||||
);
|
||||
|
||||
expect(manifest).toEqual({
|
||||
dxt_version: "0.1",
|
||||
name: "test-extension",
|
||||
version: "1.0.0",
|
||||
description: "Test description",
|
||||
author: {
|
||||
name: "John Doe",
|
||||
},
|
||||
server: {
|
||||
type: "node",
|
||||
entry_point: "server/index.js",
|
||||
mcp_config: {
|
||||
command: "node",
|
||||
args: ["${__dirname}/server/index.js"],
|
||||
env: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should include optional fields when provided", () => {
|
||||
const manifest = buildManifest(
|
||||
{
|
||||
name: "test-extension",
|
||||
displayName: "Test Extension",
|
||||
version: "1.0.0",
|
||||
description: "Test description",
|
||||
authorName: "John Doe",
|
||||
},
|
||||
"This is a detailed long description with more information.", // longDescription
|
||||
{
|
||||
authorEmail: "john@example.com",
|
||||
authorUrl: "https://example.com",
|
||||
},
|
||||
{
|
||||
homepage: "https://homepage.example.com",
|
||||
documentation: "https://docs.example.com",
|
||||
support: "https://support.example.com",
|
||||
},
|
||||
{
|
||||
icon: "icon.png",
|
||||
screenshots: ["screen1.png", "screen2.png"],
|
||||
},
|
||||
{
|
||||
serverType: "python",
|
||||
entryPoint: "server/main.py",
|
||||
mcp_config: {
|
||||
command: "python",
|
||||
args: ["server/main.py"],
|
||||
env: { PYTHONPATH: "server/lib" },
|
||||
},
|
||||
},
|
||||
[
|
||||
{ name: "tool1", description: "Tool 1 description" },
|
||||
{ name: "tool2" },
|
||||
], // tools
|
||||
false, // toolsGenerated
|
||||
[
|
||||
{
|
||||
name: "analyze_code",
|
||||
description: "Code analysis prompt",
|
||||
text: "Analyze the following code...",
|
||||
},
|
||||
], // prompts
|
||||
false, // promptsGenerated
|
||||
{
|
||||
claude_desktop: ">=0.4.0",
|
||||
platforms: ["darwin", "linux"],
|
||||
runtimes: { python: ">=3.8,<4.0" },
|
||||
}, // compatibility
|
||||
{
|
||||
api_key: {
|
||||
type: "string",
|
||||
title: "API Key",
|
||||
description: "Your API key",
|
||||
required: true,
|
||||
sensitive: true,
|
||||
},
|
||||
}, // userConfig
|
||||
{
|
||||
keywords: "test, extension, mcp",
|
||||
license: "MIT",
|
||||
repository: { type: "git", url: "https://github.com/user/repo" },
|
||||
},
|
||||
);
|
||||
|
||||
expect(manifest).toEqual({
|
||||
dxt_version: "0.1",
|
||||
name: "test-extension",
|
||||
display_name: "Test Extension",
|
||||
version: "1.0.0",
|
||||
description: "Test description",
|
||||
long_description:
|
||||
"This is a detailed long description with more information.",
|
||||
author: {
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
url: "https://example.com",
|
||||
},
|
||||
homepage: "https://homepage.example.com",
|
||||
documentation: "https://docs.example.com",
|
||||
support: "https://support.example.com",
|
||||
icon: "icon.png",
|
||||
screenshots: ["screen1.png", "screen2.png"],
|
||||
server: {
|
||||
type: "python",
|
||||
entry_point: "server/main.py",
|
||||
mcp_config: {
|
||||
command: "python",
|
||||
args: ["server/main.py"],
|
||||
env: { PYTHONPATH: "server/lib" },
|
||||
},
|
||||
},
|
||||
tools: [
|
||||
{ name: "tool1", description: "Tool 1 description" },
|
||||
{ name: "tool2" },
|
||||
],
|
||||
prompts: [
|
||||
{
|
||||
name: "analyze_code",
|
||||
description: "Code analysis prompt",
|
||||
text: "Analyze the following code...",
|
||||
},
|
||||
],
|
||||
compatibility: {
|
||||
claude_desktop: ">=0.4.0",
|
||||
platforms: ["darwin", "linux"],
|
||||
runtimes: { python: ">=3.8,<4.0" },
|
||||
},
|
||||
user_config: {
|
||||
api_key: {
|
||||
type: "string",
|
||||
title: "API Key",
|
||||
description: "Your API key",
|
||||
required: true,
|
||||
sensitive: true,
|
||||
},
|
||||
},
|
||||
keywords: ["test", "extension", "mcp"],
|
||||
license: "MIT",
|
||||
repository: { type: "git", url: "https://github.com/user/repo" },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
13
test/invalid-manifest.json
Normal file
13
test/invalid-manifest.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"dxt_version": "1.0",
|
||||
"name": "test-extension",
|
||||
"version": "1.0.0",
|
||||
"description": "A test DXT extension",
|
||||
"author": {
|
||||
"email": "not-a-valid-email"
|
||||
},
|
||||
"server": {
|
||||
"type": "invalid-type",
|
||||
"entry_point": "server/index.js"
|
||||
}
|
||||
}
|
||||
150
test/schemas.test.ts
Normal file
150
test/schemas.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
import { DxtManifestSchema } from "../src/schemas";
|
||||
|
||||
describe("DxtManifestSchema", () => {
|
||||
it("should validate a valid manifest", () => {
|
||||
const manifestPath = join(__dirname, "valid-manifest.json");
|
||||
const manifestContent = readFileSync(manifestPath, "utf-8");
|
||||
const manifestData = JSON.parse(manifestContent);
|
||||
|
||||
const result = DxtManifestSchema.safeParse(manifestData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.name).toBe("test-extension");
|
||||
expect(result.data.server.type).toBe("node");
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject an invalid manifest", () => {
|
||||
const manifestPath = join(__dirname, "invalid-manifest.json");
|
||||
const manifestContent = readFileSync(manifestPath, "utf-8");
|
||||
const manifestData = JSON.parse(manifestContent);
|
||||
|
||||
const result = DxtManifestSchema.safeParse(manifestData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map((issue) => issue.path.join("."));
|
||||
expect(errors).toContain("author.name");
|
||||
expect(errors).toContain("author.email");
|
||||
expect(errors).toContain("server.type");
|
||||
expect(errors).toContain("server.mcp_config");
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate manifest with all optional fields", () => {
|
||||
const fullManifest = {
|
||||
dxt_version: "1.0",
|
||||
name: "full-extension",
|
||||
display_name: "Full Featured Extension",
|
||||
version: "2.0.0",
|
||||
description: "An extension with all features",
|
||||
long_description: "This is a detailed description of the extension",
|
||||
author: {
|
||||
name: "Test Author",
|
||||
email: "test@example.com",
|
||||
url: "https://example.com",
|
||||
},
|
||||
repository: {
|
||||
type: "git",
|
||||
url: "https://github.com/example/extension",
|
||||
},
|
||||
homepage: "https://example.com/extension",
|
||||
documentation: "https://docs.example.com",
|
||||
support: "https://support.example.com",
|
||||
icon: "icon.png",
|
||||
screenshots: ["screenshot1.png", "screenshot2.png"],
|
||||
server: {
|
||||
type: "python",
|
||||
entry_point: "main.py",
|
||||
mcp_config: {
|
||||
command: "python",
|
||||
args: ["main.py"],
|
||||
env: { PYTHONPATH: "." },
|
||||
},
|
||||
runtime_requirements: {
|
||||
python: ">=3.8",
|
||||
},
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: "my_tool",
|
||||
description: "A useful tool",
|
||||
input_schema: { type: "object" },
|
||||
},
|
||||
],
|
||||
keywords: ["test", "example"],
|
||||
license: "MIT",
|
||||
compatibility: {
|
||||
claude_desktop: ">=1.0.0",
|
||||
platforms: ["darwin", "win32"],
|
||||
runtimes: {
|
||||
python: ">=3.8",
|
||||
node: ">=16.0.0",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
configurable: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
user_config: {
|
||||
api_key: {
|
||||
type: "string",
|
||||
title: "API Key",
|
||||
description: "Your API key",
|
||||
required: true,
|
||||
sensitive: true,
|
||||
},
|
||||
max_results: {
|
||||
type: "number",
|
||||
title: "Max Results",
|
||||
description: "Maximum number of results",
|
||||
default: 10,
|
||||
min: 1,
|
||||
max: 100,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = DxtManifestSchema.safeParse(fullManifest);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.display_name).toBe("Full Featured Extension");
|
||||
expect(result.data.tools).toHaveLength(1);
|
||||
expect(result.data.compatibility?.platforms).toContain("darwin");
|
||||
expect(result.data.user_config?.api_key.type).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("should validate server types correctly", () => {
|
||||
const serverTypes = ["python", "node", "binary"];
|
||||
|
||||
serverTypes.forEach((type) => {
|
||||
const manifest = {
|
||||
dxt_version: "1.0",
|
||||
name: "test",
|
||||
version: "1.0.0",
|
||||
description: "Test",
|
||||
author: { name: "Test" },
|
||||
server: {
|
||||
type,
|
||||
entry_point: "main",
|
||||
mcp_config: {
|
||||
command: type === "binary" ? "./main" : type,
|
||||
args: ["main"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = DxtManifestSchema.safeParse(manifest);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
467
test/sign.e2e.test.ts
Executable file
467
test/sign.e2e.test.ts
Executable file
|
|
@ -0,0 +1,467 @@
|
|||
/**
|
||||
* End-to-end test for DXT signing and verification
|
||||
*
|
||||
* This test:
|
||||
* 1. Creates a test DXT file
|
||||
* 2. Generates a self-signed certificate for testing
|
||||
* 3. Signs the DXT file
|
||||
* 4. Verifies the signature
|
||||
* 5. Tests various failure scenarios
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import forge from "node-forge";
|
||||
import * as path from "path";
|
||||
|
||||
import { signDxtFile, unsignDxtFile, verifyDxtFile } from "../src/node/sign";
|
||||
|
||||
// Test directory
|
||||
const TEST_DIR = path.join(__dirname, "test-output");
|
||||
|
||||
// Ensure test directory exists
|
||||
if (!fs.existsSync(TEST_DIR)) {
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Test file paths
|
||||
const TEST_DXT = path.join(TEST_DIR, "test.dxt");
|
||||
const SELF_SIGNED_CERT = path.join(TEST_DIR, "self-signed.crt");
|
||||
const SELF_SIGNED_KEY = path.join(TEST_DIR, "self-signed.key");
|
||||
const CA_CERT = path.join(TEST_DIR, "ca.crt");
|
||||
const CA_KEY = path.join(TEST_DIR, "ca.key");
|
||||
const SIGNED_CERT = path.join(TEST_DIR, "signed.crt");
|
||||
const SIGNED_KEY = path.join(TEST_DIR, "signed.key");
|
||||
|
||||
/**
|
||||
* Generate a self-signed certificate for testing
|
||||
*/
|
||||
function generateSelfSignedCert() {
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||
const cert = forge.pki.createCertificate();
|
||||
|
||||
cert.publicKey = keys.publicKey;
|
||||
cert.serialNumber = "01";
|
||||
cert.validity.notBefore = new Date();
|
||||
cert.validity.notAfter = new Date();
|
||||
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
|
||||
|
||||
const attrs = [
|
||||
{ name: "commonName", value: "Test DXT Publisher" },
|
||||
{ name: "countryName", value: "US" },
|
||||
{ name: "organizationName", value: "Test Org" },
|
||||
];
|
||||
|
||||
cert.setSubject(attrs);
|
||||
cert.setIssuer(attrs);
|
||||
|
||||
// Add extensions
|
||||
cert.setExtensions([
|
||||
{
|
||||
name: "basicConstraints",
|
||||
cA: false,
|
||||
},
|
||||
{
|
||||
name: "keyUsage",
|
||||
keyCertSign: false,
|
||||
digitalSignature: true,
|
||||
nonRepudiation: true,
|
||||
keyEncipherment: false,
|
||||
dataEncipherment: false,
|
||||
},
|
||||
{
|
||||
name: "extKeyUsage",
|
||||
codeSigning: true,
|
||||
},
|
||||
]);
|
||||
|
||||
// Self-sign
|
||||
cert.sign(keys.privateKey, forge.md.sha256.create());
|
||||
|
||||
// Write files
|
||||
fs.writeFileSync(SELF_SIGNED_CERT, forge.pki.certificateToPem(cert));
|
||||
fs.writeFileSync(SELF_SIGNED_KEY, forge.pki.privateKeyToPem(keys.privateKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a CA and signed certificate for testing
|
||||
*/
|
||||
function generateCASignedCert() {
|
||||
// Generate CA
|
||||
const caKeys = forge.pki.rsa.generateKeyPair(2048);
|
||||
const caCert = forge.pki.createCertificate();
|
||||
|
||||
caCert.publicKey = caKeys.publicKey;
|
||||
caCert.serialNumber = "01";
|
||||
caCert.validity.notBefore = new Date();
|
||||
caCert.validity.notAfter = new Date();
|
||||
caCert.validity.notAfter.setFullYear(
|
||||
caCert.validity.notBefore.getFullYear() + 10,
|
||||
);
|
||||
|
||||
const caAttrs = [
|
||||
{ name: "commonName", value: "Test CA" },
|
||||
{ name: "countryName", value: "US" },
|
||||
{ name: "organizationName", value: "Test CA Org" },
|
||||
];
|
||||
|
||||
caCert.setSubject(caAttrs);
|
||||
caCert.setIssuer(caAttrs);
|
||||
|
||||
caCert.setExtensions([
|
||||
{
|
||||
name: "basicConstraints",
|
||||
cA: true,
|
||||
},
|
||||
{
|
||||
name: "keyUsage",
|
||||
keyCertSign: true,
|
||||
digitalSignature: true,
|
||||
cRLSign: true,
|
||||
},
|
||||
]);
|
||||
|
||||
caCert.sign(caKeys.privateKey, forge.md.sha256.create());
|
||||
|
||||
// Generate signed certificate
|
||||
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||
const cert = forge.pki.createCertificate();
|
||||
|
||||
cert.publicKey = keys.publicKey;
|
||||
cert.serialNumber = "02";
|
||||
cert.validity.notBefore = new Date();
|
||||
cert.validity.notAfter = new Date();
|
||||
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);
|
||||
|
||||
const attrs = [
|
||||
{ name: "commonName", value: "Test Publisher (CA Signed)" },
|
||||
{ name: "countryName", value: "US" },
|
||||
{ name: "organizationName", value: "Test Publisher Org" },
|
||||
];
|
||||
|
||||
cert.setSubject(attrs);
|
||||
cert.setIssuer(caCert.subject.attributes);
|
||||
|
||||
cert.setExtensions([
|
||||
{
|
||||
name: "basicConstraints",
|
||||
cA: false,
|
||||
},
|
||||
{
|
||||
name: "keyUsage",
|
||||
digitalSignature: true,
|
||||
nonRepudiation: true,
|
||||
},
|
||||
{
|
||||
name: "extKeyUsage",
|
||||
codeSigning: true,
|
||||
},
|
||||
]);
|
||||
|
||||
// Sign with CA
|
||||
cert.sign(caKeys.privateKey, forge.md.sha256.create());
|
||||
|
||||
// Write files
|
||||
fs.writeFileSync(CA_CERT, forge.pki.certificateToPem(caCert));
|
||||
fs.writeFileSync(CA_KEY, forge.pki.privateKeyToPem(caKeys.privateKey));
|
||||
fs.writeFileSync(SIGNED_CERT, forge.pki.certificateToPem(cert));
|
||||
fs.writeFileSync(SIGNED_KEY, forge.pki.privateKeyToPem(keys.privateKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test DXT file (just a simple ZIP)
|
||||
*/
|
||||
function createTestDxt() {
|
||||
// Create a simple ZIP file content
|
||||
// This is a minimal valid ZIP with one file
|
||||
const zipContent = Buffer.from([
|
||||
0x50,
|
||||
0x4b,
|
||||
0x03,
|
||||
0x04, // Local file header signature
|
||||
0x14,
|
||||
0x00, // Version needed
|
||||
0x00,
|
||||
0x00, // Flags
|
||||
0x00,
|
||||
0x00, // Compression method (stored)
|
||||
0x00,
|
||||
0x00, // Last mod time
|
||||
0x00,
|
||||
0x00, // Last mod date
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // CRC-32
|
||||
0x0d,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // Compressed size
|
||||
0x0d,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // Uncompressed size
|
||||
0x0c,
|
||||
0x00, // Filename length
|
||||
0x00,
|
||||
0x00, // Extra field length
|
||||
// Filename: "manifest.json"
|
||||
0x6d,
|
||||
0x61,
|
||||
0x6e,
|
||||
0x69,
|
||||
0x66,
|
||||
0x65,
|
||||
0x73,
|
||||
0x74,
|
||||
0x2e,
|
||||
0x6a,
|
||||
0x73,
|
||||
0x6f,
|
||||
0x6e,
|
||||
// File content: '{"test":true}'
|
||||
0x7b,
|
||||
0x22,
|
||||
0x74,
|
||||
0x65,
|
||||
0x73,
|
||||
0x74,
|
||||
0x22,
|
||||
0x3a,
|
||||
0x74,
|
||||
0x72,
|
||||
0x75,
|
||||
0x65,
|
||||
0x7d,
|
||||
// Central directory
|
||||
0x50,
|
||||
0x4b,
|
||||
0x01,
|
||||
0x02, // Central directory signature
|
||||
0x14,
|
||||
0x00, // Version made by
|
||||
0x14,
|
||||
0x00, // Version needed
|
||||
0x00,
|
||||
0x00, // Flags
|
||||
0x00,
|
||||
0x00, // Compression method
|
||||
0x00,
|
||||
0x00, // Last mod time
|
||||
0x00,
|
||||
0x00, // Last mod date
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // CRC-32
|
||||
0x0d,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // Compressed size
|
||||
0x0d,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // Uncompressed size
|
||||
0x0c,
|
||||
0x00, // Filename length
|
||||
0x00,
|
||||
0x00, // Extra field length
|
||||
0x00,
|
||||
0x00, // File comment length
|
||||
0x00,
|
||||
0x00, // Disk number
|
||||
0x00,
|
||||
0x00, // Internal attributes
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // External attributes
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // Offset of local header
|
||||
// Filename: "manifest.json"
|
||||
0x6d,
|
||||
0x61,
|
||||
0x6e,
|
||||
0x69,
|
||||
0x66,
|
||||
0x65,
|
||||
0x73,
|
||||
0x74,
|
||||
0x2e,
|
||||
0x6a,
|
||||
0x73,
|
||||
0x6f,
|
||||
0x6e,
|
||||
// End of central directory
|
||||
0x50,
|
||||
0x4b,
|
||||
0x05,
|
||||
0x06, // End of central directory signature
|
||||
0x00,
|
||||
0x00, // Disk number
|
||||
0x00,
|
||||
0x00, // Start disk
|
||||
0x01,
|
||||
0x00, // Number of central directory records on this disk
|
||||
0x01,
|
||||
0x00, // Total number of central directory records
|
||||
0x3a,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // Size of central directory
|
||||
0x34,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // Offset of central directory
|
||||
0x00,
|
||||
0x00, // Comment length
|
||||
]);
|
||||
|
||||
fs.writeFileSync(TEST_DXT, zipContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test signing with self-signed certificate
|
||||
*/
|
||||
async function testSelfSignedSigning() {
|
||||
// Create a copy for this test
|
||||
const testFile = path.join(TEST_DIR, "test-self-signed.dxt");
|
||||
fs.copyFileSync(TEST_DXT, testFile);
|
||||
|
||||
// Sign the file
|
||||
expect(() =>
|
||||
signDxtFile(testFile, SELF_SIGNED_CERT, SELF_SIGNED_KEY),
|
||||
).not.toThrow();
|
||||
|
||||
// Verify the signature
|
||||
const result = await verifyDxtFile(testFile);
|
||||
|
||||
// Self-signed certs may not be trusted by OS, so we accept either status
|
||||
expect(["self-signed", "unsigned"]).toContain(result.status);
|
||||
|
||||
// Clean up
|
||||
fs.unlinkSync(testFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test signing with CA-signed certificate
|
||||
*/
|
||||
async function testCASignedSigning() {
|
||||
// Create a copy for this test
|
||||
const testFile = path.join(TEST_DIR, "test-ca-signed.dxt");
|
||||
fs.copyFileSync(TEST_DXT, testFile);
|
||||
|
||||
// Sign the file with intermediate
|
||||
expect(() =>
|
||||
signDxtFile(testFile, SIGNED_CERT, SIGNED_KEY, [CA_CERT]),
|
||||
).not.toThrow();
|
||||
|
||||
// Verify the signature
|
||||
const result = await verifyDxtFile(testFile);
|
||||
|
||||
// CA-signed status depends on whether test CA is trusted by OS
|
||||
expect(["signed", "unsigned"]).toContain(result.status);
|
||||
|
||||
// Clean up
|
||||
fs.unlinkSync(testFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test tampering detection
|
||||
*/
|
||||
async function testTamperingDetection() {
|
||||
// Create a copy and sign it
|
||||
const testFile = path.join(TEST_DIR, "test-tampered.dxt");
|
||||
fs.copyFileSync(TEST_DXT, testFile);
|
||||
signDxtFile(testFile, SELF_SIGNED_CERT, SELF_SIGNED_KEY);
|
||||
|
||||
// Read the signed file
|
||||
const signedContent = fs.readFileSync(testFile);
|
||||
|
||||
// Tamper with the content (change a byte in the ZIP portion)
|
||||
const tamperedContent = Buffer.from(signedContent);
|
||||
tamperedContent[10] = (tamperedContent[10] + 1) % 256;
|
||||
fs.writeFileSync(testFile, tamperedContent);
|
||||
|
||||
// Try to verify - should fail
|
||||
const result = await verifyDxtFile(testFile);
|
||||
expect(result.status).toBe("unsigned");
|
||||
|
||||
// Clean up
|
||||
fs.unlinkSync(testFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test unsigned file verification
|
||||
*/
|
||||
async function testUnsignedFile() {
|
||||
const result = await verifyDxtFile(TEST_DXT);
|
||||
expect(result.status).toBe("unsigned");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test signature removal
|
||||
*/
|
||||
async function testSignatureRemoval() {
|
||||
// Create a copy and sign it
|
||||
const testFile = path.join(TEST_DIR, "test-remove-sig.dxt");
|
||||
fs.copyFileSync(TEST_DXT, testFile);
|
||||
signDxtFile(testFile, SELF_SIGNED_CERT, SELF_SIGNED_KEY);
|
||||
|
||||
// Verify it's signed (or at least has signature data)
|
||||
await verifyDxtFile(testFile);
|
||||
|
||||
// Remove signature
|
||||
expect(() => unsignDxtFile(testFile)).not.toThrow();
|
||||
|
||||
// Verify it's unsigned
|
||||
const afterResult = await verifyDxtFile(testFile);
|
||||
expect(afterResult.status).toBe("unsigned");
|
||||
|
||||
// Clean up
|
||||
fs.unlinkSync(testFile);
|
||||
}
|
||||
|
||||
describe("DXT Signing E2E Tests", () => {
|
||||
beforeAll(() => {
|
||||
// Ensure test directory exists
|
||||
if (!fs.existsSync(TEST_DIR)) {
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Setup
|
||||
generateSelfSignedCert();
|
||||
generateCASignedCert();
|
||||
createTestDxt();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Cleanup
|
||||
try {
|
||||
fs.rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it("should sign and verify with self-signed certificate", async () => {
|
||||
await testSelfSignedSigning();
|
||||
});
|
||||
|
||||
it("should sign and verify with CA-signed certificate", async () => {
|
||||
await testCASignedSigning();
|
||||
});
|
||||
|
||||
it("should detect tampering", async () => {
|
||||
await testTamperingDetection();
|
||||
});
|
||||
|
||||
it("should identify unsigned files", async () => {
|
||||
await testUnsignedFile();
|
||||
});
|
||||
|
||||
it("should remove signatures", async () => {
|
||||
await testSignatureRemoval();
|
||||
});
|
||||
});
|
||||
19
test/valid-manifest.json
Normal file
19
test/valid-manifest.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"dxt_version": "1.0",
|
||||
"name": "test-extension",
|
||||
"display_name": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"description": "A test DXT extension",
|
||||
"author": {
|
||||
"name": "Test Author",
|
||||
"email": "test@example.com"
|
||||
},
|
||||
"server": {
|
||||
"type": "node",
|
||||
"entry_point": "server/index.js",
|
||||
"mcp_config": {
|
||||
"command": "node",
|
||||
"args": ["${__dirname}/server/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue