server.test.ts•13.3 kB
/**
* Stellaris Modding Helper MCP Server のテスト
* 実際のAPIを使用したインテグレーションテスト
*
* 注意: このテストは実際のGitHubおよびSteamCMD APIにリクエストを送信します。
* APIのレート制限を避けるため、テストの実行には時間がかかる場合があります。
* そのため、各テストケースのタイムアウトを30秒に設定しています。
*/
import { beforeEach, describe, expect, it } from "vitest";
// server.tsから複製・流用する定数とヘルパー関数
const CWTOOLS_REPO_OWNER = "cwtools";
const CWTOOLS_REPO_NAME = "cwtools-stellaris-config";
const GITHUB_API_BASE = "https://api.github.com";
const STELLARIS_APP_ID = "281990";
const STEAMCMD_API_BASE = "https://api.steamcmd.net/v1";
const STELLARIS_DOCS_OWNER = "OldEnt";
const STELLARIS_DOCS_REPO = "stellaris-triggers-modifiers-effects-list";
const RETRY_CONFIG = {
backoffMultiplier: 2,
baseDelay: 1000, // 1秒
maxDelay: 10000, // 10秒
maxRetries: 3,
};
/**
* 指数バックオフを使用したリトライ機能付きfetch
*/
async function fetchWithRetry(
url: string,
options?: RequestInit,
retryCount = 0,
): Promise<Response> {
try {
const response = await fetch(url, options);
if (response.status === 403 && response.statusText.includes("rate limit")) {
if (retryCount < RETRY_CONFIG.maxRetries) {
const delay = Math.min(
RETRY_CONFIG.baseDelay *
Math.pow(RETRY_CONFIG.backoffMultiplier, retryCount),
RETRY_CONFIG.maxDelay,
);
console.log(
`Rate limit hit, retrying in ${delay}ms (attempt ${retryCount + 1}/${RETRY_CONFIG.maxRetries})`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
return fetchWithRetry(url, options, retryCount + 1);
}
}
if (response.status >= 500 && response.status < 600) {
if (retryCount < RETRY_CONFIG.maxRetries) {
const delay = Math.min(
RETRY_CONFIG.baseDelay *
Math.pow(RETRY_CONFIG.backoffMultiplier, retryCount),
RETRY_CONFIG.maxDelay,
);
console.log(
`Server error ${response.status}, retrying in ${delay}ms (attempt ${
retryCount + 1
}/${RETRY_CONFIG.maxRetries})`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
return fetchWithRetry(url, options, retryCount + 1);
}
}
return response;
} catch (error) {
if (retryCount < RETRY_CONFIG.maxRetries) {
const delay = Math.min(
RETRY_CONFIG.baseDelay *
Math.pow(RETRY_CONFIG.backoffMultiplier, retryCount),
RETRY_CONFIG.maxDelay,
);
console.log(
`Network error, retrying in ${delay}ms (attempt ${retryCount + 1}/${RETRY_CONFIG.maxRetries}):`,
error instanceof Error ? error.message : String(error),
);
await new Promise((resolve) => setTimeout(resolve, delay));
return fetchWithRetry(url, options, retryCount + 1);
}
throw error;
}
}
/**
* SteamCMD APIからアプリ情報を取得する
*/
async function fetchSteamAppInfo(appId: string): Promise<unknown> {
const response = await fetchWithRetry(`${STEAMCMD_API_BASE}/info/${appId}`);
if (!response.ok) {
throw new Error(
`Failed to fetch app info: ${response.status} ${response.statusText}`,
);
}
const data = (await response.json()) as {
data: Record<string, unknown>;
status: string;
};
if (data.status !== "success") {
throw new Error(`API returned error: ${JSON.stringify(data.data)}`);
}
return data.data[appId];
}
/**
* GitHub APIから最新のタグを取得する
*/
async function getLatestTag(owner: string, repo: string): Promise<string> {
const response = await fetchWithRetry(
`${GITHUB_API_BASE}/repos/${owner}/${repo}/tags`,
);
if (!response.ok) {
throw new Error(
`Failed to fetch tags: ${response.status} ${response.statusText}`,
);
}
const tags = (await response.json()) as Array<{ name: string }>;
if (tags.length === 0) {
throw new Error("No tags found in repository");
}
return tags[0].name;
}
/**
* GitHub APIからディレクトリの内容を取得する
*/
async function getDirectoryContents(
owner: string,
repo: string,
path: string,
ref?: string,
): Promise<
Array<{
download_url: string;
name: string;
path: string;
size: number;
type: string;
}>
> {
const url = new URL(
`${GITHUB_API_BASE}/repos/${owner}/${repo}/contents/${path}`,
);
if (ref) {
url.searchParams.set("ref", ref);
}
const response = await fetchWithRetry(url.toString());
if (!response.ok) {
throw new Error(
`Failed to fetch directory contents: ${response.status} ${response.statusText}`,
);
}
const contents = (await response.json()) as Array<{
download_url: string;
name: string;
path: string;
size: number;
type: string;
}>;
if (!Array.isArray(contents)) {
throw new Error("Expected directory contents to be an array");
}
return contents;
}
/**
* GitHub APIからファイルの内容を取得する
*/
async function getFileContent(
owner: string,
repo: string,
path: string,
ref?: string,
): Promise<string> {
const url = new URL(
`${GITHUB_API_BASE}/repos/${owner}/${repo}/contents/${path}`,
);
if (ref) {
url.searchParams.set("ref", ref);
}
const response = await fetchWithRetry(url.toString());
if (!response.ok) {
throw new Error(
`Failed to fetch file content: ${response.status} ${response.statusText}`,
);
}
const fileData = (await response.json()) as {
content: string;
encoding: string;
};
if (fileData.encoding === "base64") {
return Buffer.from(fileData.content, "base64").toString("utf-8");
}
return fileData.content;
}
/**
* リポジトリのファイル一覧を取得し、利用可能なバージョン一覧を取得する(降順ソート済み)
*/
async function getAvailableStellarisVersions(
owner: string,
repo: string,
): Promise<string[]> {
const response = await fetchWithRetry(
`${GITHUB_API_BASE}/repos/${owner}/${repo}/contents`,
);
if (!response.ok) {
throw new Error(
`Failed to fetch repository contents: ${response.status} ${response.statusText}`,
);
}
const contents = (await response.json()) as Array<{
name: string;
type: string;
}>;
const versionFiles = contents
.filter((item) => item.type === "file")
.map((item) => item.name)
.filter((name) => /^\d+\.\d+\.\d+_game_/.test(name))
.map((name) => {
const match = name.match(/^(\d+\.\d+\.\d+)_game_/);
return match ? match[1] : null;
})
.filter((version): version is string => version !== null);
const uniqueVersions = [...new Set(versionFiles)];
uniqueVersions.sort((a, b) => {
const aVersion = a.split(".").map((n) => parseInt(n) || 0);
const bVersion = b.split(".").map((n) => parseInt(n) || 0);
for (let i = 0; i < Math.max(aVersion.length, bVersion.length); i++) {
const diff = (bVersion[i] || 0) - (aVersion[i] || 0);
if (diff !== 0) return diff;
}
return 0;
});
return uniqueVersions;
}
describe("Stellaris Modding Helper - Integration Tests", () => {
beforeEach(async () => {
// APIレート制限を避けるためにテスト間に待機時間を設ける
await new Promise((resolve) => setTimeout(resolve, 1500));
});
describe("Tool: stellaris-version", () => {
it(
"should fetch and format Stellaris version info from SteamCMD API",
async () => {
const appInfo = (await fetchSteamAppInfo(STELLARIS_APP_ID)) as any;
expect(appInfo).toBeDefined();
expect(appInfo.common.name).toBe("Stellaris");
expect(appInfo.depots.branches.public).toBeDefined();
expect(appInfo.extended.developer).toBe("Paradox Development Studio");
},
{ timeout: 30000 },
);
});
describe("Tool: cwtools-config", () => {
it(
"should list all config files including subdirectories",
async () => {
const configContents = await getDirectoryContents(
CWTOOLS_REPO_OWNER,
CWTOOLS_REPO_NAME,
"config",
);
const subdirectories = configContents.filter(
(item) => item.type === "dir",
);
expect(subdirectories.length).toBeGreaterThan(0);
const subdirContents = await getDirectoryContents(
CWTOOLS_REPO_OWNER,
CWTOOLS_REPO_NAME,
`config/${subdirectories[0].name}`,
);
const cwtFiles = subdirContents.filter(
(item) => item.type === "file" && item.name.endsWith(".cwt"),
);
expect(cwtFiles.length).toBeGreaterThan(0);
},
{ timeout: 30000 },
);
it(
"should fetch a specific file from a subdirectory",
async () => {
const fileContent = await getFileContent(
CWTOOLS_REPO_OWNER,
CWTOOLS_REPO_NAME,
"config/common/buildings.cwt",
);
expect(fileContent).toBeTruthy();
expect(fileContent).toContain("type[building]");
},
{ timeout: 30000 },
);
});
describe("Tool: stellaris-search", () => {
it(
'should find keyword "has_technology" and return snippets',
async () => {
const versions = await getAvailableStellarisVersions(
STELLARIS_DOCS_OWNER,
STELLARIS_DOCS_REPO,
);
const latestVersion = versions[0];
const fileName = `${latestVersion}_game_triggers.log`;
const fileContent = await getFileContent(
STELLARIS_DOCS_OWNER,
STELLARIS_DOCS_REPO,
fileName,
);
const lines = fileContent.split("\n");
const results = lines
.map((line, i) => ({ line, i }))
.filter(({ line }) => line.toLowerCase().includes("has_technology"));
expect(results.length).toBeGreaterThan(0);
expect(results[0].line).toContain("has_technology");
},
{ timeout: 30000 },
);
it(
'should return "not found" message for a non-existing keyword',
async () => {
const versions = await getAvailableStellarisVersions(
STELLARIS_DOCS_OWNER,
STELLARIS_DOCS_REPO,
);
const latestVersion = versions[0];
const fileName = `${latestVersion}_game_triggers.log`;
const fileContent = await getFileContent(
STELLARIS_DOCS_OWNER,
STELLARIS_DOCS_REPO,
fileName,
);
const lines = fileContent.split("\n");
const results = lines.filter((line) =>
line.toLowerCase().includes("non_existent_keyword_xyz"),
);
expect(results.length).toBe(0);
},
{ timeout: 30000 },
);
});
describe("Tool: stellaris-validate", () => {
it(
'should return VALID for an existing element "has_technology"',
async () => {
const versions = await getAvailableStellarisVersions(
STELLARIS_DOCS_OWNER,
STELLARIS_DOCS_REPO,
);
const latestVersion = versions[0];
const fileName = `${latestVersion}_game_triggers.log`;
const fileContent = await getFileContent(
STELLARIS_DOCS_OWNER,
STELLARIS_DOCS_REPO,
fileName,
);
const element = "has_technology";
const exactMatch = new RegExp(`\\b${element}\\b`, "i").test(fileContent);
expect(exactMatch).toBe(true);
},
{ timeout: 30000 },
);
it(
'should return NOT FOUND for a non-existing element "non_existent_element_xyz"',
async () => {
const versions = await getAvailableStellarisVersions(
STELLARIS_DOCS_OWNER,
STELLARIS_DOCS_REPO,
);
const latestVersion = versions[0];
const fileName = `${latestVersion}_game_triggers.log`;
const fileContent = await getFileContent(
STELLARIS_DOCS_OWNER,
STELLARIS_DOCS_REPO,
fileName,
);
const element = "non_existent_element_xyz";
const exactMatch = new RegExp(`\\b${element}\\b`, "i").test(fileContent);
expect(exactMatch).toBe(false);
},
{ timeout: 30000 },
);
it(
'should return PARTIAL MATCH for a similar element "technolog"',
async () => {
const versions = await getAvailableStellarisVersions(
STELLARIS_DOCS_OWNER,
STELLARIS_DOCS_REPO,
);
const latestVersion = versions[0];
const fileName = `${latestVersion}_game_triggers.log`;
const fileContent = await getFileContent(
STELLARIS_DOCS_OWNER,
STELLARIS_DOCS_REPO,
fileName,
);
const element = "technolog"; // 存在しないであろう単語に変更
const exactMatch = new RegExp(`\\b${element}\\b`, "i").test(fileContent);
const partialMatch = fileContent.toLowerCase().includes(element);
expect(exactMatch).toBe(false); // exact matchでは見つからない
expect(partialMatch).toBe(true); // partial matchでは見つかる (例: "technology" が含まれる行)
},
{ timeout: 30000 },
);
});
});