e2e интеграционные тесты

This commit is contained in:
2025-10-28 10:51:14 +03:00
parent 6bffe6a9e1
commit 4e2d0f03de
9 changed files with 191 additions and 85 deletions

View File

@@ -17,9 +17,12 @@
"@types/ejs": "^3.1.5",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.2",
"@types/react": "^18.3.0",
"@types/tmp": "^0.2.6",
"execa": "^8.0.0",
"msw": "^2.0.0",
"react": "^18.3.0",
"swr": "^2.3.0",
"tmp": "^0.2.1",
},
"peerDependencies": {
@@ -66,7 +69,9 @@
"@types/node": ["@types/node@22.18.12", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BICHQ67iqxQGFSzfCFTT7MRQ5XcBjG5aeKh5Ok38UBbPe5fxTyE+aHFxwVrGyr8GNlqFMLKD1D3P2K/1ks8tog=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/react": ["@types/react@18.3.26", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA=="],
"@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
@@ -120,6 +125,8 @@
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
@@ -168,10 +175,14 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="],
@@ -228,6 +239,8 @@
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"reftools": ["reftools@1.1.9", "", {}, "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w=="],
@@ -270,6 +283,8 @@
"swagger2openapi": ["swagger2openapi@7.0.8", "", { "dependencies": { "call-me-maybe": "^1.0.1", "node-fetch": "^2.6.1", "node-fetch-h2": "^2.3.0", "node-readfiles": "^0.2.0", "oas-kit-common": "^1.0.8", "oas-resolver": "^2.5.6", "oas-schema-walker": "^1.1.5", "oas-validator": "^5.0.8", "reftools": "^1.1.9", "yaml": "^1.10.0", "yargs": "^17.0.1" }, "bin": { "swagger2openapi": "swagger2openapi.js", "oas-validate": "oas-validate.js", "boast": "boast.js" } }, "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g=="],
"swr": ["swr@2.3.6", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-wfHRmHWk/isGNMwlLGlZX5Gzz/uTgo0o2IRuTMcf4CPuPFJZlq0rDaKUx+ozB5nBOReNV1kiOyzMfj+MBMikLw=="],
"tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="],
"tldts": ["tldts@7.0.17", "", { "dependencies": { "tldts-core": "^7.0.17" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ=="],
@@ -290,6 +305,8 @@
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],

View File

@@ -29,9 +29,12 @@
"@types/ejs": "^3.1.5",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.2",
"@types/react": "^18.3.0",
"@types/tmp": "^0.2.6",
"execa": "^8.0.0",
"msw": "^2.0.0",
"react": "^18.3.0",
"swr": "^2.3.0",
"tmp": "^0.2.1"
},
"peerDependencies": {

View File

@@ -15,6 +15,7 @@ program
.requiredOption('-i, --input <path>', 'Path to OpenAPI specification file (JSON or YAML)')
.requiredOption('-o, --output <path>', 'Output directory for generated files')
.option('-n, --name <name>', 'Name of generated file (without extension)')
.option('--swr', 'Generate SWR hooks for React')
.action(async (options) => {
try {
// Создание конфигурации
@@ -22,6 +23,7 @@ program
inputPath: options.input,
outputPath: options.output,
fileName: options.name,
useSwr: options.swr || false,
};
// Валидация конфигурации
@@ -38,7 +40,7 @@ program
// Генерация API
await generate(config as GeneratorConfig);
console.log(chalk.green('\n✨ Done!\n'));
console.log(chalk.green('\n✨ API client generated successfully!\n'));
} catch (error) {
console.error(chalk.red('\n❌ Error:'), error instanceof Error ? error.message : error);
console.error();

View File

@@ -8,6 +8,8 @@ export interface GeneratorConfig {
outputPath: string;
/** Имя сгенерированного файла (без расширения) */
fileName?: string;
/** Генерировать SWR hooks для React */
useSwr?: boolean;
}
/**

View File

@@ -18,25 +18,34 @@ export async function generate(config: GeneratorConfig): Promise<void> {
// Путь к кастомным шаблонам
const templatesPath = resolve(__dirname, '../src/templates');
// Читаем OpenAPI спецификацию
const inputPath = config.inputPath.startsWith('http://') || config.inputPath.startsWith('https://')
? config.inputPath
: resolve(config.inputPath);
const spec = await readJsonFile<any>(inputPath);
// Проверяем тип входного пути
const isUrl = config.inputPath.startsWith('http://') || config.inputPath.startsWith('https://');
// Для локальных файлов читаем спецификацию
let spec: any = null;
let inputPath: string | undefined = undefined;
let url: string | undefined = undefined;
if (isUrl) {
url = config.inputPath;
// Для URL не читаем спецификацию заранее, swagger-typescript-api сделает это сам
} else {
inputPath = resolve(config.inputPath);
spec = await readJsonFile<any>(inputPath);
}
// Определяем имя файла
let fileName = config.fileName;
if (!fileName) {
// Пытаемся получить имя из OpenAPI спецификации
fileName = spec.info?.title
fileName = spec?.info?.title
? spec.info.title.replace(/[^a-zA-Z0-9]/g, '')
: 'Api';
}
try {
await swaggerGenerateApi({
input: inputPath,
...(isUrl ? { url } : { input: inputPath }),
output: resolve(config.outputPath),
fileName: `${fileName}.ts`,
httpClientType: 'fetch',
@@ -60,7 +69,7 @@ export async function generate(config: GeneratorConfig): Promise<void> {
sortRoutes: false,
extractResponseError: false,
fixInvalidEnumKeyPrefix: 'KEY',
silent: false,
silent: true,
defaultResponseType: 'void',
typePrefix: '',
typeSuffix: '',
@@ -90,18 +99,18 @@ export async function generate(config: GeneratorConfig): Promise<void> {
},
onInit: (configuration) => {
// Получаем дефолтный baseUrl из OpenAPI спецификации
const defaultBaseUrl = spec.servers?.[0]?.url || '';
const apiConfig = (configuration as any).apiConfig || {};
const defaultBaseUrl = spec?.servers?.[0]?.url || apiConfig.baseUrl || '';
(configuration as any).apiConfig = (configuration as any).apiConfig || {};
(configuration as any).apiConfig.baseUrl = defaultBaseUrl;
// Передаем флаг useSwr в шаблоны
(configuration as any).useSwr = config.useSwr || false;
return configuration;
},
},
});
console.log(`Generated files in ${config.outputPath}:`);
console.log(` - ${fileName}.ts (API endpoints)`);
console.log(' - http-client.ts (HTTP client)');
console.log(' - data-contracts.ts (TypeScript types)');
// Генерация успешна
} catch (error) {
console.error('❌ Generation failed:', error);
throw error;

View File

@@ -26,8 +26,9 @@ const descriptionLines = _.compact([
%>
<% if (config.useSwr) { %>
import useSWR from "swr";
import { fetcher } from "./http-client";
<% } %>
<% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { AxiosRequestConfig, AxiosResponse } from "axios"; <% } %>
@@ -44,8 +45,8 @@ export class <%~ config.apiClassName %><SecurityDataType extends unknown><% if (
<% if(config.singleHttpClient) { %>
http: HttpClient<SecurityDataType>;
constructor (http: HttpClient<SecurityDataType>) {
this.http = http;
constructor (http?: HttpClient<SecurityDataType>) {
this.http = http || new HttpClient<SecurityDataType>();
}
<% } %>

View File

@@ -102,9 +102,9 @@ const isValidIdentifier = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
...<%~ _.get(requestConfigParam, "name") %>,
})<%~ route.namespace ? ',' : '' %>
<%
// Генерируем use* функцию для GET запросов
// Генерируем use* функцию для GET запросов (только если включен флаг useSwr)
const isGetRequest = _.upperCase(method) === 'GET';
if (isGetRequest) {
if (config.useSwr && isGetRequest) {
const useMethodName = 'use' + _.upperFirst(route.routeName.usage);
const argsWithoutParams = rawWrapperArgs.filter(arg => arg.name !== requestConfigParam.name);
const useWrapperArgs = _

View File

@@ -38,7 +38,7 @@ describe('E2E Generation', () => {
});
describe('полный цикл генерации', () => {
test.skip('CLI генерация → создание файла → импорт → использование', async () => {
test('CLI генерация → создание файла → импорт → использование', async () => {
const outputPath = join(tempDir, 'output');
// 1. Генерация через CLI
@@ -135,10 +135,91 @@ describe('E2E Generation', () => {
const exists = await fileExists(generatedFile);
expect(exists).toBe(true);
}, 60000);
test('генерация из HTTP URL', async () => {
const outputPath = join(tempDir, 'output');
// Используем публичный OpenAPI spec
const { exitCode } = await execa('bun', [
'run',
CLI_PATH,
'--input',
'https://petstore3.swagger.io/api/v3/openapi.json',
'--output',
outputPath,
'--name',
'PetStore',
]);
expect(exitCode).toBe(0);
const generatedFile = join(outputPath, 'PetStore.ts');
const exists = await fileExists(generatedFile);
expect(exists).toBe(true);
// Проверяем что файл не пустой
const content = await readTextFile(generatedFile);
expect(content.length).toBeGreaterThan(1000);
}, 60000);
test('генерация с флагом --swr', async () => {
const outputPath = join(tempDir, 'output');
const { exitCode } = await execa('bun', [
'run',
CLI_PATH,
'--input',
FIXTURES.VALID,
'--output',
outputPath,
'--name',
'SwrApi',
'--swr',
]);
expect(exitCode).toBe(0);
const generatedFile = join(outputPath, 'SwrApi.ts');
const content = await readTextFile(generatedFile);
// Проверяем наличие импорта useSWR
expect(content).toContain('import useSWR from "swr"');
// Проверяем наличие use* хуков для GET запросов
expect(content).toContain('useGetAll');
expect(content).toContain('useGetById');
}, 30000);
test('генерация без флага --swr не содержит хуки', async () => {
const outputPath = join(tempDir, 'output');
const { exitCode } = await execa('bun', [
'run',
CLI_PATH,
'--input',
FIXTURES.VALID,
'--output',
outputPath,
'--name',
'NoSwrApi',
]);
expect(exitCode).toBe(0);
const generatedFile = join(outputPath, 'NoSwrApi.ts');
const content = await readTextFile(generatedFile);
// Проверяем отсутствие импорта useSWR
expect(content).not.toContain('import useSWR from "swr"');
// Проверяем отсутствие use* хуков
expect(content).not.toContain('useGetAll');
expect(content).not.toContain('useGetById');
}, 30000);
});
describe('HTTP запросы с mock сервером', () => {
test.skip('GET запрос без параметров', async () => {
test('GET запрос без параметров', async () => {
const outputPath = join(tempDir, 'output');
await execa('bun', [
@@ -152,26 +233,18 @@ describe('E2E Generation', () => {
'TestApi',
]);
// Создаем тестовый файл для вызова API
const testFile = join(tempDir, 'test-get.ts');
const testCode = `
import { Api } from '${join(outputPath, 'TestApi.ts')}';
const api = new Api();
const result = await api.user.getAll();
console.log(JSON.stringify(result));
`;
// Динамически импортируем сгенерированный API
const generatedFile = join(outputPath, 'TestApi.ts');
const { Api } = await import(generatedFile);
await Bun.write(testFile, testCode);
const api = new Api();
const result = await api.users.getAll();
const { stdout } = await execa('bun', ['run', testFile]);
const data = JSON.parse(stdout);
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBe(2);
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(2);
}, 60000);
test.skip('POST запрос с body', async () => {
test('POST запрос с body', async () => {
const outputPath = join(tempDir, 'output');
await execa('bun', [
@@ -185,28 +258,21 @@ describe('E2E Generation', () => {
'TestApi',
]);
const testFile = join(tempDir, 'test-post.ts');
const testCode = `
import { Api } from '${join(outputPath, 'TestApi.ts')}';
const api = new Api();
const result = await api.user.create({
email: 'new@example.com',
password: 'password123'
});
console.log(JSON.stringify(result));
`;
// Динамически импортируем сгенерированный API
const generatedFile = join(outputPath, 'TestApi.ts');
const { Api } = await import(generatedFile);
await Bun.write(testFile, testCode);
const api = new Api();
const result = await api.users.create({
email: 'new@example.com',
password: 'password123'
});
const { stdout } = await execa('bun', ['run', testFile]);
const data = JSON.parse(stdout);
expect(data.id).toBe('3');
expect(data.email).toBe('new@example.com');
expect(result.id).toBe('3');
expect(result.email).toBe('new@example.com');
}, 60000);
test.skip('обработка 404 статуса', async () => {
test('обработка 404 статуса', async () => {
const outputPath = join(tempDir, 'output');
await execa('bun', [
@@ -226,7 +292,7 @@ describe('E2E Generation', () => {
const api = new Api();
try {
await api.user.getById('999');
await api.users.getById('999');
} catch (error) {
console.log('error');
}
@@ -238,7 +304,7 @@ describe('E2E Generation', () => {
expect(stdout).toContain('error');
}, 60000);
test.skip('Bearer token authentication', async () => {
test('Bearer token authentication', async () => {
const outputPath = join(tempDir, 'output');
await execa('bun', [
@@ -252,32 +318,38 @@ describe('E2E Generation', () => {
'AuthApi',
]);
const testFile = join(tempDir, 'test-auth.ts');
const testCode = `
import { Api } from '${join(outputPath, 'AuthApi.ts')}';
const api = new Api();
// Логин
const { token } = await api.auth.login({
email: 'test@example.com',
password: 'password'
});
// Установка токена
api.instance.setSecurityData(token);
// Запрос с токеном
const profile = await api.profile.get();
console.log(JSON.stringify(profile));
`;
// Динамически импортируем сгенерированный API
const generatedFile = join(outputPath, 'AuthApi.ts');
const { Api, HttpClient } = await import(generatedFile);
await Bun.write(testFile, testCode);
// Создаем HttpClient с securityWorker для добавления Bearer токена
const httpClient = new HttpClient({
securityWorker: (securityData: string | null) => {
if (securityData) {
return {
headers: {
Authorization: `Bearer ${securityData}`
}
};
}
}
});
const { stdout } = await execa('bun', ['run', testFile]);
const data = JSON.parse(stdout);
const api = new Api(httpClient);
expect(data.email).toBe('test@example.com');
// Логин
const loginResult = await api.auth.login({
email: 'test@example.com',
password: 'password'
});
// Установка токена
httpClient.setSecurityData(loginResult.token);
// Запрос с токеном
const profile = await api.profile.get();
expect(profile.email).toBe('test@example.com');
}, 60000);
});
});

View File

@@ -22,7 +22,7 @@ describe('Generated Client', () => {
});
describe('компиляция TypeScript', () => {
test.skip('сгенерированный код должен компилироваться без ошибок', async () => {
test('сгенерированный код должен компилироваться без ошибок', async () => {
const outputPath = join(tempDir, 'output');
const config: GeneratorConfig = {
inputPath: FIXTURES.VALID,
@@ -40,7 +40,7 @@ describe('Generated Client', () => {
expect(exitCode).toBe(0);
}, 30000);
test.skip('должны отсутствовать TypeScript ошибки', async () => {
test('должны отсутствовать TypeScript ошибки', async () => {
const outputPath = join(tempDir, 'output');
const config: GeneratorConfig = {
inputPath: FIXTURES.VALID,
@@ -161,7 +161,7 @@ describe('Generated Client', () => {
});
describe('различные форматы спецификаций', () => {
test.skip('должен работать с минимальной спецификацией', async () => {
test('должен работать с минимальной спецификацией', async () => {
const outputPath = join(tempDir, 'output');
const config: GeneratorConfig = {
inputPath: FIXTURES.MINIMAL,