diff --git a/.github/release-please/manifest.json b/.github/release-please/manifest.json index 8efb275..0bbe194 100644 --- a/.github/release-please/manifest.json +++ b/.github/release-please/manifest.json @@ -1 +1 @@ -{".":"0.4.0"} +{".":"0.4.1"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e098b5..bef5930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.4.1](https://github.com/do-pa/itdoc/compare/v0.4.0...v0.4.1) (2025-08-21) + + +### 🩹 Fixes + +* og_image is not working ([#231](https://github.com/do-pa/itdoc/issues/231)) ([d1070ec](https://github.com/do-pa/itdoc/commit/d1070ec1064cf914b576e2eb9153378903ccaf89)), closes [#230](https://github.com/do-pa/itdoc/issues/230) +* resolve issue causing LLM script to not run properly ([#235](https://github.com/do-pa/itdoc/issues/235)) ([1fa8d90](https://github.com/do-pa/itdoc/commit/1fa8d90fe14c37031baaa58f375550785f055275)) + ## [0.4.0](https://github.com/do-pa/itdoc/compare/v0.3.0...v0.4.0) (2025-08-08) diff --git a/bin/index.ts b/bin/index.ts index e55edac..9240706 100644 --- a/bin/index.ts +++ b/bin/index.ts @@ -55,22 +55,19 @@ Example: program .command("generate") .description("Generate ITDOC test code based on LLM.") - .option("-p, --path ", "Path to the markdown test spec file.") .option("-a, --app ", "Path to the Express root app file.") .option("-e, --env ", "Path to the .env file.") - .action((options: { path?: string; env?: string; app?: string }) => { + .action(async (options: { env?: string; app?: string }) => { const envPath = options.env ? path.isAbsolute(options.env) ? options.env : path.resolve(process.cwd(), options.env) : path.resolve(process.cwd(), ".env") - if (!options.path && !options.app) { + if (!options.app) { logger.error( - "Either a test spec path (-p) or an Express app path (-a) must be provided. By default, the OpenAI key (OPENAI_API_KEY in .env) is loaded from the root directory, but you can customize the path if needed", + "An Express app path (-a) must be provided. By default, the OpenAI key (OPENAI_API_KEY in .env) is loaded from the root directory, but you can customize the path with -e/--env if needed.", ) - logger.info("ex) itdoc generate -p ../md/testspec.md") - logger.info("ex) itdoc generate --path ../md/testspec.md") logger.info("ex) itdoc generate -a ../app.js") logger.info("ex) itdoc generate --app ../app.js") logger.info("ex) itdoc generate -a ../app.js -e ") @@ -80,12 +77,14 @@ program logger.box("ITDOC LLM START") if (options.app) { const appPath = resolvePath(options.app) + logger.info(`Running analysis based on Express app path: ${appPath}`) - generateByLLM("", appPath, envPath) - } else if (options.path) { - const specPath = resolvePath(options.path) - logger.info(`Running analysis based on test spec (MD) path: ${specPath}`) - generateByLLM(specPath, "", envPath) + try { + await generateByLLM(appPath, envPath) + } catch (err) { + logger.error(`LLM generation failed: ${(err as Error).message}`) + process.exit(1) + } } }) diff --git a/examples/express/__tests__/expressApp.test.js b/examples/express/__tests__/expressApp.test.js index 028179c..d88b119 100644 --- a/examples/express/__tests__/expressApp.test.js +++ b/examples/express/__tests__/expressApp.test.js @@ -1,7 +1,60 @@ const app = require("../expressApp.js") const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc") -const targetApp = app +const targetApp = app +describeAPI( + HttpMethod.POST, + "signup", + { + summary: "νšŒμ› κ°€μž… API", + tag: "Auth", + description: "μ‚¬μš©μžλ‘œ λΆ€ν„° 아이디와 νŒ¨μŠ€μ›Œλ“œλ₯Ό λ°›μ•„ νšŒμ›κ°€μž…μ„ μˆ˜ν–‰ν•©λ‹ˆλ‹€.", + }, + targetApp, + (apiDoc) => { + itDoc("νšŒμ›κ°€μž… 성곡", async () => { + await apiDoc + .test() + .prettyPrint() + .req() + .body({ + username: field("μ‚¬μš©μž 이름", "username"), + password: field("νŒ¨μŠ€μ›Œλ“œ", "P@ssw0rd123!@#"), + }) + .res() + .status(HttpStatus.CREATED) + }) + + itDoc("아이디λ₯Ό μž…λ ₯ν•˜μ§€ μ•ŠμœΌλ©΄ νšŒμ›κ°€μž… μ‹€νŒ¨ν•œλ‹€.", async () => { + await apiDoc + .test() + .req() + .body({ + password: field("νŒ¨μŠ€μ›Œλ“œ", "P@ssw0rd123!@#"), + }) + .res() + .status(HttpStatus.BAD_REQUEST) + .body({ + error: field("μ—λŸ¬ λ©”μ‹œμ§€", "username is required"), + }) + }) + + itDoc("νŒ¨μŠ€μ›Œλ“œκ°€ 8자 미만이면 νšŒμ›κ°€μž… μ‹€νŒ¨ν•œλ‹€.", async () => { + await apiDoc + .test() + .req() + .body({ + username: field("아이디", "penekhun"), + password: field("νŒ¨μŠ€μ›Œλ“œ", "1234567"), + }) + .res() + .status(HttpStatus.BAD_REQUEST) + .body({ + error: field("μ—λŸ¬ λ©”μ‹œμ§€", "password must be at least 8 characters"), + }) + }) + }, +) describeAPI( HttpMethod.GET, @@ -419,7 +472,7 @@ describeAPI( .test() .req() .header({ - "If-None-Match": field("ETag κ°’", '"abc123"'), + "if-none-match": field("ETag κ°’", '"abc123"'), Accept: "application/json", "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7", }) @@ -440,57 +493,6 @@ describeAPI( }) }, ) - -describeAPI( - HttpMethod.POST, - "/validate", - { - summary: "데이터 μœ νš¨μ„± 검증 API", - tag: "Validation", - description: "λ‹€μ–‘ν•œ ν˜•νƒœμ˜ 데이터 μœ νš¨μ„±μ„ κ²€μ¦ν•˜κ³  μƒμ„Έν•œ 였λ₯˜ 정보λ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€.", - }, - targetApp, - (apiDoc) => { - itDoc("λ‹€μ–‘ν•œ ν•„λ“œ μœ νš¨μ„± 였λ₯˜", async () => { - await apiDoc - .test() - .req() - .body({ - username: field("잘λͺ»λœ μ‚¬μš©μžλͺ…", "a"), - email: field("잘λͺ»λœ 이메일", "not-an-email"), - age: field("잘λͺ»λœ λ‚˜μ΄", -5), - registrationDate: field("잘λͺ»λœ λ‚ μ§œ", "2023-13-45"), - }) - .res() - .status(HttpStatus.BAD_REQUEST) - .body({ - success: false, - errors: field("였λ₯˜ λͺ©λ‘", [ - { - field: "username", - message: "Username must be at least 3 characters", - code: "MIN_LENGTH", - }, - { - field: "email", - message: "Invalid email format", - code: "INVALID_FORMAT", - }, - { - field: "age", - message: "Age must be a positive number", - code: "POSITIVE_NUMBER", - }, - { - field: "registrationDate", - message: "Invalid date format", - code: "INVALID_DATE", - }, - ]), - }) - }) - }, -) describeAPI( HttpMethod.GET, "/failed-test", diff --git a/examples/express/__tests__/expressApp2.test.js b/examples/express/__tests__/expressApp2.test.js deleted file mode 100644 index a0067ef..0000000 --- a/examples/express/__tests__/expressApp2.test.js +++ /dev/null @@ -1,58 +0,0 @@ -const app = require("../expressApp.js") -const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc") - -const targetApp = app - -describeAPI( - HttpMethod.POST, - "signup", - { - summary: "νšŒμ› κ°€μž… API", - tag: "Auth", - description: "μ‚¬μš©μžλ‘œ λΆ€ν„° 아이디와 νŒ¨μŠ€μ›Œλ“œλ₯Ό λ°›μ•„ νšŒμ›κ°€μž…μ„ μˆ˜ν–‰ν•©λ‹ˆλ‹€.", - }, - targetApp, - (apiDoc) => { - itDoc("νšŒμ›κ°€μž… 성곡", async () => { - await apiDoc - .test() - .prettyPrint() - .req() - .body({ - username: field("μ‚¬μš©μž 이름", "username"), - password: field("νŒ¨μŠ€μ›Œλ“œ", "P@ssw0rd123!@#"), - }) - .res() - .status(HttpStatus.CREATED) - }) - - itDoc("아이디λ₯Ό μž…λ ₯ν•˜μ§€ μ•ŠμœΌλ©΄ νšŒμ›κ°€μž… μ‹€νŒ¨ν•œλ‹€.", async () => { - await apiDoc - .test() - .req() - .body({ - password: field("νŒ¨μŠ€μ›Œλ“œ", "P@ssw0rd123!@#"), - }) - .res() - .status(HttpStatus.BAD_REQUEST) - .body({ - error: field("μ—λŸ¬ λ©”μ‹œμ§€", "username is required"), - }) - }) - - itDoc("νŒ¨μŠ€μ›Œλ“œκ°€ 8자 미만이면 νšŒμ›κ°€μž… μ‹€νŒ¨ν•œλ‹€.", async () => { - await apiDoc - .test() - .req() - .body({ - username: field("아이디", "penekhun"), - password: field("νŒ¨μŠ€μ›Œλ“œ", "1234567"), - }) - .res() - .status(HttpStatus.BAD_REQUEST) - .body({ - error: field("μ—λŸ¬ λ©”μ‹œμ§€", "password must be at least 8 characters"), - }) - }) - }, -) \ No newline at end of file diff --git a/examples/express/expected/oas.json b/examples/express/expected/oas.json index 9a51277..22475a0 100644 --- a/examples/express/expected/oas.json +++ b/examples/express/expected/oas.json @@ -89,7 +89,7 @@ } } }, - "/users/:userId": { + "/users/{userId}": { "get": { "summary": "μ‚¬μš©μž 쑰회 API", "tags": ["User"], @@ -383,7 +383,7 @@ } } }, - "/users/:userId/friends/:friendName": { + "/users/{userId}/friends/{friendName}": { "delete": { "summary": "νŠΉμ • μ‚¬μš©μžμ˜ 친ꡬλ₯Ό μ‚­μ œν•©λ‹ˆλ‹€.", "tags": ["User"], @@ -646,7 +646,7 @@ "operationId": "postOrders", "parameters": [ { - "name": "X-Request-ID", + "name": "x-request-id", "in": "header", "schema": { "type": "string", @@ -1179,7 +1179,7 @@ "operationId": "getCached-data", "parameters": [ { - "name": "If-None-Match", + "name": "if-none-match", "in": "header", "schema": { "type": "string", @@ -1188,7 +1188,7 @@ "required": false }, { - "name": "Accept", + "name": "accept", "in": "header", "schema": { "type": "string", @@ -1197,7 +1197,7 @@ "required": false }, { - "name": "Accept-Language", + "name": "accept-language", "in": "header", "schema": { "type": "string", @@ -1261,127 +1261,6 @@ } } }, - "/validate": { - "post": { - "summary": "데이터 μœ νš¨μ„± 검증 API", - "tags": ["Validation"], - "description": "λ‹€μ–‘ν•œ ν˜•νƒœμ˜ 데이터 μœ νš¨μ„±μ„ κ²€μ¦ν•˜κ³  μƒμ„Έν•œ 였λ₯˜ 정보λ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€.", - "operationId": "postValidate", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "username": { - "type": "string", - "example": "a", - "description": "잘λͺ»λœ μ‚¬μš©μžλͺ…" - }, - "email": { - "type": "string", - "example": "not-an-email", - "description": "잘λͺ»λœ 이메일" - }, - "age": { - "type": "integer", - "example": -5, - "description": "잘λͺ»λœ λ‚˜μ΄" - }, - "registrationDate": { - "type": "string", - "format": "date", - "example": "2023-13-45", - "description": "잘λͺ»λœ λ‚ μ§œ" - } - }, - "required": ["username", "email", "age", "registrationDate"] - }, - "example": { - "username": "a", - "email": "not-an-email", - "age": -5, - "registrationDate": "2023-13-45" - } - } - }, - "required": true - }, - "security": [{}], - "responses": { - "400": { - "description": "λ‹€μ–‘ν•œ ν•„λ“œ μœ νš¨μ„± 였λ₯˜", - "content": { - "application/json; charset=utf-8": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "field": { - "type": "string", - "example": "username" - }, - "message": { - "type": "string", - "example": "Username must be at least 3 characters" - }, - "code": { - "type": "string", - "example": "MIN_LENGTH" - } - } - }, - "description": "였λ₯˜ λͺ©λ‘", - "example": [ - { - "field": "username", - "message": "Username must be at least 3 characters", - "code": "MIN_LENGTH" - }, - { - "field": "email", - "message": "Invalid email format", - "code": "INVALID_FORMAT" - }, - { - "field": "age", - "message": "Age must be a positive number", - "code": "POSITIVE_NUMBER" - }, - { - "field": "registrationDate", - "message": "Invalid date format", - "code": "INVALID_DATE" - } - ] - } - }, - "required": ["errors"] - }, - "examples": { - "λ‹€μ–‘ν•œ ν•„λ“œ μœ νš¨μ„± 였λ₯˜": { - "value": { - "error": { - "message": "λ‹€μ–‘ν•œ ν•„λ“œ μœ νš¨μ„± 였λ₯˜", - "code": "ERROR_400" - } - } - } - } - } - } - } - } - } - }, "/failed-test": { "get": { "summary": "ν…ŒμŠ€νŠΈ μ‹€νŒ¨ μœ λ„ API", diff --git a/examples/express/expressApp.js b/examples/express/expressApp.js index be30aa5..83a9f65 100644 --- a/examples/express/expressApp.js +++ b/examples/express/expressApp.js @@ -5,26 +5,32 @@ const app = express() app.use(express.json()) app.post("/signup", function (req, res) { - const { username, password } = req.body - - if (!username) { - return res.status(400).json({ - error: "username is required", + try { + const { username, password } = req.body + + if (!username) { + return res.status(400).json({ + error: "username is required", + }) + } + + if (!password) { + return res.status(400).json({ + error: "password is required", + }) + } + + if (password.length < 8) { + return res.status(400).json({ + error: "password must be at least 8 characters", + }) + } + return res.status(201).json() + } catch (err) { + return res.status(500).json({ + error: "Internal Server Error", }) } - - if (!password) { - return res.status(400).json({ - error: "password is required", - }) - } - if (password.length < 8) { - return res.status(400).json({ - error: "password must be at least 8 characters", - }) - } - - return res.status(201).json() }) app.get("/users/:userId", (req, res) => { @@ -39,6 +45,7 @@ app.get("/users/:userId", (req, res) => { username: "hun", email: "penekhun@gmail.com", friends: ["zagabi", "json"], + // fetchedAt: new Date().toISOString(), }) }) @@ -97,8 +104,6 @@ app.get("/users", (req, res) => { error: "size are required", }) } - - // sample pagination const pageNumber = parseInt(page) const sizeNumber = parseInt(size) const startIndex = (pageNumber - 1) * sizeNumber @@ -120,7 +125,7 @@ app.get("/secret", (req, res) => { } res.set({ "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", + "Content-Type": "application/json; charset=utf-8", "itdoc-custom-Header": "secret-header-value", Authorization: "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyMDI1MDQwNiIsIm5hbWUiOiJpdGRvYyIsImFkbWluIjp0cnVlLCJpYXQiOjE3NDM5MjQzNDEsImV4cCI6MTc0MzkyNzk0MX0.LXswgSAv_hjAH3KntMqnr-aLxO4ZytGeXk5q8lzzUM8", @@ -130,7 +135,6 @@ app.get("/secret", (req, res) => { }) }) -// PUT μš”μ²­μœΌλ‘œ μ‚¬μš©μž 정보 μˆ˜μ • API app.put("/users/:userId", (req, res) => { const { userId } = req.params @@ -147,7 +151,6 @@ app.put("/users/:userId", (req, res) => { } }) -// PATCH μš”μ²­μœΌλ‘œ μ‚¬μš©μž λΆ€λΆ„ 정보 μˆ˜μ • API app.patch("/users/:userId", (req, res) => { const { userId } = req.params const { email } = req.body @@ -166,36 +169,6 @@ app.patch("/users/:userId", (req, res) => { } }) -// ν”„λ‘œν•„ 이미지 μ—…λ‘œλ“œ API -app.post("/users/:userId/profile-image", (req, res) => { - const { userId } = req.params - const contentType = req.headers["content-type"] - - if (!contentType || !contentType.includes("multipart/form-data")) { - return res.status(400).json({ - success: false, - message: "Content-Type must be multipart/form-data", - }) - } - - // 파일 ν™•μž₯자 확인 (ν…ŒμŠ€νŠΈ λͺ©μ ) - const fileExtension = - req.body && req.body.image ? req.body.image.split(".").pop().toLowerCase() : "" - - if (["jpg", "jpeg", "png", "gif"].includes(fileExtension)) { - return res.status(200).json({ - success: true, - imageUrl: `https://example.com/images/${userId}.jpg`, - }) - } else { - return res.status(400).json({ - success: false, - message: "Unsupported file type. Only jpg, png, gif are allowed.", - }) - } -}) - -// μ£Όλ¬Έ 생성 API app.post("/orders", (req, res) => { const { authorization } = req.headers @@ -206,7 +179,6 @@ app.post("/orders", (req, res) => { }) } - // κ°„λ‹¨ν•œ κ²€μ¦λ§Œ μˆ˜ν–‰ const { customer, items } = req.body if (!customer || !items) { @@ -224,9 +196,7 @@ app.post("/orders", (req, res) => { }) }) -// μƒν’ˆ 검색 API app.get("/products", (req, res) => { - // λͺ¨λ“  쿼리 νŒŒλΌλ―Έν„° μ œκ³΅λ˜μ—ˆλ‹€κ³  κ°€μ • return res.status(200).json({ products: [ { @@ -254,10 +224,9 @@ app.get("/products", (req, res) => { }) }) -// μΊμ‹œλœ 데이터 쑰회 API app.get("/cached-data", (req, res) => { const ifNoneMatch = req.headers["if-none-match"] - + if (ifNoneMatch === '"abc123"') { res.setHeader("ETag", '"abc123"') res.setHeader("Cache-Control", "max-age=3600") @@ -276,59 +245,7 @@ app.get("/cached-data", (req, res) => { } }) -// 데이터 μœ νš¨μ„± 검증 API -app.post("/validate", (req, res) => { - const { username, email, age, registrationDate } = req.body - const errors = [] - - if (!username || username.length < 3) { - errors.push({ - field: "username", - message: "Username must be at least 3 characters", - code: "MIN_LENGTH", - }) - } - - if (!email || !email.includes("@")) { - errors.push({ - field: "email", - message: "Invalid email format", - code: "INVALID_FORMAT", - }) - } - - if (typeof age !== "number" || age <= 0) { - errors.push({ - field: "age", - message: "Age must be a positive number", - code: "POSITIVE_NUMBER", - }) - } - - if (registrationDate && !Date.parse(registrationDate)) { - errors.push({ - field: "registrationDate", - message: "Invalid date format", - code: "INVALID_DATE", - }) - } - - if (errors.length > 0) { - return res.status(400).json({ - success: false, - errors, - }) - } - - return res.status(200).json({ - success: true, - message: "All fields are valid", - }) -}) - -// μ˜λ„μ μœΌλ‘œ μ‹€νŒ¨ν•˜λŠ” ν…ŒμŠ€νŠΈλ₯Ό μœ„ν•œ API μ—”λ“œν¬μΈνŠΈ app.get("/failed-test", (req, res) => { - // ν…ŒμŠ€νŠΈμ—μ„œλŠ” 200(OK)을 κΈ°λŒ€ν•˜μ§€λ§Œ 404λ₯Ό λ°˜ν™˜ν•˜μ—¬ μ˜λ„μ μœΌλ‘œ μ‹€νŒ¨ return res.status(404).json({ message: "This endpoint is designed to make tests fail", }) diff --git a/itdoc-doc/docs/experiments/LLM.mdx b/itdoc-doc/docs/experiments/LLM.mdx index 619e7f8..6101ea1 100644 --- a/itdoc-doc/docs/experiments/LLM.mdx +++ b/itdoc-doc/docs/experiments/LLM.mdx @@ -194,62 +194,4 @@ itdoc generate --app {YOUR_APP_FILE_PATH} |------------------------------------------------------------------------|--------------------------------| | --app (-a) | Root app source code file path | -When you run this command, it analyzes the Express application defined in `{YOUR_APP_FILE_PATH}` and automatically generates tests for the API endpoints in that application. - -:::info[itdoc does not send source code to external servers] -It creates an `API Spec Markdown` through its own AST analysis and sends this to ChatGPT to generate test cases. -Therefore, you can use it with confidence as your source code is not sent to external servers. -::: - -### Generating Tests from API Spec Markdown - -You can automatically generate tests based on an `API Spec Markdown` file with the following command: - -```bash -itdoc generate --p {API_Spec_Markdown_FILE_PATH} -itdoc generate --path {API_Spec_Markdown_FILE_PATH} -``` - -| Option | Description | -|-------------------------------------------------------------------------|-------------------------------| -| --path (-p) | `API Spec Markdown` file path | - -The document format should follow this structure: - -```markdown title="api-specs.md" {1-4} -`HTTP_METHOD` `ENDPOINT` -- Test Case: Test case title - - Request: Description of the request - - Response: Description of the response -``` - -Here are examples for various API cases: - - - - - ```markdown title="Authentication API Example" - GET /secret - - Test Case: Access secret message with proper authentication - - Request: Send GET request with valid authentication header - - Response: Status code 200, JSON response with secret message and specific headers - - Test Case: Unauthorized access - - Request: Send GET request with invalid authentication header - - Response: Returns status code 401 - ``` - - - - - ```markdown title="File Upload API Example" - POST /users/:userId/profile-image - - Test Case: Successfully upload profile image - - Request: Send POST request with valid image file and "multipart/form-data" content type - - Response: Status code 200, JSON response with image URL - - Test Case: Wrong content type - - Request: Send POST request without "multipart/form-data" content type - - Response: Status code 400, JSON response with error message - ``` - - - +When you run this command, it analyzes the Express application defined in `{YOUR_APP_FILE_PATH}` and automatically generates tests for the API endpoints in that application. \ No newline at end of file diff --git a/itdoc-doc/docusaurus.config.ts b/itdoc-doc/docusaurus.config.ts index 83dbcb0..3602783 100644 --- a/itdoc-doc/docusaurus.config.ts +++ b/itdoc-doc/docusaurus.config.ts @@ -59,7 +59,7 @@ const config: Config = { ], ], themeConfig: { - image: "img/logo.jpg", + image: "img/logo.png", navbar: { title: "itdoc", logo: { diff --git a/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts b/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts index 26860f4..53d5c9f 100644 --- a/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts +++ b/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts @@ -231,4 +231,42 @@ describe("OpenAPIGenerator", () => { assert.isUndefined(spec.paths["/test/no-body-responses"].get.responses["400"].content) }) }) + + describe("normalizePathTemplate", () => { + it("should handle paths without parameters", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("/users") + assert.strictEqual(normalized, "/users") + }) + + it("should handle mixed format paths", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("/users/{userId}/posts/:postId") + assert.strictEqual(normalized, "/users/{userId}/posts/{postId}") + }) + + it("should handle parameters with underscores", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("/items/:item_id") + assert.strictEqual(normalized, "/items/{item_id}") + }) + + it("should handle empty path", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("") + assert.strictEqual(normalized, "") + }) + + it("should handle root path", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("/") + assert.strictEqual(normalized, "/") + }) + + it("should handle hyphenated parameter names", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("/files/:file-name") + assert.strictEqual(normalized, "/files/{file-name}") + }) + }) }) diff --git a/lib/dsl/generator/OpenAPIGenerator.ts b/lib/dsl/generator/OpenAPIGenerator.ts index c2f55ec..88a0afa 100644 --- a/lib/dsl/generator/OpenAPIGenerator.ts +++ b/lib/dsl/generator/OpenAPIGenerator.ts @@ -34,6 +34,9 @@ interface OpenAPIInfo { /** * OpenAPI Specification generator + * + * It operates in a Singleton pattern and collects test results + * Create a Specification document in OpenAPI 3.0.0 format. */ export class OpenAPIGenerator implements IOpenAPIGenerator { private testResults: TestResult[] = [] @@ -113,8 +116,6 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { groupedResults.get(path)!.get(method)!.get(statusCode)!.push(result) } - logger.info("Grouped test results:", groupedResults) - return groupedResults } @@ -129,10 +130,15 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { const paths: Record> = {} for (const [path, methods] of groupedResults) { - paths[path] = {} + const normalizedPath = this.normalizePathTemplate(path) + paths[normalizedPath] = {} for (const [method, statusCodes] of methods) { - paths[path][method] = this.generateOperationObject(path, method, statusCodes) + paths[normalizedPath][method] = this.generateOperationObject( + normalizedPath, + method, + statusCodes, + ) } } @@ -490,7 +496,7 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { */ private selectRepresentativeResult(results: TestResult[]): TestResult { const authTestCase = results.find( - (result) => result.request.headers && "Authorization" in result.request.headers, + (result) => result.request.headers && "authorization" in result.request.headers, ) if (authTestCase) { @@ -532,8 +538,7 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { */ private validatePathParameters(paths: Record>): void { for (const [path, pathItem] of Object.entries(paths)) { - const pathParamMatches = path.match(/:([^/]+)/g) || [] - const pathParams = pathParamMatches.map((param) => param.slice(1)) + const pathParams = this.extractPathParameterNames(path) if (pathParams.length === 0) continue @@ -570,6 +575,30 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { } } + /** + * Converts colon-prefixed Express parameters to OpenAPI-compatible templates. + * @param {string} path Raw route path + * @returns {string} Normalized OpenAPI path + */ + private normalizePathTemplate(path: string): string { + return path.replace(/:([A-Za-z0-9_-]+)/g, "{$1}") + } + + /** + * Extracts parameter names from a normalized or raw path template. + * @param {string} path Path string potentially containing parameters + * @returns {string[]} Parameter names + */ + private extractPathParameterNames(path: string): string[] { + const braceMatches = path.match(/\{([^}]+)\}/g) || [] + if (braceMatches.length > 0) { + return braceMatches.map((param) => param.slice(1, -1)) + } + + const colonMatches = path.match(/:([^/]+)/g) || [] + return colonMatches.map((param) => param.slice(1)) + } + /** * Normalizes examples according to the schema. * @param {any} example Example value diff --git a/lib/dsl/generator/builders/operation/SecurityBuilder.ts b/lib/dsl/generator/builders/operation/SecurityBuilder.ts index 5bad9e3..f2d16d0 100644 --- a/lib/dsl/generator/builders/operation/SecurityBuilder.ts +++ b/lib/dsl/generator/builders/operation/SecurityBuilder.ts @@ -32,8 +32,8 @@ export class SecurityBuilder implements SecurityBuilderInterface { public extractSecurityRequirements(result: TestResult): Array> { const security: Array> = [] - if (result.request.headers && "Authorization" in result.request.headers) { - const authHeaderValue = result.request.headers["Authorization"] + if (result.request.headers && "authorization" in result.request.headers) { + const authHeaderValue = result.request.headers["authorization"] let authHeader = "" if (typeof authHeaderValue === "string") { diff --git a/lib/dsl/index.ts b/lib/dsl/index.ts index 5692bb7..0cbe109 100644 --- a/lib/dsl/index.ts +++ b/lib/dsl/index.ts @@ -18,3 +18,6 @@ export { HttpMethod } from "./enums/HttpMethod" export { HttpStatus } from "./enums/HttpStatus" export { describeAPI, itDoc, field } from "./interface" export type { ApiDocOptions } from "./interface/ItdocBuilderEntry" + +export { wrapTest, createClient } from "../wrappers" +export type { ApiDocMetadata, WrappedTestFunction } from "../wrappers" diff --git a/lib/dsl/test-builders/RequestBuilder.ts b/lib/dsl/test-builders/RequestBuilder.ts index 0cdf3c5..8b4ae50 100644 --- a/lib/dsl/test-builders/RequestBuilder.ts +++ b/lib/dsl/test-builders/RequestBuilder.ts @@ -19,18 +19,34 @@ import { DSLField } from "../interface" import { ResponseBuilder } from "./ResponseBuilder" import { FIELD_TYPES } from "../interface/field" import { AbstractTestBuilder } from "./AbstractTestBuilder" +import logger from "../../config/logger" /** * Builder class for setting API request information. */ export class RequestBuilder extends AbstractTestBuilder { /** - * Sets headers to be used in requests. + * Sets headers to be used in requests. Header names are normalized to lowercase. * @param {Record>} headers Headers to be used in requests * @returns {this} Request builder instance */ public header(headers: Record>): this { - this.config.requestHeaders = headers + const normalizedHeaders: Record> = {} + const seen = new Set() + + Object.entries(headers).forEach(([headerName, headerValue]) => { + const normalized = headerName.toLowerCase() + + if (seen.has(normalized)) { + logger.warn(`Duplicate header detected: "${headerName}" (already set)`) + return + } + + seen.add(normalized) + normalizedHeaders[normalized] = headerValue + }) + + this.config.requestHeaders = normalizedHeaders return this } diff --git a/lib/dsl/test-builders/validateResponse.ts b/lib/dsl/test-builders/validateResponse.ts index 60d3a72..724e177 100644 --- a/lib/dsl/test-builders/validateResponse.ts +++ b/lib/dsl/test-builders/validateResponse.ts @@ -25,18 +25,21 @@ import { isDSLField } from "../interface/field" * @see {@link import('../interface/field.ts').field} */ const validateDSLField = (expectedDSL: any, actualVal: any, path: string): void => { + const example = expectedDSL.example + if (example === undefined) { + throw new Error( + `The example value of the DSL field at response body[${path}] is undefined. Skipping validation for this field.`, + ) + } + // DSL Field의 example이 ν•¨μˆ˜μΈ 경우 if (typeof expectedDSL.example === "function") { - expectedDSL.example(actualVal) + validateFunction(expectedDSL.example, actualVal) return } // DSL Field의 example이 객체인 경우 - if ( - expectedDSL.example && - typeof expectedDSL.example === "object" && - expectedDSL.example !== null - ) { + if (example && typeof example === "object") { if (isDSLField(actualVal)) { validateResponse(expectedDSL.example, actualVal.example, path) } else { @@ -80,6 +83,20 @@ const validateArray = (expectedArr: any[], actualArr: any[], path: string): void }) } +const validateFunction = (func: (actualValue: any) => any, actualVal: any): void => { + const argsCount = func.length + if (argsCount > 1) { + throw new Error( + `Validator function should have at most one argument, but got ${argsCount}. + Please check the following function: + + ${func.toString()}`, + ) + } + + func(actualVal) +} + /** * Function that performs **actual validation** of API response values defined in `ResponseBuilder`. * Performs validation by branching for various types such as arrays, objects, etc. @@ -97,7 +114,7 @@ export const validateResponse = (expected: any, actual: any, path: string = ""): } // 객체인 경우 (null μ œμ™Έ) - if (expected && typeof expected === "object" && expected !== null) { + if (expected && typeof expected === "object") { for (const key in expected) { const currentPath = path ? `${path}.${key}` : key const expectedVal = expected[key] @@ -107,8 +124,10 @@ export const validateResponse = (expected: any, actual: any, path: string = ""): validateDSLField(expectedVal, actualVal, currentPath) } else if (Array.isArray(expectedVal)) { validateArray(expectedVal, actualVal, currentPath) - } else if (expectedVal && typeof expectedVal === "object" && expectedVal !== null) { - if (!actualVal || typeof actualVal !== "object" || actualVal === null) { + } else if (typeof expectedVal === "function") { + validateFunction(expectedVal, actualVal) + } else if (expectedVal && typeof expectedVal === "object") { + if (!actualVal || typeof actualVal !== "object") { throw new Error( `Expected response body[${currentPath}] to be an object but got ${actualVal}`, ) diff --git a/lib/wrappers/__tests__/CaptureContext.test.ts b/lib/wrappers/__tests__/CaptureContext.test.ts new file mode 100644 index 0000000..30d56ce --- /dev/null +++ b/lib/wrappers/__tests__/CaptureContext.test.ts @@ -0,0 +1,188 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it } from "mocha" +import { expect } from "chai" +import { CaptureContext } from "../core/CaptureContext" + +describe("CaptureContext", () => { + describe("isActive", () => { + it("should return false when not in context", () => { + void expect(CaptureContext.isActive()).to.be.false + }) + + it("should return true when in context", () => { + CaptureContext.run("test", undefined, () => { + void expect(CaptureContext.isActive()).to.be.true + }) + }) + + it("should return false after context ends", () => { + CaptureContext.run("test", undefined, () => { + // inside context + }) + void expect(CaptureContext.isActive()).to.be.false + }) + }) + + describe("getStore", () => { + it("should return undefined when not in context", () => { + void expect(CaptureContext.getStore()).to.be.undefined + }) + + it("should return store when in context", () => { + CaptureContext.run("test description", { summary: "Test" }, () => { + const store = CaptureContext.getStore() + void expect(store).to.not.be.undefined + void expect(store?.description).to.equal("test description") + void expect(store?.metadata?.summary).to.equal("Test") + void expect(store?.capturedRequests).to.be.an("array").that.is.empty + }) + }) + }) + + describe("addRequest", () => { + it("should add request to store", () => { + CaptureContext.run("test", undefined, () => { + CaptureContext.addRequest({ + method: "POST", + url: "/users", + }) + + const requests = CaptureContext.getCapturedRequests() + expect(requests).to.have.lengthOf(1) + expect(requests[0].method).to.equal("POST") + expect(requests[0].url).to.equal("/users") + }) + }) + + it("should add multiple requests", () => { + CaptureContext.run("test", undefined, () => { + CaptureContext.addRequest({ method: "GET", url: "/users" }) + CaptureContext.addRequest({ method: "POST", url: "/users" }) + + const requests = CaptureContext.getCapturedRequests() + expect(requests).to.have.lengthOf(2) + }) + }) + + it("should not add request when not in context", () => { + CaptureContext.addRequest({ method: "POST", url: "/users" }) + void expect(CaptureContext.getCapturedRequests()).to.be.empty + }) + }) + + describe("updateLastRequest", () => { + it("should update last request with additional data", () => { + CaptureContext.run("test", undefined, () => { + CaptureContext.addRequest({ method: "POST", url: "/users" }) + CaptureContext.updateLastRequest({ body: { name: "John" } }) + + const requests = CaptureContext.getCapturedRequests() + expect(requests[0].body).to.deep.equal({ name: "John" }) + }) + }) + + it("should merge data with existing request", () => { + CaptureContext.run("test", undefined, () => { + CaptureContext.addRequest({ + method: "POST", + url: "/users", + headers: { "Content-Type": "application/json" }, + }) + CaptureContext.updateLastRequest({ + body: { name: "John" }, + }) + + const requests = CaptureContext.getCapturedRequests() + expect(requests[0]).to.deep.include({ + method: "POST", + url: "/users", + body: { name: "John" }, + }) + expect(requests[0].headers).to.deep.equal({ + "Content-Type": "application/json", + }) + }) + }) + + it("should do nothing when no requests exist", () => { + CaptureContext.run("test", undefined, () => { + CaptureContext.updateLastRequest({ body: { name: "John" } }) + void expect(CaptureContext.getCapturedRequests()).to.be.empty + }) + }) + + it("should do nothing when not in context", () => { + CaptureContext.updateLastRequest({ body: { name: "John" } }) + void expect(CaptureContext.getCapturedRequests()).to.be.empty + }) + }) + + describe("clear", () => { + it("should clear all captured requests", () => { + CaptureContext.run("test", undefined, () => { + CaptureContext.addRequest({ method: "GET", url: "/users" }) + CaptureContext.addRequest({ method: "POST", url: "/users" }) + + void expect(CaptureContext.getCapturedRequests()).to.have.lengthOf(2) + + CaptureContext.clear() + + void expect(CaptureContext.getCapturedRequests()).to.be.empty + }) + }) + }) + + describe("nested contexts", () => { + it("should isolate contexts properly", () => { + CaptureContext.run("outer", undefined, () => { + CaptureContext.addRequest({ method: "GET", url: "/outer" }) + + CaptureContext.run("inner", undefined, () => { + CaptureContext.addRequest({ method: "POST", url: "/inner" }) + + const innerRequests = CaptureContext.getCapturedRequests() + expect(innerRequests).to.have.lengthOf(1) + expect(innerRequests[0].url).to.equal("/inner") + }) + + const outerRequests = CaptureContext.getCapturedRequests() + expect(outerRequests).to.have.lengthOf(1) + expect(outerRequests[0].url).to.equal("/outer") + }) + }) + }) + + describe("async support", () => { + it("should work with async functions", async () => { + await CaptureContext.run("test", undefined, async () => { + CaptureContext.addRequest({ method: "GET", url: "/users" }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + CaptureContext.updateLastRequest({ body: { name: "John" } }) + + const requests = CaptureContext.getCapturedRequests() + expect(requests[0]).to.deep.include({ + method: "GET", + url: "/users", + body: { name: "John" }, + }) + }) + }) + }) +}) diff --git a/lib/wrappers/__tests__/integration.test.ts b/lib/wrappers/__tests__/integration.test.ts new file mode 100644 index 0000000..9f0c2c4 --- /dev/null +++ b/lib/wrappers/__tests__/integration.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it } from "mocha" +import { expect } from "chai" +import express from "express" +import { wrapTest, createClient } from "../index" + +describe("wrapTest integration", () => { + let app: express.Application + + beforeEach(() => { + app = express() + app.use(express.json()) + + app.post("/auth/signup", (req, res) => { + const { username, password } = req.body + if (!username || !password) { + res.status(400).json({ error: "Missing fields" }) + return + } + res.status(201).json({ id: 1, username }) + }) + + app.post("/auth/login", (_req, res) => { + res.status(200).json({ token: "jwt-token-123" }) + }) + + app.get("/users/profile", (req, res) => { + const auth = req.headers.authorization + if (!auth) { + res.status(401).json({ error: "Unauthorized" }) + return + } + res.status(200).json({ id: 1, username: "john" }) + }) + }) + + describe("real-world usage scenarios", () => { + const apiTest = wrapTest(it) + + apiTest("should register new user successfully", async () => { + const response = await createClient.supertest(app).post("/auth/signup").send({ + username: "john", + password: "password123", + }) + + expect(response.status).to.equal(201) + expect(response.body).to.have.property("id") + expect(response.body.username).to.equal("john") + }) + + apiTest("should handle validation errors", async () => { + const response = await createClient.supertest(app).post("/auth/signup").send({ + username: "john", + // missing password + }) + + expect(response.status).to.equal(400) + expect(response.body.error).to.equal("Missing fields") + }) + + apiTest("should login and get profile", async () => { + const loginRes = await createClient.supertest(app).post("/auth/login").send({ + username: "john", + password: "password123", + }) + + expect(loginRes.status).to.equal(200) + const token = loginRes.body.token + + const profileRes = await createClient + .supertest(app) + .get("/users/profile") + .set("Authorization", `Bearer ${token}`) + + expect(profileRes.status).to.equal(200) + expect(profileRes.body.username).to.equal("john") + }) + + apiTest.withMeta({ + summary: "User Registration", + tags: ["Auth", "Users"], + description: "Register a new user account", + })("should create user with metadata", async () => { + const response = await createClient.supertest(app).post("/auth/signup").send({ + username: "jane", + password: "secure123", + }) + + expect(response.status).to.equal(201) + }) + }) + + describe("comparison with existing itDoc", () => { + it("should show the difference in usage", () => { + // OLD WAY (with itDoc): + // itDoc('should create user', async () => { + // const response = await req(app) + // .post('/users') + // .description('Create user') + // .tag('Users') + // .send({ name: 'John' }) + // .expect(201) + // }) + + // NEW WAY (with wrapTest): + // Example usage (commented out for now): + // const apiTest = wrapTest(it) + // apiTest('should create user', async () => { + // const response = await createClient.supertest(app) + // .post('/users') + // .send({ name: 'John' }) + // + // expect(response.status).toBe(201) + // }) + + void expect(true).to.be.true + }) + }) +}) diff --git a/lib/wrappers/__tests__/interceptedRequest.test.ts b/lib/wrappers/__tests__/interceptedRequest.test.ts new file mode 100644 index 0000000..696ccbe --- /dev/null +++ b/lib/wrappers/__tests__/interceptedRequest.test.ts @@ -0,0 +1,257 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, beforeEach } from "mocha" +import { expect } from "chai" +import express from "express" +import { createClient } from "../adapters" +import { CaptureContext } from "../core/CaptureContext" + +describe("createClient.supertest adapter", () => { + let app: express.Application + + beforeEach(() => { + app = express() + app.use(express.json()) + + app.get("/users", (_req, res) => { + res.status(200).json({ users: [] }) + }) + + app.post("/users", (req, res) => { + res.status(201).json({ id: 1, ...req.body }) + }) + + app.get("/users/:id", (req, res) => { + res.status(200).json({ id: req.params.id, name: "John" }) + }) + }) + + describe("without capture context", () => { + it("should work as normal supertest", async () => { + const response = await createClient.supertest(app).get("/users") + + expect(response.status).to.equal(200) + expect(response.body).to.deep.equal({ users: [] }) + }) + + it("should not capture any data", async () => { + await createClient.supertest(app).get("/users") + + void expect(CaptureContext.getCapturedRequests()).to.be.empty + }) + }) + + describe("with capture context", () => { + it("should capture GET request", async () => { + await CaptureContext.run("test", undefined, async () => { + await createClient.supertest(app).get("/users") + + const captured = CaptureContext.getCapturedRequests() + expect(captured).to.have.lengthOf(1) + expect(captured[0].method).to.equal("GET") + expect(captured[0].url).to.equal("/users") + }) + }) + + it("should capture POST request with body", async () => { + await CaptureContext.run("test", undefined, async () => { + await createClient + .supertest(app) + .post("/users") + .send({ name: "John", email: "john@test.com" }) + + const captured = CaptureContext.getCapturedRequests() + expect(captured).to.have.lengthOf(1) + expect(captured[0].method).to.equal("POST") + expect(captured[0].url).to.equal("/users") + expect(captured[0].body).to.deep.equal({ + name: "John", + email: "john@test.com", + }) + }) + }) + + it("should capture request headers", async () => { + await CaptureContext.run("test", undefined, async () => { + await createClient + .supertest(app) + .get("/users") + .set("Authorization", "Bearer token123") + .set("Accept", "application/json") + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].headers).to.include({ + Authorization: "Bearer token123", + Accept: "application/json", + }) + }) + }) + + it("should capture query parameters", async () => { + await CaptureContext.run("test", undefined, async () => { + await createClient.supertest(app).get("/users").query({ page: 1, limit: 10 }) + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].queryParams).to.deep.equal({ + page: 1, + limit: 10, + }) + }) + }) + + it("should capture response status and body", async () => { + await CaptureContext.run("test", undefined, async () => { + await createClient.supertest(app).get("/users") + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].response?.status).to.equal(200) + expect(captured[0].response?.body).to.deep.equal({ users: [] }) + }) + }) + + it("should capture response headers", async () => { + await CaptureContext.run("test", undefined, async () => { + await createClient.supertest(app).get("/users") + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].response?.headers).to.be.an("object") + expect(captured[0].response?.headers).to.have.property("content-type") + }) + }) + + it("should capture multiple requests in order", async () => { + await CaptureContext.run("test", undefined, async () => { + await createClient.supertest(app).post("/users").send({ name: "John" }) + await createClient.supertest(app).get("/users/1") + + const captured = CaptureContext.getCapturedRequests() + expect(captured).to.have.lengthOf(2) + expect(captured[0].method).to.equal("POST") + expect(captured[1].method).to.equal("GET") + }) + }) + + it("should handle request chain methods", async () => { + await CaptureContext.run("test", undefined, async () => { + await createClient + .supertest(app) + .post("/users") + .set("Authorization", "Bearer token") + .send({ name: "John" }) + .expect(201) + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0]).to.deep.include({ + method: "POST", + url: "/users", + body: { name: "John" }, + }) + expect(captured[0].headers).to.include({ + Authorization: "Bearer token", + }) + }) + }) + + it("should capture PUT requests", async () => { + app.put("/users/:id", (req, res) => { + res.status(200).json({ id: req.params.id, ...req.body }) + }) + + await CaptureContext.run("test", undefined, async () => { + await createClient.supertest(app).put("/users/1").send({ name: "Jane" }) + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].method).to.equal("PUT") + expect(captured[0].url).to.equal("/users/1") + }) + }) + + it("should capture DELETE requests", async () => { + app.delete("/users/:id", (_req, res) => { + res.status(204).send() + }) + + await CaptureContext.run("test", undefined, async () => { + await createClient.supertest(app).delete("/users/1") + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].method).to.equal("DELETE") + expect(captured[0].response?.status).to.equal(204) + }) + }) + + it("should work with expect() assertions", async () => { + await CaptureContext.run("test", undefined, async () => { + const response = await createClient.supertest(app).get("/users").expect(200) + + expect(response.body).to.deep.equal({ users: [] }) + + const captured = CaptureContext.getCapturedRequests() + expect(captured).to.have.lengthOf(1) + }) + }) + + it("should capture even when request fails", async () => { + app.get("/error", (_req, res) => { + res.status(500).json({ error: "Server error" }) + }) + + await CaptureContext.run("test", undefined, async () => { + await createClient.supertest(app).get("/error") + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].response?.status).to.equal(500) + expect(captured[0].response?.body).to.deep.equal({ + error: "Server error", + }) + }) + }) + }) + + describe("header setting variations", () => { + it("should capture headers set as object", async () => { + await CaptureContext.run("test", undefined, async () => { + await createClient.supertest(app).get("/users").set({ + Authorization: "Bearer token", + "X-Custom": "value", + }) + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].headers).to.include({ + Authorization: "Bearer token", + "X-Custom": "value", + }) + }) + }) + + it("should merge multiple set() calls", async () => { + await CaptureContext.run("test", undefined, async () => { + await createClient + .supertest(app) + .get("/users") + .set("Authorization", "Bearer token") + .set("Accept", "application/json") + + const captured = CaptureContext.getCapturedRequests() + expect(captured[0].headers).to.include({ + Authorization: "Bearer token", + Accept: "application/json", + }) + }) + }) + }) +}) diff --git a/lib/wrappers/__tests__/wrapTest.integration.test.ts b/lib/wrappers/__tests__/wrapTest.integration.test.ts new file mode 100644 index 0000000..9da52b1 --- /dev/null +++ b/lib/wrappers/__tests__/wrapTest.integration.test.ts @@ -0,0 +1,156 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it } from "mocha" +import { expect } from "chai" +import express from "express" +import { wrapTest, createClient } from "../index" + +describe("wrapTest integration", () => { + let app: express.Application + + beforeEach(() => { + app = express() + app.use(express.json()) + + app.get("/users", (req, res) => { + res.status(200).json({ users: [] }) + }) + + app.post("/users", (req, res) => { + res.status(201).json({ id: 1, ...req.body }) + }) + + app.get("/users/:id", (req, res) => { + res.status(200).json({ id: req.params.id, name: "John" }) + }) + }) + + describe("basic usage", () => { + const apiTest = wrapTest(it) + + apiTest("should make GET request successfully", async () => { + const response = await createClient.supertest(app).get("/users") + + expect(response.status).to.equal(200) + expect(response.body).to.deep.equal({ users: [] }) + }) + + apiTest("should make POST request with body", async () => { + const response = await createClient.supertest(app).post("/users").send({ + name: "John", + email: "john@test.com", + }) + + expect(response.status).to.equal(201) + expect(response.body).to.have.property("id") + expect(response.body.name).to.equal("John") + }) + + apiTest("should send headers", async () => { + const response = await createClient + .supertest(app) + .get("/users") + .set("Authorization", "Bearer token123") + + expect(response.status).to.equal(200) + }) + + apiTest("should send query parameters", async () => { + const response = await createClient + .supertest(app) + .get("/users") + .query({ page: 1, limit: 10 }) + + expect(response.status).to.equal(200) + }) + + apiTest("should handle multiple requests in one test", async () => { + const createRes = await createClient + .supertest(app) + .post("/users") + .send({ name: "John" }) + expect(createRes.status).to.equal(201) + + const getRes = await createClient.supertest(app).get("/users/1") + expect(getRes.status).to.equal(200) + }) + }) + + describe("with metadata", () => { + const apiTest = wrapTest(it) + + apiTest.withMeta({ + summary: "Create User", + tags: ["Users", "Registration"], + })("should create user with metadata", async () => { + const response = await createClient.supertest(app).post("/users").send({ + name: "Jane", + email: "jane@test.com", + }) + + expect(response.status).to.equal(201) + }) + + apiTest.withMeta({ + description: "Custom description for API", + deprecated: false, + })("should use custom description", async () => { + const response = await createClient.supertest(app).get("/users") + + expect(response.status).to.equal(200) + }) + }) + + describe("error handling", () => { + const apiTest = wrapTest(it) + + apiTest("should handle successful requests", async () => { + const response = await createClient.supertest(app).get("/users") + + expect(response.status).to.equal(200) + expect(response.body).to.have.property("users") + }) + }) + + describe("compatibility", () => { + const apiTest = wrapTest(it) + + apiTest("should work with chai expect", async () => { + const response = await createClient.supertest(app).get("/users") + + expect(response.status).to.equal(200) + expect(response.body).to.be.an("object") + expect(response.body).to.have.property("users") + }) + + apiTest("should work with supertest expect()", async () => { + await createClient + .supertest(app) + .get("/users") + .expect(200) + .expect("Content-Type", /json/) + }) + }) + + describe("without capture (regular it)", () => { + it("should work as normal test", async () => { + const response = await createClient.supertest(app).get("/users") + + expect(response.status).to.equal(200) + }) + }) +}) diff --git a/lib/wrappers/adapters/axios/AxiosAdapter.ts b/lib/wrappers/adapters/axios/AxiosAdapter.ts new file mode 100644 index 0000000..6e57fe2 --- /dev/null +++ b/lib/wrappers/adapters/axios/AxiosAdapter.ts @@ -0,0 +1,239 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { AxiosInstance, AxiosRequestConfig } from "axios" +import { HttpAdapter, HttpClient, RequestBuilder, CapturedResponse } from "../types" +import { CaptureContext } from "../../core/CaptureContext" + +export interface AxiosAdapterConfig { + baseURL?: string + timeout?: number + headers?: Record +} + +export class AxiosAdapter implements HttpAdapter { + public create(config: AxiosAdapterConfig): HttpClient { + const axios = require("axios") + const instance = axios.create(config) + return new AxiosClient(instance) + } +} + +class AxiosClient implements HttpClient { + public constructor(private axios: AxiosInstance) {} + + public get(url: string) { + return new AxiosRequestBuilder(this.axios, "GET", url) + } + public post(url: string) { + return new AxiosRequestBuilder(this.axios, "POST", url) + } + public put(url: string) { + return new AxiosRequestBuilder(this.axios, "PUT", url) + } + public patch(url: string) { + return new AxiosRequestBuilder(this.axios, "PATCH", url) + } + public delete(url: string) { + return new AxiosRequestBuilder(this.axios, "DELETE", url) + } + public head(url: string) { + return new AxiosRequestBuilder(this.axios, "HEAD", url) + } + public options(url: string) { + return new AxiosRequestBuilder(this.axios, "OPTIONS", url) + } +} + +class AxiosRequestBuilder implements RequestBuilder { + private config: AxiosRequestConfig = {} + private formDataObj?: any + + public constructor( + private axios: AxiosInstance, + private method: string, + private url: string, + ) { + if (CaptureContext.isActive()) { + CaptureContext.addRequest({ method, url }) + } + } + + public send(body: any): this { + this.config.data = body + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ body }) + } + return this + } + + public set(field: string | Record, value?: string): this { + if (!this.config.headers) this.config.headers = {} + + if (typeof field === "string") { + this.config.headers[field] = value! + } else { + Object.assign(this.config.headers, field) + } + + if (CaptureContext.isActive()) { + const headers = typeof field === "string" ? { [field]: value! } : field + this.updateHeaders(headers) + } + return this + } + + public query(params: Record): this { + this.config.params = params + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ queryParams: params }) + } + return this + } + + public attach(field: string, file: string | Buffer, filename?: string): this { + if (!this.formDataObj) { + const FormData = require("form-data") + this.formDataObj = new FormData() + } + + if (typeof file === "string") { + const fs = require("fs") + this.formDataObj.append(field, fs.createReadStream(file), filename || file) + } else { + this.formDataObj.append(field, file, filename || "file") + } + + this.config.data = this.formDataObj + + if (!this.config.headers) this.config.headers = {} + Object.assign(this.config.headers, this.formDataObj.getHeaders()) + + if (CaptureContext.isActive()) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + + if (lastReq) { + if (!lastReq.formData) { + lastReq.formData = { fields: {}, files: [] } + } + lastReq.formData.files.push({ + field, + filename: filename || (typeof file === "string" ? file : "file"), + }) + } + } + return this + } + + public field(name: string, value: string | number): this { + if (!this.formDataObj) { + const FormData = require("form-data") + this.formDataObj = new FormData() + } + this.formDataObj.append(name, String(value)) + this.config.data = this.formDataObj + + if (!this.config.headers) this.config.headers = {} + Object.assign(this.config.headers, this.formDataObj.getHeaders()) + + if (CaptureContext.isActive()) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + + if (lastReq) { + if (!lastReq.formData) { + lastReq.formData = { fields: {}, files: [] } + } + lastReq.formData.fields[name] = value + } + } + return this + } + + public auth(username: string, password: string): this { + this.config.auth = { username, password } + return this + } + + public bearer(token: string): this { + return this.set("Authorization", `Bearer ${token}`) + } + + public timeout(ms: number): this { + this.config.timeout = ms + return this + } + + public expect(_statusOrField: number | string, _value?: string | RegExp): this { + return this + } + + public async then( + onFulfilled?: (response: CapturedResponse) => T, + onRejected?: (error: any) => any, + ): Promise { + try { + const res = await this.axios.request({ + method: this.method, + url: this.url, + ...this.config, + }) + + const response: CapturedResponse = { + status: res.status, + statusText: res.statusText, + headers: res.headers as any, + body: res.data, + text: typeof res.data === "string" ? res.data : JSON.stringify(res.data), + } + + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ response }) + } + + return onFulfilled ? onFulfilled(response) : (response as any) + } catch (error: any) { + if (error.response && CaptureContext.isActive()) { + const response: CapturedResponse = { + status: error.response.status, + statusText: error.response.statusText, + headers: error.response.headers, + body: error.response.data, + } + CaptureContext.updateLastRequest({ response }) + } + + if (onRejected) return onRejected(error) + throw error + } + } + + public async end(): Promise { + return this.then() + } + + private updateHeaders(headers: Record): void { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + if (lastReq) { + lastReq.headers = { ...lastReq.headers, ...headers } + } + } +} diff --git a/lib/wrappers/adapters/fetch/FetchAdapter.ts b/lib/wrappers/adapters/fetch/FetchAdapter.ts new file mode 100644 index 0000000..c6c1770 --- /dev/null +++ b/lib/wrappers/adapters/fetch/FetchAdapter.ts @@ -0,0 +1,238 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { HttpAdapter, HttpClient, RequestBuilder, CapturedResponse } from "../types" +import { CaptureContext } from "../../core/CaptureContext" + +export interface FetchAdapterConfig { + baseURL: string + headers?: Record +} + +export class FetchAdapter implements HttpAdapter { + public create(config: FetchAdapterConfig): HttpClient { + return new FetchClient(config) + } +} + +class FetchClient implements HttpClient { + public constructor(private config: FetchAdapterConfig) {} + + public get(url: string) { + return new FetchRequestBuilder(this.config, "GET", url) + } + public post(url: string) { + return new FetchRequestBuilder(this.config, "POST", url) + } + public put(url: string) { + return new FetchRequestBuilder(this.config, "PUT", url) + } + public patch(url: string) { + return new FetchRequestBuilder(this.config, "PATCH", url) + } + public delete(url: string) { + return new FetchRequestBuilder(this.config, "DELETE", url) + } + public head(url: string) { + return new FetchRequestBuilder(this.config, "HEAD", url) + } + public options(url: string) { + return new FetchRequestBuilder(this.config, "OPTIONS", url) + } +} + +class FetchRequestBuilder implements RequestBuilder { + private init: RequestInit = {} + private headers: Record = {} + private queryParams: Record = {} + private formDataObj?: any + + public constructor( + private config: FetchAdapterConfig, + method: string, + private url: string, + ) { + this.init.method = method + if (config.headers) { + this.headers = { ...config.headers } + } + + if (CaptureContext.isActive()) { + CaptureContext.addRequest({ method, url }) + } + } + + public send(body: any): this { + this.init.body = JSON.stringify(body) + this.headers["Content-Type"] = "application/json" + + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ body }) + } + return this + } + + public set(field: string | Record, value?: string): this { + if (typeof field === "string") { + this.headers[field] = value! + } else { + Object.assign(this.headers, field) + } + + if (CaptureContext.isActive()) { + const headers = typeof field === "string" ? { [field]: value! } : field + this.updateHeaders(headers) + } + return this + } + + public query(params: Record): this { + Object.assign(this.queryParams, params) + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ queryParams: params }) + } + return this + } + + public attach(field: string, file: string | Buffer, filename?: string): this { + if (!this.formDataObj) { + const FormData = require("form-data") + this.formDataObj = new FormData() + } + + if (typeof file === "string") { + const fs = require("fs") + this.formDataObj.append(field, fs.createReadStream(file), filename || file) + } else { + this.formDataObj.append(field, file, filename || "file") + } + + this.init.body = this.formDataObj as any + Object.assign(this.headers, this.formDataObj.getHeaders()) + + if (CaptureContext.isActive()) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + + if (lastReq) { + if (!lastReq.formData) { + lastReq.formData = { fields: {}, files: [] } + } + lastReq.formData.files.push({ + field, + filename: filename || (typeof file === "string" ? file : "file"), + }) + } + } + return this + } + + public field(name: string, value: string | number): this { + if (!this.formDataObj) { + const FormData = require("form-data") + this.formDataObj = new FormData() + } + this.formDataObj.append(name, String(value)) + this.init.body = this.formDataObj as any + Object.assign(this.headers, this.formDataObj.getHeaders()) + + if (CaptureContext.isActive()) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + + if (lastReq) { + if (!lastReq.formData) { + lastReq.formData = { fields: {}, files: [] } + } + lastReq.formData.fields[name] = value + } + } + return this + } + + public auth(username: string, password: string): this { + const token = Buffer.from(`${username}:${password}`).toString("base64") + return this.set("Authorization", `Basic ${token}`) + } + + public bearer(token: string): this { + return this.set("Authorization", `Bearer ${token}`) + } + + public timeout(_ms: number): this { + return this + } + + public expect(_statusOrField: number | string, _value?: string | RegExp): this { + return this + } + + public async then( + onFulfilled?: (response: CapturedResponse) => T, + onRejected?: (error: any) => any, + ): Promise { + try { + let fullURL = `${this.config.baseURL}${this.url}` + if (Object.keys(this.queryParams).length > 0) { + const queryString = new URLSearchParams(this.queryParams).toString() + fullURL += `?${queryString}` + } + + this.init.headers = this.headers + + const res = await fetch(fullURL, this.init) + const text = await res.text() + let body: any + try { + body = JSON.parse(text) + } catch { + body = text + } + + const response: CapturedResponse = { + status: res.status, + statusText: res.statusText, + headers: Object.fromEntries(res.headers.entries()), + body, + text, + } + + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ response }) + } + + return onFulfilled ? onFulfilled(response) : (response as any) + } catch (error) { + if (onRejected) return onRejected(error) + throw error + } + } + + public async end(): Promise { + return this.then() + } + + private updateHeaders(headers: Record) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + if (lastReq) { + lastReq.headers = { ...lastReq.headers, ...headers } + } + } +} diff --git a/lib/wrappers/adapters/index.ts b/lib/wrappers/adapters/index.ts new file mode 100644 index 0000000..099c3d1 --- /dev/null +++ b/lib/wrappers/adapters/index.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { SupertestAdapter } from "./supertest/SupertestAdapter" +import { AxiosAdapter, AxiosAdapterConfig } from "./axios/AxiosAdapter" +import { FetchAdapter, FetchAdapterConfig } from "./fetch/FetchAdapter" +import { HttpClient } from "./types" + +/** + * Factory for creating HTTP clients with different adapters + * + * This allows you to use different HTTP clients (supertest, axios, fetch) + * while maintaining the same API and automatic request/response capture. + * @example Supertest + * ```typescript + * const client = createClient.supertest(app) + * await client.post('/api/users').send({ name: 'John' }) + * ``` + * @example Axios + * ```typescript + * const client = createClient.axios({ baseURL: 'http://localhost:3000' }) + * await client.post('/api/users').send({ name: 'John' }) + * ``` + * @example Fetch + * ```typescript + * const client = createClient.fetch({ baseURL: 'http://localhost:3000' }) + * await client.post('/api/users').send({ name: 'John' }) + * ``` + */ +export const createClient = { + /** + * Create a client using supertest (for Express/Fastify/NestJS apps) + * @param app - Express/Fastify app instance + * @returns HTTP client with automatic capture + */ + supertest(app: any): HttpClient { + return new SupertestAdapter().create(app) + }, + + /** + * Create a client using axios + * @param config - Axios configuration (baseURL, headers, etc.) + * @returns HTTP client with automatic capture + */ + axios(config: AxiosAdapterConfig): HttpClient { + return new AxiosAdapter().create(config) + }, + + /** + * Create a client using fetch API + * @param config - Fetch configuration (baseURL, headers) + * @returns HTTP client with automatic capture + */ + fetch(config: FetchAdapterConfig): HttpClient { + return new FetchAdapter().create(config) + }, +} + +/** + * Backward compatibility: keep `request` as alias to `createClient.supertest` + * @param app + * @deprecated Use createClient.supertest() instead + */ +export function request(app: any): HttpClient { + return createClient.supertest(app) +} + +// Export types +export type * from "./types" +export { SupertestAdapter } from "./supertest/SupertestAdapter" +export { AxiosAdapter } from "./axios/AxiosAdapter" +export type { AxiosAdapterConfig } from "./axios/AxiosAdapter" +export { FetchAdapter } from "./fetch/FetchAdapter" +export type { FetchAdapterConfig } from "./fetch/FetchAdapter" diff --git a/lib/wrappers/adapters/supertest/SupertestAdapter.ts b/lib/wrappers/adapters/supertest/SupertestAdapter.ts new file mode 100644 index 0000000..d48bcb1 --- /dev/null +++ b/lib/wrappers/adapters/supertest/SupertestAdapter.ts @@ -0,0 +1,199 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import supertest from "supertest" +import { HttpAdapter, HttpClient, RequestBuilder, CapturedResponse } from "../types" +import { CaptureContext } from "../../core/CaptureContext" +import type { CapturedRequest } from "../../types" + +export class SupertestAdapter implements HttpAdapter { + public create(app: any): HttpClient { + return new SupertestClient(supertest(app) as any) + } +} + +class SupertestClient implements HttpClient { + public constructor(private agent: any) {} + + public get(url: string) { + return new SupertestRequestBuilder(this.agent.get(url), "GET", url) + } + public post(url: string) { + return new SupertestRequestBuilder(this.agent.post(url), "POST", url) + } + public put(url: string) { + return new SupertestRequestBuilder(this.agent.put(url), "PUT", url) + } + public patch(url: string) { + return new SupertestRequestBuilder(this.agent.patch(url), "PATCH", url) + } + public delete(url: string) { + return new SupertestRequestBuilder(this.agent.delete(url), "DELETE", url) + } + public head(url: string) { + return new SupertestRequestBuilder(this.agent.head(url), "HEAD", url) + } + public options(url: string) { + return new SupertestRequestBuilder(this.agent.options(url), "OPTIONS", url) + } +} + +class SupertestRequestBuilder implements RequestBuilder { + private capturedData: Partial = {} + + public constructor( + private test: any, + method: string, + url: string, + ) { + if (CaptureContext.isActive()) { + this.capturedData = { method, url } + CaptureContext.addRequest(this.capturedData as any) + } + } + + public send(body: any): this { + this.test.send(body) + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ body }) + } + return this + } + + public set(field: string | Record, value?: string): this { + if (typeof field === "string") { + this.test.set(field, value!) + if (CaptureContext.isActive()) { + const headers = { [field]: value! } + this.updateHeaders(headers) + } + } else { + this.test.set(field) + if (CaptureContext.isActive()) { + this.updateHeaders(field) + } + } + return this + } + + public query(params: Record): this { + this.test.query(params) + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ queryParams: params }) + } + return this + } + + public attach(field: string, file: string | Buffer, filename?: string): this { + this.test.attach(field, file, filename) + if (CaptureContext.isActive()) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + + if (lastReq) { + if (!lastReq.formData) { + lastReq.formData = { fields: {}, files: [] } + } + lastReq.formData.files.push({ + field, + filename: filename || (typeof file === "string" ? file : "file"), + }) + } + } + return this + } + + public field(name: string, value: string | number): this { + this.test.field(name, value) + if (CaptureContext.isActive()) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + + if (lastReq) { + if (!lastReq.formData) { + lastReq.formData = { fields: {}, files: [] } + } + lastReq.formData.fields[name] = value + } + } + return this + } + + public auth(username: string, password: string): this { + this.test.auth(username, password) + return this + } + + public bearer(token: string): this { + return this.set("Authorization", `Bearer ${token}`) + } + + public timeout(ms: number): this { + this.test.timeout(ms) + return this + } + + public expect(statusOrField: number | string, value?: string | RegExp): this { + if (typeof statusOrField === "number") { + this.test.expect(statusOrField) + } else if (value !== undefined) { + this.test.expect(statusOrField, value) + } + return this + } + + public then( + onFulfilled?: (response: CapturedResponse) => T, + onRejected?: (error: any) => any, + ): Promise { + return this.test.then( + (res: any) => { + const response: CapturedResponse = { + status: res.status, + statusText: res.statusType || `${res.status}`, + headers: res.headers, + body: res.body, + text: res.text, + } + + if (CaptureContext.isActive()) { + CaptureContext.updateLastRequest({ response }) + } + + return onFulfilled ? onFulfilled(response) : (response as any) + }, + (error: any) => { + if (onRejected) return onRejected(error) + throw error + }, + ) + } + + public async end(): Promise { + return this.then() + } + + private updateHeaders(headers: Record) { + const store = CaptureContext.getStore() + const requests = store?.capturedRequests || [] + const lastReq = requests[requests.length - 1] + if (lastReq) { + lastReq.headers = { ...lastReq.headers, ...headers } + } + } +} diff --git a/lib/wrappers/adapters/types.ts b/lib/wrappers/adapters/types.ts new file mode 100644 index 0000000..8d9e510 --- /dev/null +++ b/lib/wrappers/adapters/types.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { CapturedResponse, CapturedRequest } from "../types" +export type { CapturedResponse, CapturedRequest } + +/** + * Builder interface for constructing HTTP requests + * All adapters must implement this interface + */ +export interface RequestBuilder { + send(body: any): this + + set(field: string, value: string): this + set(fields: Record): this + + query(params: Record): this + + attach(field: string, file: string | Buffer, filename?: string): this + field(name: string, value: string | number): this + + auth(username: string, password: string): this + bearer(token: string): this + + timeout(ms: number): this + + expect(status: number): this + expect(field: string, value: string | RegExp): this + + then( + onFulfilled?: (response: CapturedResponse) => T, + onRejected?: (error: any) => any, + ): Promise + + end(): Promise +} + +/** + * Main HTTP client interface + * All HTTP method calls return RequestBuilder + */ +export interface HttpClient { + get(url: string): RequestBuilder + post(url: string): RequestBuilder + put(url: string): RequestBuilder + patch(url: string): RequestBuilder + delete(url: string): RequestBuilder + head(url: string): RequestBuilder + options(url: string): RequestBuilder +} + +/** + * Adapter interface for creating HTTP clients + */ +export interface HttpAdapter { + /** + * Create a new HTTP client instance + * @param config - Configuration (app instance, base URL, etc.) + */ + create(config: TConfig): HttpClient +} diff --git a/lib/wrappers/core/CaptureContext.ts b/lib/wrappers/core/CaptureContext.ts new file mode 100644 index 0000000..f565781 --- /dev/null +++ b/lib/wrappers/core/CaptureContext.ts @@ -0,0 +1,112 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AsyncLocalStorage } from "async_hooks" +import { ApiDocMetadata, CapturedRequest } from "../types" + +/** + * Store structure for AsyncLocalStorage + */ +export interface CaptureStore { + description: string + metadata?: ApiDocMetadata + capturedRequests: CapturedRequest[] +} + +/** + * Context manager for capturing HTTP requests/responses using AsyncLocalStorage + * This ensures thread-safe isolation of captured data across concurrent tests + */ +export class CaptureContext { + private static storage = new AsyncLocalStorage() + + /** + * Run a function within a capture context + * @param description Test description + * @param metadata Optional API documentation metadata + * @param fn Function to execute + * @returns Result of the function + */ + public static run( + description: string, + metadata: ApiDocMetadata | undefined, + fn: () => T | Promise, + ): T | Promise { + const store: CaptureStore = { + description, + metadata, + capturedRequests: [], + } + return this.storage.run(store, fn) + } + + /** + * Get the current capture store + * @returns Current store or undefined if not in capture context + */ + public static getStore(): CaptureStore | undefined { + return this.storage.getStore() + } + + /** + * Check if capture context is currently active + * @returns true if active, false otherwise + */ + public static isActive(): boolean { + return this.storage.getStore() !== undefined + } + + /** + * Add a new request to the capture store + * @param request Partial request data + */ + public static addRequest(request: Partial): void { + const store = this.getStore() + if (store) { + store.capturedRequests.push(request as CapturedRequest) + } + } + + /** + * Update the last captured request with additional data + * @param data Partial request data to merge + */ + public static updateLastRequest(data: Partial): void { + const store = this.getStore() + if (store && store.capturedRequests.length > 0) { + const last = store.capturedRequests[store.capturedRequests.length - 1] + Object.assign(last, data) + } + } + + /** + * Get all captured requests from current context + * @returns Array of captured requests + */ + public static getCapturedRequests(): CapturedRequest[] { + return this.getStore()?.capturedRequests || [] + } + + /** + * Clear all captured requests (useful for testing) + */ + public static clear(): void { + const store = this.getStore() + if (store) { + store.capturedRequests = [] + } + } +} diff --git a/lib/wrappers/index.ts b/lib/wrappers/index.ts new file mode 100644 index 0000000..8cf5318 --- /dev/null +++ b/lib/wrappers/index.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Wrapper-based API testing module + * + * This module provides a high-order function approach to automatically capture + * HTTP requests and responses from your tests and generate API documentation. + * @example Basic usage + * ```typescript + * import { wrapTest, createClient } from 'itdoc/wrappers' + * + * const apiTest = wrapTest(it) + * + * describe('User API', () => { + * apiTest('should create user', async () => { + * const response = await createClient.supertest(app) + * .post('/users') + * .send({ name: 'John' }) + * + * expect(response.status).toBe(201) + * }) + * }) + * ``` + * @example With metadata + * ```typescript + * apiTest.withMeta({ + * summary: 'Create User', + * tags: ['Users', 'Registration'] + * })('should create user', async () => { + * const response = await createClient.supertest(app) + * .post('/users') + * .send({ name: 'John' }) + * + * expect(response.status).toBe(201) + * }) + * ``` + */ + +export { wrapTest } from "./wrapTest" +export { createClient } from "./adapters" +export type { ApiDocMetadata, WrappedTestFunction, TestFunction } from "./types" +export type { + HttpClient, + HttpAdapter, + RequestBuilder, + CapturedResponse, + CapturedRequest, +} from "./adapters/types" diff --git a/lib/wrappers/types.ts b/lib/wrappers/types.ts new file mode 100644 index 0000000..087620c --- /dev/null +++ b/lib/wrappers/types.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * API documentation metadata that can be attached to tests + */ +export interface ApiDocMetadata { + summary?: string + description?: string + tags?: string[] + deprecated?: boolean + operationId?: string +} + +/** + * Captured HTTP request data + */ +export interface CapturedRequest { + method?: string + url?: string + body?: any + headers?: Record + queryParams?: Record + pathParams?: Record + formData?: { + fields: Record + files: Array<{ + field: string + filename: string + mimetype?: string + }> + } + response?: CapturedResponse +} + +/** + * Captured HTTP response data + */ +export interface CapturedResponse { + status: number + statusText?: string + body?: any + headers?: Record + text?: string +} + +/** + * Test function type + */ +export type TestFunction = () => void | Promise + +/** + * Test framework's 'it' function type + * Compatible with both Jest and Mocha + * Using any for maximum compatibility across test frameworks + */ +export type ItFunction = (description: string, fn: any) => any + +/** + * Wrapped test function with metadata support + */ +export interface WrappedTestFunction { + (description: string, testFn: TestFunction): void + withMeta: (metadata: ApiDocMetadata) => (description: string, testFn: TestFunction) => void +} diff --git a/lib/wrappers/wrapTest.ts b/lib/wrappers/wrapTest.ts new file mode 100644 index 0000000..6dabd57 --- /dev/null +++ b/lib/wrappers/wrapTest.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestEventManager } from "../dsl/generator/TestEventManager" +import { TestResultCollector } from "../dsl/generator/TestResultCollector" +import { CaptureContext } from "./core/CaptureContext" +import { TestResult } from "../dsl/generator/types/TestResult" +import { HttpMethod } from "../dsl/enums/HttpMethod" +import { ApiDocMetadata, ItFunction, TestFunction, WrappedTestFunction } from "./types" + +/** + * Wrap a test framework's 'it' function to automatically capture HTTP requests/responses + * and generate API documentation from successful tests + * + * @param originalIt - The test framework's 'it' function (from Jest or Mocha) + * @returns A wrapped test function with automatic capturing capabilities + * + * @example + * ```typescript + * import { wrapTest, request } from 'itdoc/wrappers' + * + * const apiTest = wrapTest(it) + * + * describe('User API', () => { + * apiTest('should create user', async () => { + * const res = await request(app) + * .post('/users') + * .send({ name: 'John' }) + * + * expect(res.status).toBe(201) + * }) + * }) + * ``` + */ +export function wrapTest(originalIt: ItFunction): WrappedTestFunction { + const testEventManager = TestEventManager.getInstance() + const collector = TestResultCollector.getInstance() + + /** + * Internal wrapper that executes the test with capturing + */ + const executeTest = ( + description: string, + testFn: TestFunction, + metadata?: ApiDocMetadata, + ): void => { + // Register test with event manager + testEventManager.registerTest() + + // Execute test with original 'it' function + originalIt(description, async () => { + let captured: any[] = [] + + try { + // Run test within capture context + await CaptureContext.run(description, metadata, async () => { + await testFn() + // IMPORTANT: Get captured requests INSIDE the context + captured = CaptureContext.getCapturedRequests() + }) + + for (const req of captured) { + // Only collect requests that have responses (successful requests) + if (req.response) { + const testResult: TestResult = { + method: req.method as HttpMethod, + url: req.url!, + options: { + summary: metadata?.summary, + description: metadata?.description || description, + tag: metadata?.tags?.[0], // Use first tag for compatibility with ApiDocOptions + }, + request: { + body: req.body, + headers: req.headers, + queryParams: req.queryParams, + pathParams: req.pathParams, + }, + response: { + status: req.response.status, + body: req.response.body, + headers: req.response.headers, + }, + testSuiteDescription: description, + } + + collector.collectResult(testResult) + } + } + + testEventManager.completeTestSuccess() + } catch (error) { + testEventManager.completeTestFailure() + throw error + } + }) + } + + /** + * Main wrapped test function + */ + const wrapped = (description: string, testFn: TestFunction): void => { + executeTest(description, testFn) + } + + /** + * Method to add metadata to the test + */ + wrapped.withMeta = (metadata: ApiDocMetadata) => { + return (description: string, testFn: TestFunction): void => { + executeTest(description, testFn, metadata) + } + } + + return wrapped as WrappedTestFunction +} diff --git a/package.json b/package.json index f6488af..c68d681 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "itdoc", - "version": "0.4.0", + "version": "0.4.1", "description": "Test-driven documentation for RESTful services", "license": "Apache-2.0", "bin": { @@ -91,6 +91,7 @@ "fast-glob": "^3.3.3", "lodash": "^4.17.21", "openai": "^4.90.0", + "sinon": "^20.0.0", "supertest": "^7.0.0", "widdershins": "^4.0.1" }, @@ -98,17 +99,20 @@ "@eslint/js": "~9.17", "@types/chai": "^5.0.1", "@types/eslint__js": "~8.42", + "@types/lodash": "^4.17.20", "@types/mocha": "^10.0.10", "@types/node": "~20", "@types/sinon": "^17.0.2", "@types/supertest": "^6.0.2", "@typescript-eslint/parser": "~8.19", + "axios": "^1.12.2", "chai": "^5.2.0", "eslint": "~9.17", "eslint-config-prettier": "~9.1", "eslint-plugin-jsdoc": "^50.6.0", "eslint-plugin-license-header": "^0.8.0", "eslint-plugin-mocha": "^10.5.0", + "form-data": "^4.0.4", "globals": "~15.14", "husky": "^8.0.0", "lint-staged": "^15.4.3", @@ -123,10 +127,20 @@ "typescript-eslint": "~8.19" }, "peerDependencies": { + "axios": "^1.0.0", + "form-data": "^4.0.0", "jest": "^29.0.0", "mocha": "^11.0.0" }, - "packageManager": "pnpm@10.5.2", + "peerDependenciesMeta": { + "axios": { + "optional": true + }, + "form-data": { + "optional": true + } + }, + "packageManager": "pnpm@10.15.0", "engines": { "node": ">=20" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 870fd13..67c23ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@redocly/cli': specifier: ^1.34.0 - version: 1.34.0(ajv@5.5.2)(supports-color@10.0.0) + version: 1.34.0(ajv@8.17.1)(supports-color@10.0.0) '@redocly/openapi-core': specifier: ^1.34.2 version: 1.34.2(supports-color@10.0.0) @@ -47,12 +47,15 @@ importers: openai: specifier: ^4.90.0 version: 4.90.0(ws@8.18.1) + sinon: + specifier: ^20.0.0 + version: 20.0.0 supertest: specifier: ^7.0.0 version: 7.0.0(supports-color@10.0.0) widdershins: specifier: ^4.0.1 - version: 4.0.1(ajv@5.5.2)(mkdirp@0.5.6)(supports-color@10.0.0) + version: 4.0.1(ajv@8.17.1)(mkdirp@0.5.6)(supports-color@10.0.0) devDependencies: '@eslint/js': specifier: ~9.17 @@ -63,6 +66,9 @@ importers: '@types/eslint__js': specifier: ~8.42 version: 8.42.3 + '@types/lodash': + specifier: ^4.17.20 + version: 4.17.20 '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -78,6 +84,9 @@ importers: '@typescript-eslint/parser': specifier: ~8.19 version: 8.19.1(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.7.3) + axios: + specifier: ^1.12.2 + version: 1.12.2 chai: specifier: ^5.2.0 version: 5.2.0 @@ -96,6 +105,9 @@ importers: eslint-plugin-mocha: specifier: ^10.5.0 version: 10.5.0(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0)) + form-data: + specifier: ^4.0.4 + version: 4.0.4 globals: specifier: ~15.14 version: 15.14.0 @@ -111,9 +123,6 @@ importers: rimraf: specifier: ^6.0.1 version: 6.0.1 - sinon: - specifier: ^20.0.0 - version: 20.0.0 sort-package-json: specifier: ^2.15.1 version: 2.15.1 @@ -159,7 +168,7 @@ importers: version: link:../.. jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) + version: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)) jest-diff: specifier: ^29.7.0 version: 29.7.0 @@ -233,7 +242,7 @@ importers: version: link:../.. jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) + version: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)) examples/nestjs: dependencies: @@ -330,13 +339,13 @@ importers: version: link:../.. jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) + version: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)) mocha: specifier: ^10.3.0 version: 10.8.2 ts-jest: specifier: ^29.2.6 - version: 29.2.6(@babel/core@7.28.0(supports-color@10.0.0))(@jest/transform@29.7.0(supports-color@10.0.0))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)))(typescript@5.8.3) + version: 29.2.6(@babel/core@7.28.0(supports-color@10.0.0))(@jest/transform@29.7.0(supports-color@10.0.0))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)))(typescript@5.7.3) itdoc-doc: dependencies: @@ -357,7 +366,7 @@ importers: version: 2.1.1 docusaurus: specifier: ^1.14.7 - version: 1.14.7(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + version: 1.14.7(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)) prism-react-renderer: specifier: ^2.3.0 version: 2.4.1(react@19.0.0) @@ -3162,6 +3171,9 @@ packages: '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/lodash@4.17.20': + resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -3948,6 +3960,9 @@ packages: aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + axios@1.12.2: + resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + b4a@1.6.7: resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} @@ -6100,10 +6115,6 @@ packages: resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} engines: {node: '>= 6'} - form-data@4.0.2: - resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} - engines: {node: '>= 6'} - form-data@4.0.4: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} @@ -8491,6 +8502,7 @@ packages: node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} @@ -9745,6 +9757,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pseudomap@1.0.2: resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} @@ -10690,6 +10705,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -10974,10 +10990,12 @@ packages: superagent@9.0.2: resolution: {integrity: sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==} engines: {node: '>=14.18.0'} + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net supertest@7.0.0: resolution: {integrity: sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==} engines: {node: '>=14.18.0'} + deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net supertest@7.1.4: resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==} @@ -13592,7 +13610,7 @@ snapshots: react-dev-utils: 12.0.1(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) terser-webpack-plugin: 5.3.14(@swc/core@1.11.29)(esbuild@0.25.0)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) tslib: 2.8.1 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) webpackbar: 6.0.1(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) transitivePeerDependencies: @@ -13717,7 +13735,7 @@ snapshots: tslib: 2.8.1 unified: 11.0.5 unist-util-visit: 5.0.0 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) vfile: 6.0.3 webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) transitivePeerDependencies: @@ -14300,7 +14318,7 @@ snapshots: resolve-pathname: 3.0.0 shelljs: 0.8.5 tslib: 2.8.1 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) utility-types: 3.11.0 webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) transitivePeerDependencies: @@ -14724,41 +14742,6 @@ snapshots: - supports-color - ts-node - '@jest/core@29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0(supports-color@10.0.0) - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0(supports-color@10.0.0) - '@jest/types': 29.6.3 - '@types/node': 20.17.24 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0(supports-color@10.0.0) - jest-runner: 29.7.0(supports-color@10.0.0) - jest-runtime: 29.7.0(supports-color@10.0.0) - jest-snapshot: 29.7.0(supports-color@10.0.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - '@jest/core@29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3))': dependencies: '@jest/console': 29.7.0 @@ -14794,41 +14777,6 @@ snapshots: - supports-color - ts-node - '@jest/core@29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0(supports-color@10.0.0) - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0(supports-color@10.0.0) - '@jest/types': 29.6.3 - '@types/node': 20.17.24 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0(supports-color@10.0.0) - jest-runner: 29.7.0(supports-color@10.0.0) - jest-runtime: 29.7.0(supports-color@10.0.0) - jest-snapshot: 29.7.0(supports-color@10.0.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - '@jest/core@30.0.5(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3))': dependencies: '@jest/console': 30.0.5 @@ -15544,7 +15492,7 @@ snapshots: require-from-string: 2.0.2 uri-js-replace: 1.0.1 - '@redocly/cli@1.34.0(ajv@5.5.2)(supports-color@10.0.0)': + '@redocly/cli@1.34.0(ajv@8.17.1)(supports-color@10.0.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/exporter-trace-otlp-http': 0.53.0(@opentelemetry/api@1.9.0) @@ -15553,13 +15501,13 @@ snapshots: '@opentelemetry/semantic-conventions': 1.27.0 '@redocly/config': 0.22.1 '@redocly/openapi-core': 1.34.0(supports-color@10.0.0) - '@redocly/respect-core': 1.34.0(ajv@5.5.2)(supports-color@10.0.0) + '@redocly/respect-core': 1.34.0(ajv@8.17.1)(supports-color@10.0.0) abort-controller: 3.0.0 chokidar: 3.6.0 colorette: 1.4.0 core-js: 3.41.0 dotenv: 16.4.7 - form-data: 4.0.2 + form-data: 4.0.4 get-port-please: 3.1.2 glob: 7.2.3 handlebars: 4.7.8 @@ -15610,12 +15558,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@redocly/respect-core@1.34.0(ajv@5.5.2)(supports-color@10.0.0)': + '@redocly/respect-core@1.34.0(ajv@8.17.1)(supports-color@10.0.0)': dependencies: '@faker-js/faker': 7.6.0 '@redocly/ajv': 8.11.2 '@redocly/openapi-core': 1.34.0(supports-color@10.0.0) - better-ajv-errors: 1.2.0(ajv@5.5.2) + better-ajv-errors: 1.2.0(ajv@8.17.1) colorette: 2.0.20 concat-stream: 2.0.0 cookie: 0.7.2 @@ -16085,6 +16033,8 @@ snapshots: dependencies: '@types/node': 20.17.24 + '@types/lodash@4.17.20': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -16102,7 +16052,7 @@ snapshots: '@types/node-fetch@2.6.12': dependencies: '@types/node': 20.17.24 - form-data: 4.0.2 + form-data: 4.0.4 '@types/node-forge@1.3.11': dependencies: @@ -16170,7 +16120,7 @@ snapshots: '@types/serve-index@1.9.4': dependencies: - '@types/express': 4.17.21 + '@types/express': 5.0.2 '@types/serve-static@1.15.7': dependencies: @@ -16197,7 +16147,7 @@ snapshots: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 '@types/node': 20.17.24 - form-data: 4.0.2 + form-data: 4.0.4 '@types/supertest@6.0.2': dependencies: @@ -16972,6 +16922,14 @@ snapshots: aws4@1.13.2: {} + axios@1.12.2: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + b4a@1.6.7: {} babel-jest@29.7.0(@babel/core@7.26.9(supports-color@10.0.0))(supports-color@10.0.0): @@ -17177,11 +17135,22 @@ snapshots: jsonpointer: 4.1.0 leven: 3.1.0 - better-ajv-errors@1.2.0(ajv@5.5.2): + better-ajv-errors@0.6.7(ajv@8.17.1): + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/runtime': 7.27.0 + ajv: 8.17.1 + chalk: 2.4.2 + core-js: 3.41.0 + json-to-ast: 2.1.0 + jsonpointer: 4.1.0 + leven: 3.1.0 + + better-ajv-errors@1.2.0(ajv@8.17.1): dependencies: '@babel/code-frame': 7.26.2 '@humanwhocodes/momoa': 2.0.4 - ajv: 5.5.2 + ajv: 8.17.1 chalk: 4.1.2 jsonpointer: 5.0.1 leven: 3.1.0 @@ -17809,7 +17778,7 @@ snapshots: compressible@2.0.18: dependencies: - mime-db: 1.52.0 + mime-db: 1.54.0 compression@1.8.0(supports-color@10.0.0): dependencies: @@ -17971,21 +17940,6 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - create-jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)): dependencies: '@jest/types': 29.6.3 @@ -18001,21 +17955,6 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - create-require@1.1.1: {} cross-env@7.0.3: @@ -18607,7 +18546,7 @@ snapshots: dependencies: '@leichtgewicht/ip-codec': 2.0.5 - docusaurus@1.14.7(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)): + docusaurus@1.14.7(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)): dependencies: '@babel/core': 7.26.9(supports-color@10.0.0) '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.26.9(supports-color@10.0.0))(supports-color@10.0.0) @@ -18647,7 +18586,7 @@ snapshots: postcss: 7.0.39 prismjs: 1.30.0 react: 16.14.0 - react-dev-utils: 11.0.4(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + react-dev-utils: 11.0.4(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)) react-dom: 16.14.0(react@16.14.0) remarkable: 2.0.1 request: 2.88.2 @@ -19393,7 +19332,7 @@ snapshots: ext-list@2.2.2: dependencies: - mime-db: 1.52.0 + mime-db: 1.54.0 ext-name@5.0.0: dependencies: @@ -19584,6 +19523,13 @@ snapshots: schema-utils: 3.3.0 webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) + file-loader@6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)): + dependencies: + loader-utils: 2.0.4 + schema-utils: 3.3.0 + webpack: 5.99.6(@swc/core@1.11.29)(esbuild@0.25.0) + optional: true + file-type@10.11.0: {} file-type@19.6.0: @@ -19765,7 +19711,7 @@ snapshots: forever-agent@0.6.1: {} - fork-ts-checker-webpack-plugin@4.1.6(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)): + fork-ts-checker-webpack-plugin@4.1.6(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)): dependencies: '@babel/code-frame': 7.26.2 chalk: 2.4.2 @@ -19774,7 +19720,7 @@ snapshots: semver: 5.7.2 tapable: 1.1.3 typescript: 5.6.3 - webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) + webpack: 5.99.6(@swc/core@1.11.29)(esbuild@0.25.0) worker-rpc: 0.1.1 optionalDependencies: eslint: 9.17.0(jiti@1.21.7)(supports-color@10.0.0) @@ -19840,13 +19786,6 @@ snapshots: combined-stream: 1.0.8 mime-types: 2.1.35 - form-data@4.0.2: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - mime-types: 2.1.35 - form-data@4.0.4: dependencies: asynckit: 0.4.0 @@ -21277,25 +21216,6 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)): - dependencies: - '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest-cli@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)): dependencies: '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)) @@ -21315,25 +21235,6 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)): - dependencies: - '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest-cli@30.0.5(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)): dependencies: '@jest/core': 30.0.5(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)) @@ -21384,37 +21285,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)): - dependencies: - '@babel/core': 7.26.9(supports-color@10.0.0) - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.26.9(supports-color@10.0.0))(supports-color@10.0.0) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0(supports-color@10.0.0) - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0(supports-color@10.0.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.17.24 - ts-node: 10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)): dependencies: '@babel/core': 7.26.9(supports-color@10.0.0) @@ -21446,37 +21316,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)): - dependencies: - '@babel/core': 7.26.9(supports-color@10.0.0) - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.26.9(supports-color@10.0.0))(supports-color@10.0.0) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0(supports-color@10.0.0) - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0(supports-color@10.0.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.17.24 - ts-node: 10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)): dependencies: '@babel/core': 7.26.9(supports-color@10.0.0) @@ -21508,37 +21347,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)): - dependencies: - '@babel/core': 7.26.9(supports-color@10.0.0) - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.26.9(supports-color@10.0.0))(supports-color@10.0.0) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0(supports-color@10.0.0) - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0(supports-color@10.0.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 22.15.21 - ts-node: 10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@30.0.5(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)): dependencies: '@babel/core': 7.28.0(supports-color@10.0.0) @@ -22020,18 +21828,6 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)): - dependencies: - '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)): dependencies: '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)) @@ -22044,18 +21840,6 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)): - dependencies: - '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest@30.0.5(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)): dependencies: '@jest/core': 30.0.5(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)) @@ -24703,6 +24487,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + pseudomap@1.0.2: {} psl@1.15.0: @@ -24807,7 +24593,7 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-dev-utils@11.0.4(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)): + react-dev-utils@11.0.4(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)): dependencies: '@babel/code-frame': 7.10.4 address: 1.1.2 @@ -24818,7 +24604,7 @@ snapshots: escape-string-regexp: 2.0.0 filesize: 6.1.0 find-up: 4.1.0 - fork-ts-checker-webpack-plugin: 4.1.6(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + fork-ts-checker-webpack-plugin: 4.1.6(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)) global-modules: 2.0.0 globby: 11.0.1 gzip-size: 5.1.1 @@ -24833,7 +24619,7 @@ snapshots: shell-quote: 1.7.2 strip-ansi: 6.0.0 text-table: 0.2.0 - webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) + webpack: 5.99.6(@swc/core@1.11.29)(esbuild@0.25.0) optionalDependencies: typescript: 5.6.3 transitivePeerDependencies: @@ -26261,7 +26047,7 @@ snapshots: cookiejar: 2.1.4 debug: 4.4.0(supports-color@10.0.0) fast-safe-stringify: 2.1.1 - form-data: 4.0.2 + form-data: 4.0.4 formidable: 3.5.2 methods: 1.1.2 mime: 2.6.0 @@ -26329,9 +26115,9 @@ snapshots: csso: 5.0.5 picocolors: 1.1.1 - swagger2openapi@6.2.3(ajv@5.5.2): + swagger2openapi@6.2.3(ajv@8.17.1): dependencies: - better-ajv-errors: 0.6.7(ajv@5.5.2) + better-ajv-errors: 0.6.7(ajv@8.17.1) call-me-maybe: 1.0.2 node-fetch-h2: 2.3.0 node-readfiles: 0.2.0 @@ -26602,26 +26388,6 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0) esbuild: 0.25.0 - ts-jest@29.2.6(@babel/core@7.28.0(supports-color@10.0.0))(@jest/transform@29.7.0(supports-color@10.0.0))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)))(typescript@5.8.3): - dependencies: - bs-logger: 0.2.6 - ejs: 3.1.10 - fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - jest-util: 29.7.0 - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.1 - typescript: 5.8.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.28.0(supports-color@10.0.0) - '@jest/transform': 29.7.0(supports-color@10.0.0) - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0) - esbuild: 0.25.0 - ts-jest@29.4.0(@babel/core@7.28.0(supports-color@10.0.0))(@jest/transform@30.0.5(supports-color@10.0.0))(@jest/types@30.0.5)(babel-jest@30.0.5(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0))(esbuild@0.25.0)(jest-util@30.0.5)(jest@30.0.5(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)))(typescript@5.7.3): dependencies: bs-logger: 0.2.6 @@ -26674,27 +26440,6 @@ snapshots: '@swc/core': 1.11.29 optional: true - ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.17.24 - acorn: 8.14.1 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.8.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.11.29 - optional: true - ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -26715,27 +26460,6 @@ snapshots: optionalDependencies: '@swc/core': 1.11.29 - ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 22.15.21 - acorn: 8.14.1 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.8.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.11.29 - optional: true - tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 @@ -27065,14 +26789,14 @@ snapshots: urix@0.1.0: {} - url-loader@4.1.1(file-loader@6.2.0(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)): + url-loader@4.1.1(file-loader@6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)): dependencies: loader-utils: 2.0.4 mime-types: 2.1.35 schema-utils: 3.3.0 webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) optionalDependencies: - file-loader: 6.2.0(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + file-loader: 6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)) url-parse-lax@1.0.0: dependencies: @@ -27407,7 +27131,7 @@ snapshots: dependencies: isexe: 2.0.0 - widdershins@4.0.1(ajv@5.5.2)(mkdirp@0.5.6)(supports-color@10.0.0): + widdershins@4.0.1(ajv@8.17.1)(mkdirp@0.5.6)(supports-color@10.0.0): dependencies: dot: 1.1.3 fast-safe-stringify: 2.1.1 @@ -27421,7 +27145,7 @@ snapshots: oas-schema-walker: 1.1.5 openapi-sampler: 1.6.1 reftools: 1.1.9 - swagger2openapi: 6.2.3(ajv@5.5.2) + swagger2openapi: 6.2.3(ajv@8.17.1) urijs: 1.19.11 yaml: 1.10.2 yargs: 12.0.5 diff --git a/script/llm/examples/index.ts b/script/llm/examples/index.ts index 57c4737..17bdae0 100644 --- a/script/llm/examples/index.ts +++ b/script/llm/examples/index.ts @@ -1,3 +1,19 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + export const itdocExampleJs = ` describeAPI( HttpMethod.POST, @@ -35,6 +51,24 @@ describeAPI( error: field("μ—λŸ¬ λ©”μ„Έμ§€", "username is required"), }) }) + itDoc("μ—λŸ¬κ°€ λ°œμƒν•  경우, 500 응닡을 λ°˜ν™˜ν•œλ‹€.", async () => { + const layer = getRouteLayer(targetApp, "post", "/signup") + sandbox.stub(layer, "handle").callsFake((req, res, next) => { + return res.status(500).json({ error: "Internal Server Error" }) + }) + await apiDoc + .test() + .req() + .body({ + username: field("μ‚¬μš©μž 이름", "hun"), + password: field("νŒ¨μŠ€μ›Œλ“œ(8자 이상)", "12345678"), + }) + .res() + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body({ + error: field("μ—λŸ¬ λ©”μ‹œμ§€", "Internal Server Error"), + }) + }) }, ) @@ -64,6 +98,20 @@ describeAPI( friends: field("μœ μ €μ˜ 친ꡬ", ["zagabi", "json"]), }) }) + + itDoc("μœ νš¨ν•œ ν—€λ”λ‘œ μ ‘κ·Όν•˜λ©΄ 200 응닡을 λ°˜ν™˜ν•œλ‹€.", async () => { + await apiDoc + .test() + .req() + .queryParam({ + token: field("인증 토큰A", 123456) + }) + .header({ + Authorization: field("인증 토큰B", "Bearer 123456"), + }) + .res() + .status(HttpStatus.OK) + }) }, ) ` diff --git a/script/llm/index.ts b/script/llm/index.ts index cbb9126..248380c 100644 --- a/script/llm/index.ts +++ b/script/llm/index.ts @@ -19,214 +19,116 @@ import _ from "lodash" import fs from "fs" import path from "path" import dotenv from "dotenv" -import { getItdocPrompt, getMDPrompt } from "./prompt/index" +import { getItdocPrompt } from "./prompt/index" import logger from "../../lib/config/logger" import { loadFile } from "./loader/index" import { getOutputPath } from "../../lib/config/getOutputPath" import { analyzeRoutes } from "./parser/index" -import { parseSpecFile } from "../../lib/utils/specParser" -import { resolvePath } from "../../lib/utils/pathResolver" import { RouteResult } from "./parser/type/interface" /** - * Split raw Markdown into individual test blocks. - * Each block starts with an HTTP method line and includes subsequent bullet lines. - * @param {string} markdown - The raw Markdown string containing test definitions. - * @returns {string[]} Array of trimmed test block strings. + * Extracts a path prefix for grouping tests (first two non-empty segments). + * @param {string} pathStr - Full request path (e.g. "/api/products/123"). + * @returns {string} Normalized prefix (e.g. "/api/products"). Empty -> "/". */ -function splitTestBlocks(markdown: string): string[] { - const blockRegex = - /(?:^|\n)(?:-?\s*(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+[^\n]+)(?:\n(?:- .+))*/g - const raw = markdown.match(blockRegex) || [] - return raw.map((b) => b.trim()) -} -/** - * Extract the API path prefix from a test block. - * Strips any leading dash, matches the HTTP method and path, then returns the top two segments. - * @param {string} mdBlock - A single test block string. - * @returns {string} The normalized prefix (e.g. "/api/products"). - */ -function getMarkdownPrefix(mdBlock: string): string { - const firstLine = mdBlock.split("\n")[0].trim().replace(/^-\s*/, "") // remove leading β€œ- ” - const m = firstLine.match( - /^(?:ν…ŒμŠ€νŠΈ 이름:\s*)?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([^\s]+)/i, - ) - if (!m) return "/unknown" - const path = m[2] - const parts = path.split("/").filter(Boolean) +function getPathPrefix(pathStr: string): string { + const parts = pathStr.split("/").filter(Boolean) return "/" + parts.slice(0, 2).join("/") } + /** - * Group test blocks by their path prefix and chunk each group into arrays of limited size. - * @param {string} markdown - The raw Markdown containing test blocks. - * @param {number} [chunkSize] - Maximum number of blocks per chunk. - * @returns {string[][]} Array of chunks, each a list of test block strings. + * Groups routes by prefix and chunks each group. + * @param {RouteResult[]} routes - Parsed route specs. + * @param {number} [chunkSize] - Max routes per chunk. + * @returns {RouteResult[][]} Chunked groups of routes. */ -function groupAndChunkMarkdownTests(markdown: string, chunkSize: number = 5): string[][] { - const blocks = splitTestBlocks(markdown) - const byPrefix: Record = {} - for (const blk of blocks) { - const prefix = getMarkdownPrefix(blk) - ;(byPrefix[prefix] ||= []).push(blk) +function groupAndChunkSpecRoutes(routes: RouteResult[], chunkSize: number = 10): RouteResult[][] { + const by: Record = {} + for (const r of routes) { + const prefix = getPathPrefix(r.path || "/unknown") + ;(by[prefix] ||= []).push(r) } - - const allChunks: string[][] = [] - for (const group of Object.values(byPrefix)) { - const chunks = _.chunk(group, chunkSize) - for (const c of chunks) { - allChunks.push(c) - } + const out: RouteResult[][] = [] + for (const group of Object.values(by)) { + for (const c of _.chunk(group, chunkSize)) out.push(c) } - - return allChunks + return out } + /** - * Convert grouped Markdown test definitions into itdoc-formatted TypeScript, - * calling the OpenAI API for each chunk. - * @param {OpenAI} openai - An initialized OpenAI client instance. - * @param {string} rawMarkdown - The raw Markdown test spec. - * @param {boolean} isEn - Whether to generate prompts/output in English. - * @param {boolean} [isTypeScript] - Whether output should use TypeScript syntax. - * @returns {Promise} The concatenated itdoc output or null on error. + * Creates itdoc test code from analyzed route JSON using an LLM. + * + * - Groups routes by prefix into chunks. + * - For each chunk, builds a prompt and calls OpenAI Chat Completions. + * - Concatenates all generated tests into a single string. + * @param {OpenAI} openai - OpenAI client. + * @param {RouteResult[]} raw - Array of analyzed route specs. + * @param {boolean} isEn - Output in English (true) or Korean (false). + * @param {boolean} [isTypeScript] - Generate TS-flavored examples. + * @returns {Promise} Generated test code or null on failure. */ -async function makeitdocByMD( +export async function makeitdoc( openai: OpenAI, - rawMarkdown: string, + raw: RouteResult[], isEn: boolean, isTypeScript: boolean = false, ): Promise { try { const maxRetry = 5 let result = "" - const chunks = groupAndChunkMarkdownTests(rawMarkdown, 5) + const specChunks = groupAndChunkSpecRoutes(raw) let gptCallCount = 0 - - for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { - const chunk = chunks[chunkIndex] - const chunkContent = chunk.join("\n\n") + for (let chunkIndex = 0; chunkIndex < specChunks.length; chunkIndex++) { + const routesChunk = specChunks[chunkIndex] let chunkResult = "" + for (let retry = 0; retry < maxRetry; retry++) { gptCallCount++ - logger.info(`[makeitdocByMD] Attempting GPT call : ${gptCallCount} times`) - const msg = getItdocPrompt(chunkContent, isEn, retry + 1, isTypeScript) - const response: any = await openai.chat.completions.create({ - model: "gpt-4o", + logger.info(`[makeitdoc] Attempting GPT call : ${gptCallCount} times`) + const msg = getItdocPrompt(routesChunk, isEn, retry + 1, isTypeScript) + + const response = await openai.chat.completions.create({ + model: "gpt-5", messages: [{ role: "user", content: msg }], - temperature: 0, - max_tokens: 10000, + max_completion_tokens: 10000, }) - const text = response.choices[0].message.content?.trim() ?? "" - const finishReason = response.choices[0].finish_reason - const cleaned = text - .replace(/```(?:json|javascript|typescript|markdown)?/g, "") - .replace(/```/g, "") - .replace(/\(.*?\/.*?\)/g, "") - .trim() - chunkResult += cleaned + "\n" - if (finishReason === "stop") break - await new Promise((res) => setTimeout(res, 500)) - } - result += chunkResult.trim() + "\n\n" - await new Promise((res) => setTimeout(res, 500)) - } - return result.trim() - } catch (error: unknown) { - logger.error(`makeitdocByMD() ERROR: ${error}`) - return null - } -} -/** - * Extracts the top two segments of a URL path to use as a grouping prefix. - * @param {string} path - The full request path (e.g. "/api/products/123"). - * @returns {string} The normalized prefix (e.g. "/api/products"). - */ -function getPathPrefix(path: string): string { - const parts = path.split("/").filter(Boolean) - return "/" + parts.slice(0, 2).join("/") -} -/** - * Groups an array of route objects by their path prefix and then chunks each group. - * @param {{ path: string }[]} content - Array of route objects with a `path` property. - * @param {number} [chunkSize] - Maximum number of routes per chunk. Defaults to 5. - * @returns {any[][]} A list of route chunks, each chunk is an array of route objects. - */ -function groupAndChunkRoutes(content: any[], chunkSize: number = 5): any[][] { - const grouped = _.groupBy(content, (item: { path: string }) => getPathPrefix(item.path)) - const chunkedGroups: any[][] = [] - for (const groupItems of Object.values(grouped)) { - const chunks = _.chunk(groupItems, chunkSize) - chunkedGroups.push(...chunks) - } - return chunkedGroups -} -/** - * Generates a Markdown specification by batching routes into chunks and querying the LLM. - * @param {OpenAI} openai - An initialized OpenAI client. - * @param {any[]} content - Array of route definitions to generate spec for. - * @returns {Promise} The concatenated Markdown spec, or null if an error occurred. - */ + const choice = response.choices?.[0] + const text = choice?.message?.content?.trim() ?? "" + const finishReason = choice?.finish_reason ?? null -/** - * Generates a Markdown specification by analyzing app routes using OpenAI. - * @param {OpenAI} openai - An initialized OpenAI client instance. - * @param {RouteResult[]} content - Array of route definitions to generate spec for. - * @returns {Promise} The concatenated Markdown spec, or null if an error occurred. - */ -async function makeMDByApp(openai: OpenAI, content: RouteResult[]): Promise { - try { - let cnt = 0 - const chunks = groupAndChunkRoutes(content, 4) - const maxRetry = 5 - let result = "" - for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { - const chunk = chunks[chunkIndex] - let chunkResult = "" - for (let retry = 0; retry < maxRetry; retry++) { - logger.info(`[makeMDByApp] Attempting GPT API call : ${++cnt} times`) - const msg = getMDPrompt(chunk, retry + 1) - const response: any = await openai.chat.completions.create({ - model: "gpt-4o", - messages: [{ role: "user", content: msg }], - temperature: 0, - max_tokens: 10000, - }) - const text = response.choices[0].message.content?.trim() ?? "" - const finishReason = response.choices[0].finish_reason const cleaned = text .replace(/```(?:json|javascript|typescript|markdown)?/g, "") .replace(/```/g, "") - .replace(/`markdown/g, "") - .replace(/\(.*?\/.*?\)/g, "") .trim() chunkResult += cleaned + "\n" - if (finishReason === "stop") break + if (finishReason === "stop" && cleaned) break await new Promise((res) => setTimeout(res, 500)) } + result += chunkResult.trim() + "\n\n" + await new Promise((res) => setTimeout(res, 300)) } return result.trim() } catch (error: unknown) { - logger.error(`makeMDByApp() ERROR: ${error}`) + logger.error(`makeitdoc() ERROR: ${error}`) return null } } + /** - * Main entry point to generate both Markdown specs and itdoc TypeScript tests. - * - If `testspecPath` is provided, reads and processes that file. - * - Otherwise analyzes an Express app's routes to build the spec. - * @param {string} [testspecPath] - Optional path to an existing Markdown test spec file. - * @param {string} [appPath] - Path to the Express app entry file (overrides spec metadata). - * @param {string} [envPath] - Path to the .env file containing OPENAI_API_KEY. - * @returns {Promise} Exits the process on error, otherwise writes output files. + * CLI entrypoint that: + * - Loads environment variables. + * - Analyzes an Express app file into route specs. + * - Invokes LLM to generate itdoc tests. + * - Writes the resulting test file with prelude (imports/helpers). + * @param {string} [appPath] - Path to Express app entry. + * @param {string} [envPath] - Path to .env file containing OPENAI_API_KEY. + * @returns {Promise} Exits the process on unrecoverable errors. */ -export default async function generateByLLM( - testspecPath?: string, - appPath?: string, - envPath?: string, -): Promise { +export default async function generateByLLM(appPath?: string, envPath?: string): Promise { const actualEnv = loadFile("env", envPath, false) dotenv.config({ path: actualEnv }) if (!process.env.OPENAI_API_KEY) { @@ -241,32 +143,6 @@ export default async function generateByLLM( let isTypeScript = false let appImportPath = "" let resolvedAppPath = "" - let parsedSpecContent = "" - const originalAppPath = appPath - - if (testspecPath && !originalAppPath) { - if (!fs.existsSync(testspecPath)) { - logger.error(`Test spec file not found: ${testspecPath}`) - process.exit(1) - } - - const specContent = fs.readFileSync(testspecPath, "utf8") - const { metadata, content } = parseSpecFile(specContent) - parsedSpecContent = content - - if (metadata.app) { - appPath = resolvePath(metadata.app) - logger.info(`[generateByLLM] App path found in : ${metadata.app} -> ${appPath}`) - } else { - logger.error(` - [generateByLLM] App path is not defined in the test spec file. Please define it at the top of the test spec file like below: - --- - app:@/path/to/your/app.js - --- - `) - process.exit(1) - } - } if (!appPath) { logger.error("App path not provided. Please specify it with -a or --app option.") @@ -279,49 +155,20 @@ export default async function generateByLLM( const relativePath = path.relative(outputDir, resolvedAppPath).replace(/\\/g, "/") appImportPath = relativePath.startsWith(".") ? relativePath : `./${relativePath}` - if (!testspecPath) { - let analyzedRoutes = await analyzeRoutes(resolvedAppPath) - - if (!analyzedRoutes) { - logger.error( - "AST analysis failed. Please ensure your routes use app.get() or router.post() format.", - ) - process.exit(1) - } - analyzedRoutes = analyzedRoutes.sort((a, b) => a.path.localeCompare(b.path)) - - const specFromApp = await makeMDByApp(openai, analyzedRoutes) - - if (!specFromApp) { - logger.error("Failed to generate markdown spec from app analysis.") - process.exit(1) - } - - const mdPath = path.join(outputDir, "output.md") - fs.writeFileSync(mdPath, specFromApp, "utf8") - logger.info(`Your APP Markdown spec analysis completed: ${mdPath}`) - - const doc = await makeitdocByMD(openai, specFromApp, false, isTypeScript) - if (!doc) { - logger.error("Failed to generate itdoc from markdown spec.") - process.exit(1) - } - result = doc - } else { - let specContent: string - if (parsedSpecContent) { - specContent = parsedSpecContent - } else { - specContent = loadFile("spec", testspecPath, true) - } + const analyzedRoutes = await analyzeRoutes(resolvedAppPath) + if (!analyzedRoutes) { + logger.error( + "AST analysis failed. Please ensure your routes use app.get() or router.post() format.", + ) + process.exit(1) + } - const doc = await makeitdocByMD(openai, specContent, false, isTypeScript) - if (!doc) { - logger.error("Failed to generate test code from markdown spec.") - process.exit(1) - } - result = doc + const doc = await makeitdoc(openai, analyzedRoutes, false, isTypeScript) + if (!doc) { + logger.error("Failed to generate itdoc from markdown spec.") + process.exit(1) } + result = doc if (!result) { logger.error("generateByLLM() did not return any result.") @@ -338,15 +185,48 @@ export default async function generateByLLM( let importStatement = "" if (isTypeScript) { importStatement = `import { app } from "${appImportPath}" -import { describeAPI, itDoc, HttpStatus, field, HttpMethod } from "itdoc"` +import { describeAPI, itDoc, HttpStatus, field, HttpMethod } from "itdoc" +import sinon from "sinon" +const sandbox = sinon.createSandbox() +function getRouteLayer(app, method, path) { + method = String(method).toLowerCase() + const stack = app && app._router && app._router.stack ? app._router.stack : [] + for (const layer of stack) { + if (!layer.route) continue + if (layer.route.path !== path) continue + if (!layer.route.methods || !layer.route.methods[method]) continue + const routeStack = layer.route.stack || [] + if (routeStack.length > 0) return routeStack[0] + } +} +afterEach(() => { + sandbox.restore() +}) +` } else { importStatement = `const app = require('${appImportPath}') const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc") const targetApp = app +const sinon = require("sinon") +const sandbox = sinon.createSandbox() +function getRouteLayer(app, method, path) { + method = String(method).toLowerCase() + const stack = app && app._router && app._router.stack ? app._router.stack : [] + for (const layer of stack) { + if (!layer.route) continue + if (layer.route.path !== path) continue + if (!layer.route.methods || !layer.route.methods[method]) continue + const routeStack = layer.route.stack || [] + if (routeStack.length > 0) return routeStack[0] + } +} +afterEach(() => { + sandbox.restore() +}) ` } - result = importStatement + "\n\n" + result.trim() + result = importStatement + "\n\n" + result.trim() fs.writeFileSync(outPath, result, "utf8") logger.info(`[generateByLLM] itdoc LLM SCRIPT completed.`) } diff --git a/script/llm/loader/index.ts b/script/llm/loader/index.ts index 412ed21..f044ea5 100644 --- a/script/llm/loader/index.ts +++ b/script/llm/loader/index.ts @@ -18,18 +18,31 @@ import fs from "fs" import path from "path" import logger from "../../../lib/config/logger" -type FileType = "spec" | "app" | "env" +type FileType = "app" | "env" /** - * Checks the path according to the given type and returns the file path or content. - * @param {FileType} type "spec" | "app" | "env" - * @param {string} filePath Input path (relative or absolute) - * @param {boolean} readContent If true, returns file content as string; if false, returns only the path - * @returns {string} (file content or path) + * Load and optionally read a file depending on its {@link FileType}. + * + * Behavior by type: + * - **`"app"`** (required): If the file cannot be resolved, logs an error and **terminates the process** with `process.exit(1)`. + * - **`"env"`** (optional): If the file cannot be resolved, logs a warning and returns an empty string. + * + * Resolution rules: + * - If `filePath` is provided, it is resolved relative to `process.cwd()` when not absolute. + * - If `filePath` is omitted, the function searches a set of sensible defaults for the given type. + * @param {FileType} type + * The category of file to resolve (`"app"` or `"env"`). + * @param {string} [filePath] + * An explicit path to the file. If relative, it is resolved from `process.cwd()`. + * When omitted, a default search list is used (see implementation). + * @param {boolean} [readContent] + * When `true`, returns the UTF-8 file contents; when `false`, returns the resolved absolute path. + * @returns {string} + * The resolved absolute path (when `readContent === false`) or the UTF-8 contents (when `true`). + * For missing `"env"` files, an empty string is returned. */ export function loadFile(type: FileType, filePath?: string, readContent: boolean = false): string { - const defaultPaths: Record = { - spec: [path.resolve(process.cwd(), "md/testspec.md")], + const defaults: Record = { app: [ path.resolve(process.cwd(), "app.js"), path.resolve(process.cwd(), "app.ts"), @@ -41,26 +54,41 @@ export function loadFile(type: FileType, filePath?: string, readContent: boolean env: [path.resolve(process.cwd(), ".env")], } - let resolvedPath: string - + let resolved: string | undefined if (filePath) { - resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath) + resolved = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath) } else { - const foundPath = defaultPaths[type].find((p) => fs.existsSync(p)) - - if (!foundPath) { - logger.error( - `${type} νŒŒμΌμ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€. κΈ°λ³Έ 경둜: ${defaultPaths[type].join(", ")}`, - ) - process.exit(1) + resolved = defaults[type].find((p) => fs.existsSync(p)) + } + if (!resolved || !fs.existsSync(resolved)) { + if (type === "env") { + if (filePath) { + logger.warn( + `ENV file not found at provided path: ${filePath}. Continuing without it.`, + ) + } else { + logger.warn( + `ENV file not found at default locations: ${defaults.env.join(", ")}. Continuing without it.`, + ) + } + return readContent ? "" : "" } - - resolvedPath = foundPath + if (filePath) { + logger.error(`${type} file does not exist: ${filePath}`) + } else { + logger.error(`${type} file not found. Searched: ${defaults[type].join(", ")}`) + } + process.exit(1) } + if (!readContent) return resolved - if (!fs.existsSync(resolvedPath)) { - logger.error(`${type} 파일이 μ‘΄μž¬ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€: ${resolvedPath}`) + try { + return fs.readFileSync(resolved, "utf8") + } catch (err) { + logger.error(`Failed to read ${type} file: ${resolved}. ${(err as Error).message}`) + if (type === "env") { + return "" + } process.exit(1) } - return readContent ? fs.readFileSync(resolvedPath, "utf8") : resolvedPath } diff --git a/script/llm/parser/analyzer/returnValueExtractor.ts b/script/llm/parser/analyzer/returnValueExtractor.ts deleted file mode 100644 index c8a0c7b..0000000 --- a/script/llm/parser/analyzer/returnValueExtractor.ts +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright 2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NodePath } from "@babel/traverse" -import * as t from "@babel/types" -import { getProjectFiles, parseMultipleFiles } from "../utils/fileParser" -import { extractValue } from "../utils/extractValue" -import traversePkg from "@babel/traverse" -// @ts-expect-error - CommonJS/ES modules ν˜Έν™˜μ„± 이슈둜 μΈν•œ νƒ€μž… μ—λŸ¬ λ¬΄μ‹œ -const traverse = traversePkg.default - -/** - * Dynamically finds related service methods across the project and extracts actual return values. - * @param {string} methodName - Method name to find (e.g., getAllProducts) - * @param {string} projectRoot - Project root path - * @returns {any} Extracted actual return value or null - */ -export function extractActualReturnValue(methodName: string, projectRoot: string): any { - try { - const filePaths = getProjectFiles(projectRoot) - const parsedFiles = parseMultipleFiles(filePaths) - - for (const { ast } of parsedFiles) { - const result = extractReturnValueFromAST(ast, methodName) - - if (result && !hasPartialNullValues(result)) { - return result - } - } - - return null - } catch { - return null - } -} - -/** - * Checks if an object contains null values. - * @param {any} obj - Object to check - * @returns {boolean} Whether null values are included - */ -export function hasPartialNullValues(obj: any): boolean { - if (obj === null || obj === undefined) return true - if (typeof obj !== "object") return false - if (Array.isArray(obj)) { - return obj.some((item) => hasPartialNullValues(item)) - } - return Object.values(obj).some((value) => hasPartialNullValues(value)) -} - -/** - * Dynamically extracts return values of specific methods from AST. - * @param {t.File} ast - File AST - * @param {string} methodName - Method name - * @returns {any} Return value structure - */ -export function extractReturnValueFromAST(ast: t.File, methodName: string): any { - let returnValue: any = null - - traverse(ast, { - ClassMethod(methodPath: NodePath) { - if (t.isIdentifier(methodPath.node.key) && methodPath.node.key.name === methodName) { - returnValue = extractReturnFromFunction(methodPath.node, ast) - } - }, - ObjectMethod(methodPath: NodePath) { - if (t.isIdentifier(methodPath.node.key) && methodPath.node.key.name === methodName) { - returnValue = extractReturnFromFunction(methodPath.node, ast) - } - }, - VariableDeclarator(varPath: NodePath) { - if (t.isObjectExpression(varPath.node.init)) { - const objExpr = varPath.node.init - objExpr.properties.forEach((prop) => { - if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) { - if (prop.key.name === methodName) { - if ( - t.isArrowFunctionExpression(prop.value) || - t.isFunctionExpression(prop.value) - ) { - returnValue = extractReturnFromFunction(prop.value, ast) - } - } - } - }) - } else if ( - t.isIdentifier(varPath.node.id) && - varPath.node.id.name === methodName && - (t.isArrowFunctionExpression(varPath.node.init) || - t.isFunctionExpression(varPath.node.init)) - ) { - returnValue = extractReturnFromFunction(varPath.node.init, ast) - } - }, - FunctionDeclaration(funcPath: NodePath) { - if (funcPath.node.id && funcPath.node.id.name === methodName) { - returnValue = extractReturnFromFunction(funcPath.node, ast) - } - }, - }) - - return returnValue -} - -/** - * Extracts return structure from function/method - * @param {t.Function} func - Function node - * @param {t.File} ast - Complete file AST (for variable finding) - * @returns {any} Return value structure - */ -export function extractReturnFromFunction(func: t.Function, ast?: t.File): any { - let returnValue: any = null - const visitedVariables = new Set() - - /** - * Recursively traverses nodes to find all ReturnStatements. - * @param {t.Node} node Node to traverse - * @returns {t.ReturnStatement[]} All ReturnStatements - */ - function findAllReturnStatements(node: t.Node): t.ReturnStatement[] { - const returns: t.ReturnStatement[] = [] - - if (t.isReturnStatement(node)) { - returns.push(node) - } - - if (t.isBlockStatement(node)) { - for (const stmt of node.body) { - returns.push(...findAllReturnStatements(stmt)) - } - } - - if (t.isIfStatement(node)) { - returns.push(...findAllReturnStatements(node.consequent)) - if (node.alternate) { - returns.push(...findAllReturnStatements(node.alternate)) - } - } - - return returns - } - - /** - * Selects the most meaningful ReturnStatement. - * Prioritizes actual values over undefined or null. - * @param {t.ReturnStatement[]} returnStatements Return statements - * @returns {t.ReturnStatement | null} The most meaningful ReturnStatement - */ - function selectBestReturnStatement( - returnStatements: t.ReturnStatement[], - ): t.ReturnStatement | null { - if (returnStatements.length === 0) return null - - for (const stmt of returnStatements) { - if (stmt.argument) { - if (t.isIdentifier(stmt.argument)) { - if (stmt.argument.name !== "undefined" && stmt.argument.name !== "null") { - return stmt - } - } else { - return stmt - } - } - } - - return returnStatements[0] - } - - if (func.body && t.isBlockStatement(func.body)) { - const allReturns = findAllReturnStatements(func.body) - const returnStmt = selectBestReturnStatement(allReturns) - - if (returnStmt?.argument) { - returnValue = extractValue(returnStmt.argument, {}, {}, ast, visitedVariables) - } - } else if (func.body && t.isExpression(func.body)) { - returnValue = extractValue(func.body, {}, {}, ast, visitedVariables) - } - - return returnValue -} diff --git a/script/llm/parser/analyzer/routeAnalyzer.ts b/script/llm/parser/analyzer/routeAnalyzer.ts index bbb370d..e34f1c8 100644 --- a/script/llm/parser/analyzer/routeAnalyzer.ts +++ b/script/llm/parser/analyzer/routeAnalyzer.ts @@ -159,14 +159,31 @@ function findFunctionDefinition( return foundFunction } - /** - * Analyzes the body of a given function node to collect request/response information. - * @param {t.FunctionExpression | t.ArrowFunctionExpression} functionNode - The function node to analyze. - * @param {string} source - The source code of the file. - * @param {any} ret - The object that collects analysis results. - * @param {NodePath} parentPath - The parent call expression node. - * @param {t.File} [ast] - The full AST of the file, used for nested function analysis. + * Analyze the body of a route handler function to extract request/response metadata. + * + * Walks the handler’s AST and delegates to specialized analyzers: + * - **VariableDeclarator** β†’ `analyzeVariableDeclarator` + * Captures destructured `req` fields (`query`, `params`, `headers`, `body`), tracks identifiers, + * and records samples for local array literals (e.g., `const members = [...]`). + * - **CallExpression** β†’ `analyzeResponseCall` + * Detects `res.status(...)`, `res.json(...)`, `res.send(...)`, and aggregates default/branch responses. + * - **MemberExpression** β†’ `analyzeMemberExpression` + * Tracks usage like `req.headers.*`, `req.body.*`, and analyzes inline `res.json({ ... })` objects. + * + * The function **mutates** the provided accumulator `ret` in place (adds req field sets, response maps, etc.). + * @param {t.FunctionExpression | t.ArrowFunctionExpression} functionNode + * The route handler (function or arrow function) whose body will be traversed. + * @param {string} source + * Raw source code of the file; forwarded to sub-analyzers for context (e.g., snippet extraction). + * @param {any} ret + * Mutable accumulator object that will be enriched with analysis results + * (e.g., `reqHeaders`, `reqParams`, `reqQuery`, `bodyFields`, `defaultResponse`, `branchResponses`). + * @param {NodePath} parentPath + * The `CallExpression` path that registered the route (e.g., `app.get(...)`), used to inherit scope during traversal. + * @param {t.File} [ast] + * Optional full-file AST. When provided, sub-analyzers may use it to resolve identifiers across the file. + * @returns {void} Mutates `ret` in place; no value is returned. */ export function analyzeFunctionBody( functionNode: t.FunctionExpression | t.ArrowFunctionExpression, diff --git a/script/llm/parser/analyzer/variableAnalyzer.ts b/script/llm/parser/analyzer/variableAnalyzer.ts index fceb975..6e219b3 100644 --- a/script/llm/parser/analyzer/variableAnalyzer.ts +++ b/script/llm/parser/analyzer/variableAnalyzer.ts @@ -16,13 +16,60 @@ import { NodePath } from "@babel/traverse" import * as t from "@babel/types" -import { createFunctionIdentifier } from "../utils/extractValue" +import { extractValue } from "../utils/extractValue" /** - * Analyzes request parameters and function call results from variable declarations. - * @param {NodePath} varPath - Variable declaration node - * @param {any} ret - Analysis result storage object - * @param {Record} localArrays - Local array storage object + * Ensures that the accumulator object `ret` has a response json field map. + * @param {any} ret Mutable accumulator for analysis results. + * @returns {Record} The ensured `responseJsonFieldMap`. + */ +function ensureResMap(ret: any) { + if (!ret.responseJsonFieldMap) ret.responseJsonFieldMap = {} + return ret.responseJsonFieldMap as Record +} + +/** + * Analyze `res.json({ ... })` object literal and fill metadata for each property. + * @param {t.ObjectExpression} obj Object literal passed to `res.json`. + * @param {any} ret Mutable accumulator for analysis results (augments `responseJsonFieldMap`). + * @param {Record | undefined} variableMap Mapping of identifiers to previously-resolved descriptors. + * @param {Record} localArrays Map of local array identifiers (for value tracking). + * @param {t.File | undefined} ast Whole-file AST (optional). + * @returns {void} + */ +function analyzeJsonObjectFields( + obj: t.ObjectExpression, + ret: any, + variableMap: Record | undefined, + localArrays: Record, + ast?: t.File, +) { + const out = ensureResMap(ret) + + for (const p of obj.properties) { + if (!t.isObjectProperty(p)) continue + + let keyName: string | null = null + if (t.isIdentifier(p.key)) keyName = p.key.name + else if (t.isStringLiteral(p.key)) keyName = p.key.value + if (!keyName) continue + + const v = p.value + const extracted = extractValue(v as t.Node, localArrays, variableMap ?? {}, ast) + if (extracted !== null && extracted !== undefined) { + out[keyName] = extracted + } + } +} + +/** + * Analyze a variable declarator for: + * - Calls/constructors assigned to identifiers + * - Destructuring from `req.query|params|body|headers` and track field usage + * @param {NodePath} varPath Variable declarator path. + * @param {any} ret Mutable accumulator for analysis results (adds `variableMap`, `req*` sets). + * @param {Record} localArrays Map of local array identifiers (for value tracking). + * @returns {void} */ export function analyzeVariableDeclarator( varPath: NodePath, @@ -32,24 +79,27 @@ export function analyzeVariableDeclarator( const decl = varPath.node if (t.isIdentifier(decl.id) && t.isArrayExpression(decl.init)) { - localArrays[decl.id.name] = [] + const arrVal = extractValue( + decl.init, + localArrays, + ret.variableMap ?? {}, + ret.ast as t.File | undefined, + ) + localArrays[decl.id.name] = Array.isArray(arrVal) ? arrVal : [] return } if (t.isIdentifier(decl.id) && decl.init) { - let callExpression: t.CallExpression | null = null + if (!ret.variableMap) ret.variableMap = {} + const maybe = extractValue( + decl.init as t.Node, + localArrays, + ret.variableMap, + ret.ast as t.File | undefined, + ) - if (t.isAwaitExpression(decl.init) && t.isCallExpression(decl.init.argument)) { - callExpression = decl.init.argument - } else if (t.isCallExpression(decl.init)) { - callExpression = decl.init - } - - if (callExpression) { - if (!ret.variableMap) { - ret.variableMap = {} - } - ret.variableMap[decl.id.name] = createFunctionIdentifier(callExpression) + if (maybe !== null && maybe !== undefined) { + ret.variableMap[decl.id.name] = maybe } } @@ -57,25 +107,62 @@ export function analyzeVariableDeclarator( t.isObjectPattern(decl.id) && t.isMemberExpression(decl.init) && t.isIdentifier(decl.init.object, { name: "req" }) && - t.isIdentifier(decl.init.property) + (t.isIdentifier(decl.init.property) || t.isStringLiteral(decl.init.property)) ) { - const propName = decl.init.property.name + const propName = t.isIdentifier(decl.init.property) + ? decl.init.property.name + : decl.init.property.value + decl.id.properties.forEach((prop: any) => { - if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) { - const fieldName = prop.key.name + if (t.isObjectProperty(prop)) { + const key = prop.key + const fieldName = t.isIdentifier(key) + ? key.name + : t.isStringLiteral(key) + ? key.value + : null + if (!fieldName) return switch (propName) { case "query": ret.reqQuery.add(fieldName) + if (!ret.variableMap) ret.variableMap = {} + ret.variableMap[fieldName] = { + type: "member_access", + object: "req.query", + property: fieldName, + identifier: `req.query.${fieldName}`, + } break case "params": ret.reqParams.add(fieldName) + if (!ret.variableMap) ret.variableMap = {} + ret.variableMap[fieldName] = { + type: "member_access", + object: "req.params", + property: fieldName, + identifier: `req.params.${fieldName}`, + } break case "headers": ret.reqHeaders.add(fieldName.toLowerCase()) + if (!ret.variableMap) ret.variableMap = {} + ret.variableMap[fieldName] = { + type: "member_access", + object: "req.headers", + property: fieldName.toLowerCase(), + identifier: `req.headers.${fieldName.toLowerCase()}`, + } break case "body": ret.bodyFields.add(fieldName) + if (!ret.variableMap) ret.variableMap = {} + ret.variableMap[fieldName] = { + type: "member_access", + object: "req.body", + property: fieldName, + identifier: `req.body.${fieldName}`, + } break } } @@ -84,21 +171,37 @@ export function analyzeVariableDeclarator( } /** - * Analyzes request parameters from member expressions. - * @param {NodePath} memPath - Member expression node - * @param {any} ret - Analysis result storage object + * Inspect member-expressions to: + * - Track request field usage (`req.headers.*`, `req.body.*`) + * - Detect `res.json({ ... })` calls and analyze their object argument + * @param {NodePath} memPath Member expression path. + * @param {any} ret Mutable accumulator for analysis results. + * @returns {void} */ export function analyzeMemberExpression(memPath: NodePath, ret: any) { const mm = memPath.node + const parentObj = ((): t.MemberExpression | t.OptionalMemberExpression | null => { + if (t.isMemberExpression(mm.object) || t.isOptionalMemberExpression(mm.object)) { + return mm.object + } + return null + })() + if ( - t.isMemberExpression(mm.object) && - t.isIdentifier(mm.object.object, { name: "req" }) && - t.isIdentifier(mm.object.property) && - t.isIdentifier(mm.property) + parentObj && + t.isIdentifier(parentObj.object, { name: "req" }) && + (t.isIdentifier(parentObj.property) || t.isStringLiteral(parentObj.property)) ) { - const parent = mm.object.property.name - const child = mm.property.name + const parent = t.isIdentifier(parentObj.property) + ? parentObj.property.name + : parentObj.property.value + + let child: string | null = null + if (t.isIdentifier(mm.property)) child = mm.property.name + else if (t.isStringLiteral(mm.property)) child = mm.property.value + + if (!child) return if (parent === "headers") { ret.reqHeaders.add(child.toLowerCase()) @@ -106,4 +209,32 @@ export function analyzeMemberExpression(memPath: NodePath, r ret.bodyFields.add(child) } } + + const isJsonCallee = + (t.isIdentifier(mm.property, { name: "json" }) || + t.isStringLiteral(mm.property, { value: "json" })) && + (t.isIdentifier(mm.object, { name: "res" }) || + (t.isCallExpression(mm.object) && + (t.isMemberExpression(mm.object.callee) || + t.isOptionalMemberExpression(mm.object.callee)) && + (t.isIdentifier((mm.object.callee as t.MemberExpression).property, { + name: "status", + }) || + t.isStringLiteral((mm.object.callee as t.MemberExpression).property as any, { + value: "status", + })))) + + if (isJsonCallee && memPath.parentPath && memPath.parentPath.isCallExpression()) { + const call = memPath.parentPath.node + const firstArg = call.arguments[0] + if (firstArg && t.isObjectExpression(firstArg)) { + analyzeJsonObjectFields( + firstArg, + ret, + ret.variableMap, + ret.localArrays ?? {}, + ret.ast as t.File | undefined, + ) + } + } } diff --git a/script/llm/parser/utils/extractValue.ts b/script/llm/parser/utils/extractValue.ts index 2f0d683..bf32759 100644 --- a/script/llm/parser/utils/extractValue.ts +++ b/script/llm/parser/utils/extractValue.ts @@ -15,46 +15,249 @@ */ import * as t from "@babel/types" -import traversePkg from "@babel/traverse" -// @ts-expect-error - CommonJS/ES modules ν˜Έν™˜μ„± 이슈둜 μΈν•œ νƒ€μž… μ—λŸ¬ λ¬΄μ‹œ +import traversePkg, { NodePath } from "@babel/traverse" +// @ts-expect-error Interop between CJS/ESM default export of @babel/traverse const traverse = traversePkg.default -import { NodePath } from "@babel/traverse" /** - * Creates identifier information for function calls. - * @param {t.CallExpression} callExpression - Function call expression - * @returns {object} Identifier information + * Convert a call's argument list into a compact string signature. + * + * - Spreads become `...` + * - Argument placeholders become `?` + * - Other expressions are rendered via `exprToString` + * @param {(t.Expression | t.SpreadElement | t.ArgumentPlaceholder)[]} args Call arguments. + * @returns {string} Comma-separated argument signature. */ -export function createFunctionIdentifier(callExpression: t.CallExpression): object { - if (t.isMemberExpression(callExpression.callee)) { - const { object, property } = callExpression.callee - - if (t.isIdentifier(object) && t.isIdentifier(property)) { - return { - type: "function_call", - object: object.name, - method: property.name, - identifier: `${object.name}.${property.name}()`, - } +function argsToString(args: Array): string { + return args + .map((a) => { + if (t.isSpreadElement(a)) return `...${exprToString(a.argument as t.Node)}` + if (t.isArgumentPlaceholder(a)) return "?" + return exprToString(a as t.Node) + }) + .join(", ") +} + +/** + * Retrieve a representative array sample for an identifier that refers to an array. + * + * Looks up: + * 1) `localArrays[name]` for in-scope literals + * 2) `variableMap[name].sample` for previously captured samples + * @param {string | undefined} objName Identifier to resolve. + * @param {Record} localArrays Map of local array literals. + * @param {Record} variableMap Map of prior variable descriptors. + * @returns {any[] | undefined} An example array if known. + */ +function getArraySample( + objName: string | undefined, + localArrays: Record, + variableMap: Record, +): any[] | undefined { + if (!objName) return undefined + if (Array.isArray(localArrays[objName])) return localArrays[objName] + const v = variableMap[objName] + if (v && Array.isArray(v.sample)) return v.sample + return undefined +} + +/** + * Safely converts a value to a string. + * + * - If the value is already a string, it is returned as-is. + * - If the value is an object, it is serialized into a JSON string. + * @param {string | object} v - The value to be converted. + * @returns {string} The string representation of the input value. + */ +function toSig(v: string | object): string { + return typeof v === "string" ? v : JSON.stringify(v) +} +/** + * Create a normalized descriptor for a member access expression. + * + * Supports: + * - Optional chaining (`a?.b`) + * - Computed properties (`a["b"]`, `a?.[x]`) + * @param {t.MemberExpression | t.OptionalMemberExpression} mem Member expression node. + * @returns {{type: 'member_access', object: string, property: string, identifier: string}} Access metadata. + */ + +/** + * + * @param mem + */ +function createMemberAccessIdentifier(mem: t.MemberExpression | t.OptionalMemberExpression) { + const objStr = toSig(exprToString(mem.object)) // <- string | object β†’ string + + let propName = "" + if (t.isIdentifier(mem.property)) propName = mem.property.name + else if (t.isStringLiteral(mem.property)) propName = mem.property.value + else propName = toSig(exprToString(mem.property as t.Node)) // <- μ•ˆμ „ λ³€ν™˜ + + const sep = t.isOptionalMemberExpression(mem) ? "?." : "." + const accessor = mem.computed + ? `${sep}[${toSig(exprToString(mem.property as t.Node))}]` // <- μ•ˆμ „ λ³€ν™˜ + : `${sep}${propName}` + + return { + type: "member_access" as const, + object: objStr, + property: propName, + identifier: `${objStr}${accessor}`, + } +} + +/** + * Render an AST expression as a readable string or structured object. + * + * - Literals β†’ literal string + * - `new Ctor(args)` β†’ `"new Ctor()"` + * - `a.b` / `a?.b` / computed forms β†’ `"a.b"` / `"a?.[b]"` + * - Calls/optional calls β†’ structured via `createInvocationIdentifier` + * @param {t.Node | null | undefined} node AST node to stringify. + * @param {Record} [localArrays] Local arrays for enrichment. + * @param {Record} [variableMap] Variable descriptors for enrichment. + * @returns {string | object} Human-readable signature or a call descriptor. + */ +function exprToString( + node: t.Node | null | undefined, + localArrays: Record = {}, + variableMap: Record = {}, +): string | object { + if (!node) return "" + if (t.isIdentifier(node)) return node.name + if (t.isThisExpression(node)) return "this" + if (t.isSuper(node)) return "super" + if (t.isStringLiteral(node)) return node.value + if (t.isNumericLiteral(node)) return String(node.value) + if (t.isBooleanLiteral(node)) return String(node.value) + if (t.isNullLiteral(node)) return "null" + + if (t.isNewExpression(node)) { + const ctor = exprToString(node.callee, localArrays, variableMap) + const argsSig = node.arguments ? argsToString(node.arguments as any) : "" + return `new ${ctor}(${argsSig})` + } + + if (t.isMemberExpression(node)) { + const obj = exprToString(node.object, localArrays, variableMap) + const prop = node.computed + ? `[${exprToString(node.property as t.Node, localArrays, variableMap)}]` + : `.${exprToString(node.property as t.Node, localArrays, variableMap)}` + return `${obj}${prop}` + } + + if (t.isOptionalMemberExpression(node)) { + const obj = exprToString(node.object, localArrays, variableMap) + const prop = node.computed + ? `?.[${exprToString(node.property as t.Node, localArrays, variableMap)}]` + : `?.${exprToString(node.property as t.Node, localArrays, variableMap)}` + return `${obj}${prop}` + } + + if (t.isOptionalCallExpression(node)) { + const info: any = createInvocationIdentifier(node, localArrays, variableMap) + if (info && typeof info.object === "string") { + const sample = getArraySample(info.object, localArrays, variableMap) + if (sample) info.sample = sample } - } else if (t.isIdentifier(callExpression.callee)) { + return info + } + + if (t.isCallExpression(node)) { + const info: any = createInvocationIdentifier(node, localArrays, variableMap) + if (info && typeof info.object === "string") { + const sample = getArraySample(info.object, localArrays, variableMap) + if (sample) info.sample = sample + } + return info + } + + return `<${node.type}>` +} + +/** + * Build a normalized descriptor for call and optional-call expressions. + * + * Shapes: + * - Member calls β†’ `{ type: 'function_call', object, method, identifier }` + * - Identifier calls β†’ `{ type: 'function_call', method, identifier }` + * - Complex callees β†’ `{ type: 'function_call', identifier }` + * @param {t.CallExpression | t.OptionalCallExpression} inv Invocation node. + * @param {Record} [localArrays] Local array map for enrichment. + * @param {Record} [variableMap] Variable map for enrichment. + * @returns {object} Call descriptor. + */ +function createInvocationIdentifier( + inv: t.CallExpression | t.OptionalCallExpression, + localArrays: Record = {}, + variableMap: Record = {}, +): object { + const callee = inv.callee as t.Expression + const argsSig = argsToString(inv.arguments) + + if (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) { + const objResult = exprToString(callee.object, localArrays, variableMap) + const objStr = typeof objResult === "string" ? objResult : JSON.stringify(objResult) + + let methodName = "" + if (t.isIdentifier(callee.property)) methodName = callee.property.name + else if (t.isStringLiteral(callee.property)) methodName = callee.property.value + else { + const propResult = exprToString(callee.property as t.Node, localArrays, variableMap) + methodName = typeof propResult === "string" ? propResult : JSON.stringify(propResult) + } + + const sep = t.isOptionalMemberExpression(callee) ? "?." : "." + const accessorForId = callee.computed + ? `${sep}[${exprToString(callee.property as t.Node, localArrays, variableMap)}]` + : `${sep}${methodName}` + + return { + type: "function_call", + object: objStr, + method: methodName, + identifier: `${objStr}${accessorForId}(${argsSig})`, + } + } + + if (t.isIdentifier(callee)) { return { type: "function_call", - method: callExpression.callee.name, - identifier: `${callExpression.callee.name}()`, + method: callee.name, + identifier: `${callee.name}(${argsSig})`, } } + const calleeResult = exprToString(callee, localArrays, variableMap) + const calleeStr = typeof calleeResult === "string" ? calleeResult : JSON.stringify(calleeResult) + return { type: "function_call", - identifier: "", + identifier: `${calleeStr}(${argsSig})`, } } /** - * Handles basic literal values - * @param {t.Node} node Node to extract value from - * @returns {any} Extracted actual value or null + * Create a function-call descriptor while preserving the existing signature, + * extended to support `OptionalCallExpression`. + * @param {t.CallExpression | t.OptionalCallExpression} callExpression Call or optional-call. + * @param {Record} [localArrays] Local arrays for enrichment. + * @param {Record} [variableMap] Variable map for enrichment. + * @returns {object} Call descriptor. + */ +export function createFunctionIdentifier( + callExpression: t.CallExpression | t.OptionalCallExpression, + localArrays: Record = {}, + variableMap: Record = {}, +): object { + return createInvocationIdentifier(callExpression, localArrays, variableMap) +} + +/** + * Extract a primitive value from a literal node. + * @param {t.Node} node Candidate literal node. + * @returns {any} JS value for string/number/boolean/null, or `undefined` if not a basic literal. */ function extractLiteralValue(node: t.Node): any { if (t.isStringLiteral(node)) return node.value @@ -64,13 +267,16 @@ function extractLiteralValue(node: t.Node): any { } /** - * Handles object expressions - * @param {t.ObjectExpression} node Object expression node - * @param {Record} localArrays Local array map - * @param {Record} variableMap Variable map - * @param {t.File} ast Complete file AST (for variable tracking) - * @param {Set} visitedVariables Visited variables (to prevent circular references) - * @returns {any} Extracted actual value or null + * Extract a plain object from an `ObjectExpression`. + * + * - Each property value is recursively extracted via `extractValue`. + * - Spread properties are resolved via `resolveSpreadValue`. + * @param {t.ObjectExpression} node Object literal node. + * @param {Record} localArrays Local arrays map. + * @param {Record} variableMap Variable descriptors. + * @param {t.File} [ast] Whole-file AST, used to chase identifiers if needed. + * @param {Set} [visitedVariables] Cycle guard set. + * @returns {any} Plain JS object representing the expression. */ function extractObjectValue( node: t.ObjectExpression, @@ -91,7 +297,6 @@ function extractObjectValue( ast, visitedVariables, ) - obj[key] = value !== null ? value : null } else if (t.isSpreadElement(prop)) { const resolved = resolveSpreadValue( @@ -133,13 +338,18 @@ function extractObjectValue( } /** - * Handles array expressions - * @param {t.ArrayExpression} node Array expression node - * @param {Record} localArrays Local array map - * @param {Record} variableMap Variable map - * @param {t.File} ast Complete file AST (for variable tracking) - * @param {Set} visitedVariables Visited variables (to prevent circular references) - * @returns {any} Extracted actual value or null + * Extract an array from an `ArrayExpression`. + * + * - Elements are recursively extracted via `extractValue`. + * - Spread elements are resolved via `resolveSpreadValue`. + * @param {t.ArrayExpression} node Array literal node. + * @param {Record localArrays Local arrays map. + * @param {Record variableMap Variable descriptors. + * @param localArrays + * @param variableMap + * @param {t.File} [ast] Whole-file AST for identifier resolution. + * @param {Set} [visitedVariables] Cycle guard set. + * @returns {any[]} Array value. */ function extractArrayValue( node: t.ArrayExpression, @@ -180,13 +390,15 @@ function extractArrayValue( } /** - * Handles identifiers - * @param {t.Identifier} node Identifier node - * @param {Record} localArrays Local array map - * @param {Record} variableMap Variable map - * @param {t.File} ast Complete file AST (for variable tracking) - * @param {Set} visitedVariables Visited variables (to prevent circular references) - * @returns {any} Extracted actual value or null + * Resolve the value of an identifier where possible. + * @param {t.Identifier} node Identifier node. + * @param {Record localArrays Local arrays map. + * @param {Record variableMap Variable descriptors. + * @param localArrays + * @param variableMap + * @param {t.File} [ast] Whole-file AST for identifier resolution. + * @param {Set} [visitedVariables] Cycle guard set. + * @returns {any} Resolved value or `null` when unknown. */ function extractIdentifierValue( node: t.Identifier, @@ -201,7 +413,9 @@ function extractIdentifierValue( if (variableMap[name]) { const mapping = variableMap[name] - return mapping.sample || mapping + if (Array.isArray(mapping?.samples)) return mapping.samples + if (Array.isArray(mapping?.sample)) return mapping.sample + return mapping } if (ast) { @@ -212,13 +426,22 @@ function extractIdentifierValue( } /** - * Value extraction function - * @param {t.Node} node - AST node to extract - * @param {Record} localArrays - Map of locally defined array variables - * @param {Record} variableMap - Variable name to data structure mapping - * @param {t.File} ast - Complete file AST (for variable tracking) - * @param {Set} visitedVariables - For preventing circular references - * @returns {any} Extracted actual value or identifier information + * Extract a best-effort JS value (or structured descriptor) from an arbitrary AST node. + * + * Order: + * - Literals β†’ primitive + * - Object β†’ plain object + * - Array β†’ array + * - Identifier β†’ resolved via maps/AST + * - MemberExpression/OptionalMemberExpression β†’ member access descriptor + * - Call/OptionalCall β†’ call descriptor + * - NewExpression β†’ constructor descriptor + * @param {t.Node} node Any AST node to extract. + * @param {Record} localArrays Local arrays map. + * @param {Record} [variableMap] Variable descriptors. + * @param {t.File} [ast] Whole-file AST (optional). + * @param {Set} [visitedVariables] Cycle guard set. + * @returns {any} Extracted value or descriptor, or `null` if not resolvable. */ export function extractValue( node: t.Node, @@ -233,30 +456,83 @@ export function extractValue( if (t.isObjectExpression(node)) { return extractObjectValue(node, localArrays, variableMap, ast, visitedVariables) } - if (t.isArrayExpression(node)) { return extractArrayValue(node, localArrays, variableMap, ast, visitedVariables) } - if (t.isIdentifier(node)) { return extractIdentifierValue(node, localArrays, variableMap, ast, visitedVariables) } + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + return createMemberAccessIdentifier(node) + } + + if (t.isOptionalCallExpression(node)) { + const info: any = createInvocationIdentifier(node, localArrays, variableMap) + if (info && typeof info.object === "string") { + const sampleArr = ((): any[] | undefined => { + const v = variableMap[info.object] + if (v && Array.isArray(v?.samples)) return v.samples + if (v && Array.isArray(v?.sample)) return v.sample + const fromLocal = localArrays[info.object] + if (Array.isArray(fromLocal)) return fromLocal + return undefined + })() + if (sampleArr) { + info.samples = sampleArr // μ£Ό 좜λ ₯ + if (info.sample === undefined) { + info.sample = sampleArr // ν•˜μœ„ν˜Έν™˜(λ‹¨μˆ˜ 킀도 채움) + } + } + } + return info + } + if (t.isCallExpression(node)) { - return createFunctionIdentifier(node) + const info: any = createInvocationIdentifier(node, localArrays, variableMap) + if (info && typeof info.object === "string") { + const sampleArr = ((): any[] | undefined => { + const v = variableMap[info.object] + if (v && Array.isArray(v?.samples)) return v.samples + if (v && Array.isArray(v?.sample)) return v.sample + const fromLocal = localArrays[info.object] + if (Array.isArray(fromLocal)) return fromLocal + return undefined + })() + if (sampleArr) { + info.samples = sampleArr + if (info.sample === undefined) { + info.sample = sampleArr + } + } + } + return info + } + if (t.isNewExpression(node)) { + const ctor = exprToString(node.callee, localArrays, variableMap) + const ctorStr = typeof ctor === "string" ? ctor : JSON.stringify(ctor) + const argsSig = node.arguments ? argsToString(node.arguments as any) : "" + return { + type: "constructor_call", + constructor: ctorStr, + identifier: `new ${ctorStr}(${argsSig})`, + } } return null } /** - * Resolves values referenced by spread operators - * @param {t.Node} node - Spread target node - * @param {Record} localArrays - Local array map - * @param {Record} variableMap - Variable map - * @param {t.File} ast - File AST - * @param {Set} visitedVariables - Visited variables - * @returns {any} Resolved value or null + * Resolve the value referenced by a spread argument (`...x`) used in object/array literals. + * + * - Directly returns from `localArrays` or `variableMap` when available. + * - Otherwise searches the AST for variable initializers/assignments. + * @param {t.Node} node Spread argument node. + * @param {Record} localArrays Local arrays map. + * @param {Record} variableMap Variable descriptors. + * @param {t.File} [ast] Whole-file AST for identifier resolution. + * @param {Set} [visitedVariables] Cycle guard set. + * @returns {any} Resolved spread value or `null` if not resolvable. */ function resolveSpreadValue( node: t.Node, @@ -338,13 +614,14 @@ function resolveSpreadValue( } /** - * Finds the actual value of a variable from AST - * @param {string} variableName - Variable name to find - * @param {t.File} ast - File AST - * @param {Set} visitedVariables - Visited variables (to prevent circular references) - * @param {Record} localArrays - Local arrays - * @param {Record} variableMap - Variable map - * @returns {any} Actual value of the variable or null + * Find the value assigned to a variable by scanning the AST for matching + * declarations and assignments. Prevents cycles via `visitedVariables`. + * @param {string} variableName Identifier to resolve. + * @param {t.File | undefined} ast Whole-file AST. + * @param {Set} visitedVariables Cycle guard set. + * @param {Record} localArrays Local arrays map. + * @param {Record} variableMap Variable descriptors. + * @returns {any} Resolved value or `null` when not found. */ function findVariableValue( variableName: string, diff --git a/script/llm/prompt/index.ts b/script/llm/prompt/index.ts index 08cd404..fe6fc91 100644 --- a/script/llm/prompt/index.ts +++ b/script/llm/prompt/index.ts @@ -15,21 +15,33 @@ */ import { itdocExampleJs, itdocExampleTs } from "../examples/index" +import { RouteResult } from "../parser/type/interface" + /** - * Returns a prompt message for generating itdoc functions to create API documentation - * and test cases based on the given test content and language settings. + * Generates an instruction prompt for creating `itdoc` test scripts + * based on analyzed route information and language settings. + * + * The function dynamically selects between JavaScript and TypeScript + * examples and returns a localized prompt (Korean or English) with + * strict formatting and output rules for test code generation. * - * This function reads specified example files (e.g., Express test files) and includes them - * as function examples, then appends additional messages according to the input test content - * and language settings. - * @param {string} content - String containing test content (description of test cases). - * @param {boolean} isEn - If true, sets additional messages to output in English; if false, in Korean. - * @param {number} part - Current part number when output is divided into multiple parts - * @param {boolean} isTypeScript - If true, outputs in TypeScript; if false, in JavaScript - * @returns {string} - Generated prompt message string. + * Rules include: + * - Output only test code lines (no comments, explanations, or prose). + * - Follow order and branching guides when generating tests. + * - Ensure field calls have exactly two arguments. + * - Properly quote HTTP headers with hyphens. + * - Do not generate redundant imports or initialization code. + * - Split long outputs into chunks when necessary. + * @param {RouteResult[]} routesChunk - A chunk of parsed route definitions + * used as the basis for test generation. + * @param {boolean} isEn - Whether to generate the prompt in English (true) or Korean (false). + * @param {number} part - The sequential part number of the response when the GPT call does not return the full output at once and multiple calls are made to retrieve the continuation. + * @param {boolean} [isTypeScript] - Whether to generate TypeScript-based test examples instead of JavaScript. + * @returns {string} A formatted prompt containing route information, language rules, + * and example code for generating `itdoc` tests. */ export function getItdocPrompt( - content: string, + routesChunk: RouteResult[], isEn: boolean, part: number, isTypeScript: boolean = false, @@ -41,84 +53,91 @@ export function getItdocPrompt( codeTypes: { js: "μžλ°”μŠ€ν¬λ¦½νŠΈ", ts: "νƒ€μž…μŠ€ν¬λ¦½νŠΈ" }, outputInstruction: "그리고 λ°˜λ“œμ‹œ ν•œκΈ€λ‘œ 좜λ ₯ν•΄μ•Ό ν•©λ‹ˆλ‹€.", codeLabel: "μ½”λ“œ", + noComments: + "μ„€λͺ…, 주석(//, /* */), λ°±ν‹± μ½”λ“œλΈ”λ‘(```), μ—¬λŠ”/λ‹«λŠ” 문ꡬλ₯Ό μ ˆλŒ€ ν¬ν•¨ν•˜μ§€ λ§ˆμ„Έμš”.", + orderGuide: + "좜λ ₯ μˆœμ„œλŠ” 경둜(prefix)와 λ©”μ„œλ“œ 순으둜 μ •λ ¬ν•˜μ„Έμš”. κΈ°λ³Έ 응닡 ν…ŒμŠ€νŠΈ β†’ 각 λΆ„κΈ° ν…ŒμŠ€νŠΈ 순으둜 λ¬Άμ–΄μ„œ μž‘μ„±ν•˜μ„Έμš”.", + branchGuide: + "각 λΌμš°νŠΈμ— μ •μ˜λœ κΈ°λ³Έ 응닡과 λͺ¨λ“  branches(쑰건)λ₯Ό 각각 λ³„λ„μ˜ itdoc ν…ŒμŠ€νŠΈλ‘œ μƒμ„±ν•˜μ„Έμš”.", + branchGuide2: + "λ§Œμ•½ default.status의 값이 빈 배열이라면 ν•΄λ‹Ή default 쑰건에 ν•΄λ‹Ήν•˜λŠ” ν…ŒμŠ€νŠΈλŠ” μ ˆλŒ€ μž‘μ„±ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", + fieldGuide: + 'λͺ¨λ“  field ν˜ΈμΆœμ€ λ°˜λ“œμ‹œ field("μ„€λͺ…", "μ˜ˆμ‹œκ°’")처럼 2개의 λ§€κ°œλ³€μˆ˜λ₯Ό 포함해야 ν•©λ‹ˆλ‹€(단일 인자 κΈˆμ§€).', + fieldGuide2: + 'λ§Œμ•½ a : b 라고 ν–ˆμ„ λ•Œ bκ°€ 객체인 경우 fieldλŠ” fieldλŠ”("bμ„€λͺ…", b) μ΄λŸ°μ‹μœΌλ‘œ 객체 전체λ₯Ό μ„€λͺ…ν•΄μ•Ό ν•©λ‹ˆλ‹€. 객체 μ•ˆμ— fieldκ°€ λ“€μ–΄κ°€λ©΄ μ•ˆλ©λ‹ˆλ‹€.', + headerGuide: + 'HTTP 헀더 킀에 ν•˜μ΄ν”ˆ(-)이 ν¬ν•¨λ˜μ–΄ 있으면 λ°˜λ“œμ‹œ ν°λ”°μ˜΄ν‘œλ‘œ κ°μ‹Έμ„Έμš”. 예: "Cache-Control", "Last-Modified"', + noPathImport: + "파일 경둜 import/require(예: examples/express-*)λŠ” 좜λ ₯ν•˜μ§€ λ§ˆμ„Έμš”. ν…ŒμŠ€νŠΈ λŸ¬λ„ˆ/초기 μ„€μ • μ½”λ“œλŠ” 이미 μ£Όμ–΄μ‘ŒμŠ΅λ‹ˆλ‹€.", + initGiven: + 'λ‹€μŒ 초기 섀정은 이미 ν¬ν•¨λ˜μ–΄ μžˆμœΌλ―€λ‘œ μƒμ„±ν•˜μ§€ λ§ˆμ„Έμš”: const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc")', + chunksGuideFirst: + "좜λ ₯이 κΈΈμ–΄μ§ˆ 경우 λ‹€μŒ μš”μ²­μ—μ„œ 이어받을 수 μžˆλ„λ‘ μ μ ˆν•œ λ‹¨μœ„λ‘œ λΆ„ν• ν•˜μ—¬ 좜λ ₯ν•˜μ„Έμš”. 응닡 λ§ˆμ§€λ§‰μ— '...' 같은 κΈ°ν˜ΈλŠ” λ„£μ§€ λ§ˆμ„Έμš”.", + chunksGuideNext: + "이전 좜λ ₯의 μ΄μ–΄μ§€λŠ” λΆ€λΆ„λ§Œ 좜λ ₯ν•˜μ„Έμš”. 이전 λ‚΄μš©μ„ λ°˜λ³΅ν•˜μ§€ λ§ˆμ„Έμš”. λΆ„ν•  제λͺ©(1/3 λ“±)은 κΈˆμ§€ν•©λ‹ˆλ‹€.", + langOut: "좜λ ₯은 λ°˜λ“œμ‹œ ν•œκΈ€λ‘œ μž‘μ„±ν•˜μ„Έμš”.", + codeOnly: "였직 ν…ŒμŠ€νŠΈ μ½”λ“œ μ€„λ§Œ 좜λ ₯ν•˜μ„Έμš”.", + etc: "function_call둜 λ˜μ–΄μžˆλŠ” 뢀뢄은 κ·ΈλŒ€λ‘œ ν•¨μˆ˜λ‘œ 좜λ ₯ν•΄μ•Ό ν•©λ‹ˆλ‹€. ex) fetchedAt: field('쑰회 μ‹œκ°(ISO 8601 ν˜•μ‹)', new Date().toISOString()) λ˜ν•œ, parseInt() λ“±μœΌλ‘œ νƒ€μž…μ„ μœ μΆ”ν•  수 μžˆλŠ” 경우 ν•΄λ‹Ή νƒ€μž…μœΌλ‘œ λ°˜ν™˜ν•΄μ•Ό ν•©λ‹ˆλ‹€. ex)parseInt(page) 의 경우 1, 2 λ“±μ˜ int νƒ€μž…μœΌλ‘œ 좜λ ₯λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€. 그리고 describeAPIμ—λŠ” λ°˜λ“œμ‹œ summary, tag, description값이 λ“€μ–΄κ°€μ•Ό ν•©λ‹ˆλ‹€. μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μ—”λ“œν¬μΈνŠΈλ‚˜ νŒŒλΌλ―Έν„°/헀더/λ°”λ”” ν•„λ“œλŠ” λ§Œλ“€μ§€ λ§ˆμ„Έμš”. μž…λ ₯에 μžˆλŠ” μ •λ³΄λ§Œ μ‚¬μš©ν•˜μ„Έμš”. res() 이후에 λ°˜λ“œμ‹œ μœ νš¨ν•œ status()κ°€ λΆ™μ–΄μ•Ό ν•©λ‹ˆλ‹€. responsesμ—μ„œ μ£Όμ–΄μ§€λŠ” json 값듀은 μž„μ˜λ‘œ λ°”κΎΈμ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", }, en: { codeTypes: { js: "JavaScript", ts: "TypeScript" }, outputInstruction: "And the output must be in English.", codeLabel: "code", + noComments: + "Do NOT include explanations, comments (// or /* */), or fenced code blocks (```), or any opening/closing prose.", + orderGuide: + "Order tests by path prefix and HTTP method. For each route, output the default response test first, then branch tests.", + branchGuide: + "For every route, generate separate itdoc tests for the default response and for every branch condition.", + branchGuide2: + "If the default.status value is an empty array, never create a test that corresponds to that default condition.", + fieldGuide: + 'Every field call MUST have exactly two arguments: field("label", "example"). Single-argument calls are forbidden.', + fieldGuide2: + 'If b is an object when a : b, the field should describe the whole object like this ("b description", b). The field should not be inside the object.', + headerGuide: + 'If an HTTP header key contains a hyphen (-), it MUST be quoted, e.g., "Cache-Control", "Last-Modified".', + noPathImport: + "Do not output any file-path import/require lines (e.g., examples/express-*). The runner/bootstrap is already provided.", + initGiven: + 'Do not generate the following since it is already included: const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc")', + chunksGuideFirst: + "If output is long, split into reasonable parts that can be continued later. Do not append trailing ellipses like '...'.", + chunksGuideNext: + "Output only the continuation of the previous part. Do NOT repeat earlier content. Do NOT print part titles like (1/3).", + langOut: "The output must be in English.", + codeOnly: "Output only test code lines.", + etc: "The part with function_call must be output as a function as it is. ex) fetchedAt: field('Inquiry Time (ISO 8601)', new Date().toISOString()) Also, if the type can be inferred by pathInt(), etc., it must be returned to that type. ex) For pathInt(page), it must be output in int type such as 1, 2, etc. And describeAPI must have summary, tag, and description values. Do not create non-existent endpoints or parameter/header/body fields. Use only the information in the input. Be sure to have a valid status () after res().The json values given in responses are not arbitrarily changed.", }, } as const const lang = isEn ? LANGUAGE_TEXTS.en : LANGUAGE_TEXTS.ko const codeType = isTypeScript ? lang.codeTypes.ts : lang.codeTypes.js const codeMessage = `${codeType} ${lang.codeLabel}` - - const partGuide = - part > 1 - ? `이전 좜λ ₯의 μ΄μ–΄μ§€λŠ” ${part}번째 λΆ€λΆ„λ§Œ 좜λ ₯ν•˜μ„Έμš”. 이전 λ‚΄μš©μ„ λ°˜λ³΅ν•˜μ§€ λ§ˆμ„Έμš”.` - : `좜λ ₯이 κΈΈμ–΄μ§ˆ 경우 λ‹€μŒ μš”μ²­μ—μ„œ 이어받을 수 μžˆλ„λ‘ μ μ ˆν•œ λ‹¨μœ„λ‘œ λΆ„ν• ν•˜μ—¬ 좜λ ₯ν•˜μ„Έμš”. 응닡 λ§ˆμ§€λ§‰μ— '...' 같은 κΈ°ν˜ΈλŠ” λ„£μ§€ λ§ˆμ„Έμš”.` + const partGuide = part > 1 ? lang.chunksGuideNext : lang.chunksGuideFirst return ` -λ‹€μŒ ν…ŒμŠ€νŠΈ λ‚΄μš©μ„ 기반으둜 itdoc ν…ŒμŠ€νŠΈ 슀크립트λ₯Ό ${codeMessage}둜 μƒμ„±ν•΄μ£Όμ„Έμš”. -- λͺ¨λ“  λΌμš°ν„°μ— λŒ€ν•œ ν…ŒμŠ€νŠΈλ₯Ό 포함해야 ν•©λ‹ˆλ‹€. -- fieldλŠ” field("a", "b") 처럼 2개의 λ§€κ°œλ³€μˆ˜λ₯Ό λ°˜λ“œμ‹œ 포함해야 ν•©λ‹ˆλ‹€. field둜만 λ‚˜μ˜€λ©΄ μ•ˆλ©λ‹ˆλ‹€. -- μ€‘λ³΅λ˜λŠ” μ„€λͺ…은 μ—†μ–΄μ•Ό ν•©λ‹ˆλ‹€. -- HTTP 헀더와 같이 ν•˜μ΄ν”ˆ(-)이 ν¬ν•¨λœ ν‚€λŠ” λ°˜λ“œμ‹œ ν°λ”°μ˜΄ν‘œλ‘œ 감싸야 ν•©λ‹ˆλ‹€. - μ˜¬λ°”λ₯Έ μ˜ˆμ‹œ: "Cache-Control", "Last-Modified" - 잘λͺ»λœ μ˜ˆμ‹œ: Cache-Control, Last-Modified (no) -- μ½”λ“œ μ„€λͺ… 없이 μ½”λ“œλ§Œ 좜λ ₯ν•΄μ•Ό ν•˜λ©°, \`(1/10)\` 같은 μžλ™ λΆ„ν•  제λͺ©μ€ λ„£μ§€ λ§ˆμ„Έμš”. -- 좜λ ₯은 ${codeMessage}둜만 κ΅¬μ„±λ˜μ–΄μ•Ό ν•˜λ©°, λ°±ν‹± 블둝(\`\`\`)도 μ‚¬μš©ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. +λ‹€μŒ ν…ŒμŠ€νŠΈ μ„€λͺ…을 기반으둜 itdoc ν…ŒμŠ€νŠΈ 슀크립트λ₯Ό ${codeMessage}둜 μƒμ„±ν•˜μ„Έμš”. + +ν•„μˆ˜ κ·œμΉ™: +- ${lang.noComments} +- ${lang.codeOnly} +- ${lang.orderGuide} +- ${lang.branchGuide} +- ${lang.branchGuide2} +- ${lang.fieldGuide} +- ${lang.fieldGuide2} +- ${lang.headerGuide} +- ${lang.noPathImport} +- ${lang.initGiven} +- ${lang.etc} - ${partGuide} -${lang.outputInstruction} +- ${lang.outputInstruction} -[ν…ŒμŠ€νŠΈ μ„€λͺ… λ‚΄μš©] -${content} +[ν…ŒμŠ€νŠΈλ₯Ό μ§„ν–‰ν•΄μ•Όν•˜λŠ” 라우트 뢄석 κ²°κ³Ό] +${JSON.stringify(routesChunk, null, 2)} [ν•¨μˆ˜ μ˜ˆμ‹œ] ${itdocExample} -- κ²½λ‘œμ— ν•΄λ‹Ήν•˜λŠ” μ½”λ“œλŠ” 좜λ ₯ν•˜μ§€ 말아야 ν•©λ‹ˆλ‹€. -ex) import { app } from "examples/express-ts/index.ts" - λ˜λŠ” - const app = require("examples/express/index.js") - -- μ•„λž˜ 초기 μ„€μ • μ½”λ“œλŠ” 이미 ν¬ν•¨λ˜μ–΄ μžˆμœΌλ‹ˆ 해당뢀뢄은 μƒμ„±ν•˜μ§€ 말아야 ν•©λ‹ˆλ‹€. -const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc") -- header() λ©”μ„œλ“œλŠ” λ‹€μŒκ³Ό 같이 객체가 ν¬ν•¨λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€. -header({ - Authorization: field("인증 토큰", "Bearer 123456"), -}) `.trim() } - -/** - * Returns a prompt for creating JSON-based API specifications in Markdown format. - * @param {any} content - JSON object containing API definitions. - * @param {number} part - Current part number when output is divided into multiple parts - * @returns {string} - Prompt message for Markdown generation. - */ -export function getMDPrompt(content: any, part?: number): string { - const partNote = part ? ` (이 λ¬Έμ„œλŠ” ${part}번째 μš”μ²­μž…λ‹ˆλ‹€)` : "" - return `λ‹€μŒ JSON을 λ°”νƒ•μœΌλ‘œ API ν…ŒμŠ€νŠΈ μΌ€μ΄μŠ€λ§Œ Markdown ν˜•μ‹μœΌλ‘œ μž‘μ„±ν•˜μ„Έμš”${partNote}. -μž…λ ₯ JSON: -${JSON.stringify(content, null, 2)} -좜λ ₯ 포맷 (각 ν•­λͺ©λ§Œ): -- μ—”λ“œν¬μΈνŠΈ (예: GET /api/products) -- ν…ŒμŠ€νŠΈ 이름 (κ°„κ²°ν•˜κ²Œ) -- μš”μ²­ 쑰건 (ν•„μš” μ‹œ μš”μ²­ λ°”λ””Β·κ²½λ‘œ λ§€κ°œλ³€μˆ˜ λ“± μžμ„Έν•˜κ²Œ ν‘œν˜„ν•  것) -- μ˜ˆμƒ 응닡 (μƒνƒœ μ½”λ“œ 및 λ°˜ν™˜λ˜λŠ” 객체 λ˜λŠ” λ©”μ‹œμ§€ λ“± μžμ„Έν•˜κ²Œ ν‘œν˜„ν•  것) - -μ˜ˆμ‹œ) -PUT /api/products/:id -- ν…ŒμŠ€νŠΈ 이름: μ œν’ˆ μ—…λ°μ΄νŠΈ 성곡 -- μš”μ²­ 쑰건: μœ νš¨ν•œ μ œν’ˆ ID와 name, price, category 제곡 -- μ˜ˆμƒ 응닡: μƒνƒœ μ½”λ“œ 500, "message"κ°€ "Server is running"인 JSON 응닡, "data"에 "timestamp", "uptime", "memoryUsage" 정보 포함 - -μ§€μΌœμ•Ό ν•  사항: -1. 제λͺ©Β·κ°œμš”Β·μš”μ•½Β·κ²°λ‘  λ“±μ˜ λΆ€κ°€ μ„€λͺ…은 μ ˆλŒ€ ν¬ν•¨ν•˜μ§€ λ§ˆμ„Έμš”. -2. 일반 μ§€μΉ¨ 문ꡬ(β€œμ •μƒ 흐름과 였λ₯˜ 흐름을 λͺ¨λ‘ 포함” λ“±)도 μ“°μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. -3. λ§ˆν¬λ‹€μš΄ κ°•μ‘°(**), μ½”λ“œ 블둝 μ•ˆμ˜ 별도 μŠ€νƒ€μΌλ§μ€ μ‚¬μš© κΈˆμ§€μž…λ‹ˆλ‹€. -4. 좜λ ₯이 κΈΈμ–΄μ§€λ©΄ (1 / 3), (2 / 3)처럼 μˆœμ„œ ν‘œκΈ°ν•˜μ—¬ λΆ„ν• ν•˜μ„Έμš”. -5. 였직 ν…ŒμŠ€νŠΈ μΌ€μ΄μŠ€ λͺ©λ‘λ§Œ, ν•­λͺ©λ³„λ‘œ ꡬ뢄해 λ‚˜μ—΄ν•©λ‹ˆλ‹€. -6. DELETE, PUT, GET, POST, PATCH μ•žμ— - λ₯Ό 뢙이지 μ•ŠμŠ΅λ‹ˆλ‹€. -` -} diff --git a/script/makedocs/index.ts b/script/makedocs/index.ts index 365f0cb..b1934c9 100644 --- a/script/makedocs/index.ts +++ b/script/makedocs/index.ts @@ -40,11 +40,9 @@ export async function generateDocs(oasOutputPath: string, outputDir: string): Pr logger.info(`HTML path: ${htmlPath}`) const config = await loadConfig({}) - logger.info("Step 1: Redocly configuration loaded") const bundleResult = await bundle({ ref: oasOutputPath, config }) const api = bundleResult.bundle.parsed - logger.info("Step 2: OpenAPI bundling completed") const widdershinsOpts = { headings: 2, summary: true } console.log = () => {} const markdown = await widdershins.convert(api, widdershinsOpts)