From fbb7a91cd5fd4fb0d27cbf91e785cbff5576e714 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Mon, 17 Aug 2026 18:01:12 +0900 Subject: [PATCH 1/2] feat(request): JSON envelope output by default --- README.md | 28 +- src/commands/request/index.test.ts | 424 +++++++++++++++++------------ src/commands/request/index.ts | 177 ++++++------ 3 files changed, 341 insertions(+), 288 deletions(-) diff --git a/README.md b/README.md index 083cb3b..47ac9b5 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,11 @@ hono request [file] [options] - `-d, --data ` - Request body data - `-H, --header
` - Custom headers (can be used multiple times) - `-w, --watch` - Watch for changes and resend request -- `-J, --json` - Output response as JSON -- `-o, --output ` - Write to file instead of stdout -- `-O, --remote-name` - Write output to file named as remote file -- `-i, --include` - Include protocol and headers in the output -- `-I, --head` - Show only protocol and headers in the output +- `-o, --output ` - Write response body to file instead of stdout +- `-O, --remote-name` - Write response body to file named as remote file +- `--plain` - human-readable output instead of JSON +- `-i, --include` - Include status and headers in the output (with `--plain`) +- `-I, --head` - Show only status and headers in the output (with `--plain`) - `-e, --external ` - Mark package as external (can be used multiple times) **Examples:** @@ -81,21 +81,25 @@ hono request -P /api/protected \ hono request -e pg -e dotenv src/your-app.ts ``` -**Response Format:** +**Output:** -The command returns a JSON object with the following structure: +The result is JSON with the shared envelope. A JSON response body is embedded as an object, not an escaped string: ```json { - "status": 200, - "body": "{\"message\":\"Hello World\"}", - "headers": { - "content-type": "application/json", - "x-custom-header": "value" + "ok": true, + "data": { + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": { "message": "Hello World" } } } ``` +A binary response body becomes `"body": null` with `"binary": true` — save it with `-o`. Use `--plain` to print the raw body like curl. + ### `build` Build your Hono app into a single bundled file. diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index c7a9e0f..a29b4e3 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -27,6 +27,7 @@ describe('requestCommand', () => { let program: Command let consoleLogSpy: ReturnType let consoleWarnSpy: ReturnType + let consoleErrorSpy: ReturnType let mockModules: any let mockBuildAndImportApp: any @@ -58,6 +59,7 @@ describe('requestCommand', () => { requestCommand(program) consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) // Get mocked modules mockModules = { @@ -74,25 +76,40 @@ describe('requestCommand', () => { afterEach(() => { consoleLogSpy.mockRestore() consoleWarnSpy.mockRestore() + consoleErrorSpy.mockRestore() vi.restoreAllMocks() }) - it('should json request body output when default', async () => { + it('should output the JSON envelope by default', async () => { const mockApp = new Hono() const jsonBody = { message: 'Success' } mockApp.get('/data', (c) => c.json(jsonBody)) setupBasicMocks('test-app.js', mockApp) await program.parseAsync(['node', 'test', 'request', '-P', '/data', 'test-app.js']) - expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(jsonBody, null, 2)) + expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + ok: true, + data: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: jsonBody, + }, + }) }) - it('should text request body output when default', async () => { + it('should output a text body as a string in the envelope', async () => { const mockApp = new Hono() const text = 'Hello, World!' mockApp.get('/data', (c) => c.text(text)) setupBasicMocks('test-app.js', mockApp) await program.parseAsync(['node', 'test', 'request', '-P', '/data', 'test-app.js']) - expect(consoleLogSpy).toHaveBeenCalledWith(text) + expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + ok: true, + data: { + status: 200, + headers: { 'content-type': 'text/plain;charset=UTF-8' }, + body: text, + }, + }) }) it('should handle GET request to specific file', async () => { @@ -102,7 +119,7 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/', 'test-app.js', '-J']) + await program.parseAsync(['node', 'test', 'request', '-P', '/', 'test-app.js']) // Verify resolve was called with correct arguments expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'test-app.js') @@ -113,17 +130,14 @@ describe('requestCommand', () => { sourcemap: true, }) - expect(consoleLogSpy).toHaveBeenCalledWith( - JSON.stringify( - { - status: 200, - body: { message: 'Hello' }, - headers: { 'content-type': 'application/json' }, - }, - null, - 2 - ) - ) + expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + ok: true, + data: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { message: 'Hello' }, + }, + }) }) it('should handle GET request to specific file with watch option', async () => { @@ -133,7 +147,7 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync(['node', 'test', 'request', '-w', '-P', '/', 'test-app.js', '--json']) + await program.parseAsync(['node', 'test', 'request', '-w', '-P', '/', 'test-app.js']) // Verify resolve was called with correct arguments expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'test-app.js') @@ -144,17 +158,14 @@ describe('requestCommand', () => { sourcemap: true, }) - expect(consoleLogSpy).toHaveBeenCalledWith( - JSON.stringify( - { - status: 200, - body: { message: 'Hello' }, - headers: { 'content-type': 'application/json' }, - }, - null, - 2 - ) - ) + expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + ok: true, + data: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { message: 'Hello' }, + }, + }) }) it('should handle JSON response with charset in Content-Type', async () => { @@ -167,50 +178,36 @@ describe('requestCommand', () => { ) setupBasicMocks('test-app.js', mockApp) - await program.parseAsync([ - 'node', - 'test', - 'request', - '-P', - '/json-charset', - '-J', - 'test-app.js', - ]) + await program.parseAsync(['node', 'test', 'request', '-P', '/json-charset', 'test-app.js']) - expect(consoleLogSpy).toHaveBeenCalledWith( - JSON.stringify( - { - status: 200, - body: jsonBody, - headers: { 'content-type': 'application/json; charset=utf-8' }, - }, - null, - 2 - ) - ) + expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + ok: true, + data: { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + body: jsonBody, + }, + }) }) - // This test validates that `formatResponseBody` returns an object (not a string) when the response is JSON and the -J flag is used. - // It ensures that the final output JSON contains the response body as a nested object, rather than a double-stringified JSON string. - it('should return object body in JSON output when response is JSON and -J is used', async () => { + // The output must contain the response body as a nested object, + // not a double-stringified JSON string. + it('should return object body in JSON output when response is JSON', async () => { const mockApp = new Hono() const jsonBody = { foo: 'bar', nested: { a: 1 } } mockApp.get('/json-obj', (c) => c.json(jsonBody)) setupBasicMocks('test-app.js', mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/json-obj', '-J', 'test-app.js']) - - expect(consoleLogSpy).toHaveBeenCalledWith( - JSON.stringify( - { - status: 200, - body: jsonBody, // Should be the object itself, not stringified JSON string - headers: { 'content-type': 'application/json' }, - }, - null, - 2 - ) - ) + await program.parseAsync(['node', 'test', 'request', '-P', '/json-obj', 'test-app.js']) + + expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + ok: true, + data: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: jsonBody, + }, + }) }) it('should handle POST request with data', async () => { @@ -234,23 +231,19 @@ describe('requestCommand', () => { '-d', 'test data', 'test-app.js', - '-J', ]) // Verify resolve was called with correct arguments expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'test-app.js') - const expectedOutput = JSON.stringify( - { + expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + ok: true, + data: { status: 201, - body: { received: 'test data' }, headers: { 'content-type': 'application/json', 'x-custom-header': 'test-value' }, + body: { received: 'test data' }, }, - null, - 2 - ) - - expect(consoleLogSpy).toHaveBeenCalledWith(expectedOutput) + }) }) it('should handle default app path when no file provided', async () => { @@ -270,24 +263,21 @@ describe('requestCommand', () => { }) mockBuildAndImportApp.mockReturnValue(createBuildIterator(mockApp)) - await program.parseAsync(['node', 'test', 'request', '-J']) + await program.parseAsync(['node', 'test', 'request']) // Verify resolve was called with correct arguments for default candidates expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'src/index.ts') expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'src/index.tsx') expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'src/index.js') - const expectedOutput = JSON.stringify( - { + expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + ok: true, + data: { status: 200, - body: { message: 'Default app' }, headers: { 'content-type': 'application/json' }, + body: { message: 'Default app' }, }, - null, - 2 - ) - - expect(consoleLogSpy).toHaveBeenCalledWith(expectedOutput) + }) }) it('should handle single header option correctly', async () => { @@ -312,22 +302,16 @@ describe('requestCommand', () => { '-H', 'Authorization: Bearer token123', 'test-app.js', - '-J', ]) - expect(consoleLogSpy).toHaveBeenCalledWith( - JSON.stringify( - { - status: 200, - body: { - auth: 'Bearer token123', - }, - headers: { 'content-type': 'application/json' }, - }, - null, - 2 - ) - ) + expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + ok: true, + data: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { auth: 'Bearer token123' }, + }, + }) }) it('should handle multiple header options correctly', async () => { @@ -355,20 +339,16 @@ describe('requestCommand', () => { '-H', 'X-Custom-Header: custom-value', 'test-app.js', - '-J', ]) - expect(consoleLogSpy).toHaveBeenCalledWith( - JSON.stringify( - { - status: 200, - body: { auth: 'Bearer token456', userAgent: 'TestClient/1.0', custom: 'custom-value' }, - headers: { 'content-type': 'application/json' }, - }, - null, - 2 - ) - ) + expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + ok: true, + data: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { auth: 'Bearer token456', userAgent: 'TestClient/1.0', custom: 'custom-value' }, + }, + }) }) it('should handle no header options correctly', async () => { @@ -381,21 +361,14 @@ describe('requestCommand', () => { const expectedPath = 'test-app.js' setupBasicMocks(expectedPath, mockApp) - await program.parseAsync([ - 'node', - 'test', - 'request', - '-P', - '/api/noheader', - 'test-app.js', - '-J', - ]) + await program.parseAsync(['node', 'test', 'request', '-P', '/api/noheader', 'test-app.js']) // Should not include any custom headers, only default ones const output = consoleLogSpy.mock.calls[0][0] as string const result = JSON.parse(output) - expect(result.status).toBe(200) - expect(result.headers['content-type']).toBe('application/json') + expect(result.ok).toBe(true) + expect(result.data.status).toBe(200) + expect(result.data.headers['content-type']).toBe('application/json') }) it('should handle malformed header gracefully', async () => { @@ -418,21 +391,17 @@ describe('requestCommand', () => { '-H', 'ValidHeader: value', 'test-app.js', - '-J', ]) // Should still work, malformed header is ignored - expect(consoleLogSpy).toHaveBeenCalledWith( - JSON.stringify( - { - status: 200, - body: { success: true }, - headers: { 'content-type': 'application/json' }, - }, - null, - 2 - ) - ) + expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + ok: true, + data: { + status: 200, + headers: { 'content-type': 'application/json' }, + body: { success: true }, + }, + }) }) it('should handle HTML response', async () => { @@ -440,7 +409,7 @@ describe('requestCommand', () => { const htmlContent = '

Hello World

' mockApp.get('/html', (c) => c.html(htmlContent)) setupBasicMocks('test-app.js', mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/html', 'test-app.js']) + await program.parseAsync(['node', 'test', 'request', '-P', '/html', '--plain', 'test-app.js']) expect(consoleLogSpy).toHaveBeenCalledWith(htmlContent) }) @@ -449,7 +418,7 @@ describe('requestCommand', () => { const xmlContent = 'Hello' mockApp.get('/xml', (c) => c.body(xmlContent, 200, { 'Content-Type': 'application/xml' })) setupBasicMocks('test-app.js', mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/xml', 'test-app.js']) + await program.parseAsync(['node', 'test', 'request', '-P', '/xml', '--plain', 'test-app.js']) expect(consoleLogSpy).toHaveBeenCalledWith(xmlContent) }) @@ -458,11 +427,37 @@ describe('requestCommand', () => { const pngData = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0]) mockApp.get('/image.png', (c) => c.body(pngData.buffer, 200, { 'Content-Type': 'image/png' })) setupBasicMocks('test-app.js', mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/image.png', 'test-app.js']) + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/image.png', + '--plain', + 'test-app.js', + ]) expect(consoleWarnSpy).toHaveBeenCalledWith('Binary output can mess up your terminal.') expect(consoleLogSpy).not.toHaveBeenCalled() }) + it('should output null body with binary flag for binary response by default', async () => { + const mockApp = new Hono() + const pngData = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0]) + mockApp.get('/image.png', (c) => c.body(pngData.buffer, 200, { 'Content-Type': 'image/png' })) + setupBasicMocks('test-app.js', mockApp) + await program.parseAsync(['node', 'test', 'request', '-P', '/image.png', 'test-app.js']) + expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + ok: true, + data: { + status: 200, + headers: { 'content-type': 'image/png' }, + body: null, + binary: true, + }, + }) + expect(consoleWarnSpy).not.toHaveBeenCalled() + }) + it('should warn on binary PDF response', async () => { const mockApp = new Hono() const pdfData = new Uint8Array([37, 80, 68, 70, 45, 49, 46, 55, 0, 0, 0, 0]) @@ -470,7 +465,15 @@ describe('requestCommand', () => { c.body(pdfData.buffer, 200, { 'Content-Type': 'application/pdf' }) ) setupBasicMocks('test-app.js', mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/document.pdf', 'test-app.js']) + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/document.pdf', + '--plain', + 'test-app.js', + ]) expect(consoleWarnSpy).toHaveBeenCalledWith('Binary output can mess up your terminal.') expect(consoleLogSpy).not.toHaveBeenCalled() }) @@ -503,7 +506,16 @@ describe('requestCommand', () => { return `${cwd}/${path}` }) - await program.parseAsync(['node', 'test', 'request', '-P', '/resource', '-w', 'test-app.js']) + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/resource', + '-w', + '--plain', + 'test-app.js', + ]) expect(consoleWarnSpy).toHaveBeenCalledWith('Binary output can mess up your terminal.') expect(consoleLogSpy).toHaveBeenCalledWith(text) @@ -530,11 +542,11 @@ describe('requestCommand', () => { 'test-app.js', ]) - expect(mockSaveFile).toHaveBeenCalledWith( - new TextEncoder().encode(JSON.stringify(jsonBody)).buffer, - outputPath - ) - expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) + const saved = mockSaveFile.mock.calls[0] + expect(new TextDecoder().decode(saved[0] as ArrayBuffer)).toBe(JSON.stringify(jsonBody)) + expect(saved[1]).toBe(outputPath) + expect(consoleErrorSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) + expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string).data.savedTo).toBe(outputPath) }) it('should save binary response to specified file with -o option', async () => { @@ -560,8 +572,10 @@ describe('requestCommand', () => { 'test-app.js', ]) - expect(mockSaveFile).toHaveBeenCalledWith(binaryData, outputPath) - expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) + const saved = mockSaveFile.mock.calls[0] + expect(new Uint8Array(saved[0] as ArrayBuffer)).toEqual(new Uint8Array(binaryData)) + expect(saved[1]).toBe(outputPath) + expect(consoleErrorSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) }) it('should save response to remote-named file with -O option', async () => { @@ -580,11 +594,10 @@ describe('requestCommand', () => { await program.parseAsync(['node', 'test', 'request', '-P', '/index.html', '-O', 'test-app.js']) expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/index.html', 'text/html; charset=UTF-8') - expect(mockSaveFile).toHaveBeenCalledWith( - new TextEncoder().encode(htmlContent).buffer, - 'index.html' - ) - expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to index.html`) + const saved = mockSaveFile.mock.calls[0] + expect(new TextDecoder().decode(saved[0] as ArrayBuffer)).toBe(htmlContent) + expect(saved[1]).toBe('index.html') + expect(consoleErrorSpy).toHaveBeenCalledWith(`Saved response to index.html`) }) it('should save binary response to remote-named file with -O option', async () => { @@ -603,8 +616,10 @@ describe('requestCommand', () => { await program.parseAsync(['node', 'test', 'request', '-P', '/image.png', '-O', 'test-app.js']) expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/image.png', 'image/png') - expect(mockSaveFile).toHaveBeenCalledWith(pngData, 'image.png') - expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to image.png`) + const saved = mockSaveFile.mock.calls[0] + expect(new Uint8Array(saved[0] as ArrayBuffer)).toEqual(new Uint8Array(pngData)) + expect(saved[1]).toBe('image.png') + expect(consoleErrorSpy).toHaveBeenCalledWith(`Saved response to image.png`) }) it('should save response to "index" when remote-name option is used with root path', async () => { @@ -623,8 +638,10 @@ describe('requestCommand', () => { await program.parseAsync(['node', 'test', 'request', '-P', '/', '-O', 'test-app.js']) expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/', 'text/html; charset=UTF-8') - expect(mockSaveFile).toHaveBeenCalledWith(new TextEncoder().encode(htmlContent).buffer, 'index') - expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to index`) + const saved = mockSaveFile.mock.calls[0] + expect(new TextDecoder().decode(saved[0] as ArrayBuffer)).toBe(htmlContent) + expect(saved[1]).toBe('index') + expect(consoleErrorSpy).toHaveBeenCalledWith(`Saved response to index`) }) it('should prioritize -o over -O when both are present', async () => { @@ -651,28 +668,16 @@ describe('requestCommand', () => { outputPath, '-O', 'test-app.js', - '-J', ]) expect(mockGetFilenameFromPath).not.toHaveBeenCalled() - expect(mockSaveFile).toHaveBeenCalledWith( - new TextEncoder().encode( - JSON.stringify( - { - status: 200, - body: textContent, - headers: { 'content-type': 'text/plain;charset=UTF-8' }, - }, - null, - 2 - ) - ).buffer, - outputPath - ) - expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) + const saved = mockSaveFile.mock.calls[0] + expect(new TextDecoder().decode(saved[0] as ArrayBuffer)).toBe(textContent) + expect(saved[1]).toBe(outputPath) + expect(consoleErrorSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) }) - it('should protocol headers and save when default', async () => { + it('should save the raw response body with -o by default', async () => { const mockApp = new Hono() const jsonBody = { data: 'filtered' } mockApp.get('/filtered-data', (c) => c.json(jsonBody)) @@ -693,11 +698,10 @@ describe('requestCommand', () => { 'test-app.js', ]) - expect(mockSaveFile).toHaveBeenCalledWith( - new TextEncoder().encode(JSON.stringify(jsonBody, null, 2)).buffer, - outputPath - ) - expect(consoleLogSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) + const saved = mockSaveFile.mock.calls[0] + expect(new TextDecoder().decode(saved[0] as ArrayBuffer)).toBe(JSON.stringify(jsonBody)) + expect(saved[1]).toBe(outputPath) + expect(consoleErrorSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) }) it('should include protocol and headers with --include option', async () => { @@ -706,7 +710,16 @@ describe('requestCommand', () => { mockApp.get('/text', (c) => c.text(textBody, 200, { 'X-Custom-Header': 'IncludeValue' })) setupBasicMocks('test-app.js', mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/text', '-i', 'test-app.js']) + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/text', + '--plain', + '-i', + 'test-app.js', + ]) const expectedOutput = [ '200', @@ -725,7 +738,16 @@ describe('requestCommand', () => { mockApp.get('/text', (c) => c.text(textBody, 200, { 'X-Custom-Header': 'HeadValue' })) setupBasicMocks('test-app.js', mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/text', '-I', 'test-app.js']) + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/text', + '--plain', + '-I', + 'test-app.js', + ]) const expectedOutput = [ '200', @@ -743,7 +765,17 @@ describe('requestCommand', () => { mockApp.get('/text', (c) => c.text(textBody, 200, { 'X-Custom-Header': 'PrioritizeValue' })) setupBasicMocks('test-app.js', mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/text', '-i', '-I', 'test-app.js']) + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/text', + '--plain', + '-i', + '-I', + 'test-app.js', + ]) const expectedOutput = [ '200', @@ -755,7 +787,7 @@ describe('requestCommand', () => { expect(consoleLogSpy).toHaveBeenCalledWith(expectedOutput) }) - it('should display JSON body correctly with --json and --include options', async () => { + it('should display JSON body correctly with --plain and --include options', async () => { const mockApp = new Hono() const jsonBody = { message: 'Hello JSON' } mockApp.get('/json-data', (c) => c.json(jsonBody)) @@ -767,7 +799,7 @@ describe('requestCommand', () => { 'request', '-P', '/json-data', - '-J', + '--plain', '-i', 'test-app.js', ]) @@ -884,7 +916,15 @@ describe('requestCommand', () => { mockApp.get('/test', (c) => c.body(jsonString, 200, { 'Content-Type': contentType })) setupBasicMocks('test-app.js', mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/test', 'test-app.js']) + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/test', + '--plain', + 'test-app.js', + ]) expect(consoleLogSpy).toHaveBeenCalledWith(formattedJsonString) }) @@ -896,10 +936,32 @@ describe('requestCommand', () => { mockApp.get('/test', (c) => c.body(jsonString, 200, { 'Content-Type': contentType })) setupBasicMocks('test-app.js', mockApp) - await program.parseAsync(['node', 'test', 'request', '-P', '/test', 'test-app.js']) + await program.parseAsync([ + 'node', + 'test', + 'request', + '-P', + '/test', + '--plain', + 'test-app.js', + ]) expect(consoleLogSpy).toHaveBeenCalledWith(jsonString) }) }) }) + + it('should print a JSON error when the entry file is not found', async () => { + mockModules.existsSync.mockReturnValue(false) + mockModules.resolve.mockImplementation((cwd: string, path: string) => `${cwd}/${path}`) + + await program.parseAsync(['node', 'test', 'request', 'missing.ts']) + + const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) + expect(parsed.ok).toBe(false) + expect(parsed.error.code).toBe('ENTRY_NOT_FOUND') + expect(parsed.error.hint).toBeDefined() + expect(process.exitCode).toBe(1) + process.exitCode = undefined + }) }) diff --git a/src/commands/request/index.ts b/src/commands/request/index.ts index 958ab8d..ed1e767 100644 --- a/src/commands/request/index.ts +++ b/src/commands/request/index.ts @@ -4,6 +4,7 @@ import { existsSync, realpathSync } from 'node:fs' import { resolve } from 'node:path' import { buildAndImportApp } from '../../utils/build.js' import { getFilenameFromPath, saveFile } from '../../utils/file.js' +import { CliError, handleErrors, printResult } from '../../utils/output.js' const DEFAULT_ENTRY_CANDIDATES = ['src/index.ts', 'src/index.tsx', 'src/index.js', 'src/index.jsx'] @@ -13,7 +14,7 @@ interface RequestOptions { header?: string[] path?: string watch: boolean - json: boolean + plain: boolean output?: string remoteName: boolean include: boolean @@ -30,7 +31,6 @@ export function requestCommand(program: Command) { .option('-X, --method ', 'HTTP method', 'GET') .option('-d, --data ', 'Request body data') .option('-w, --watch', 'Watch for changes and resend request', false) - .option('-J, --json', 'Output response as JSON', false) .option( '-H, --header
', 'Custom headers', @@ -39,10 +39,11 @@ export function requestCommand(program: Command) { }, [] as string[] ) - .option('-o, --output ', 'Write to file instead of stdout') - .option('-O, --remote-name', 'Write output to file named as remote file', false) - .option('-i, --include', 'Include protocol and headers in the output', false) - .option('-I, --head', 'Show only protocol and headers in the output', false) + .option('-o, --output ', 'Write response body to file instead of stdout') + .option('-O, --remote-name', 'Write response body to file named as remote file', false) + .option('--plain', 'human-readable output instead of JSON', false) + .option('-i, --include', 'Include protocol and headers in the output (with --plain)', false) + .option('-I, --head', 'Show only protocol and headers in the output (with --plain)', false) .option( '-e, --external ', 'Mark package as external (can be used multiple times)', @@ -51,102 +52,88 @@ export function requestCommand(program: Command) { }, [] as string[] ) - .action(async (file: string | undefined, options: RequestOptions) => { - const doSaveFile = options.output || options.remoteName - const path = options.path || '/' - const watch = options.watch - const external = options.external || [] - const buildIterator = getBuildIterator(file, watch, external) - for await (const app of buildIterator) { - const result = await executeRequest(app, path, options) - const contentType = result.headers['content-type'] - const outputBody = formatResponseBody( - result.body, - contentType, - options.json && !options.include - ) - const buffer = await result.response.clone().arrayBuffer() - const isBinaryData = isBinaryResponse(buffer) - if (isBinaryData && !doSaveFile) { - console.warn('Binary output can mess up your terminal.') - continue + .action( + handleErrors(async (file: string | undefined, options: RequestOptions) => { + const doSaveFile = options.output || options.remoteName + const path = options.path || '/' + const watch = options.watch + const external = options.external || [] + const buildIterator = getBuildIterator(file, watch, external) + for await (const app of buildIterator) { + const result = await executeRequest(app, path, options) + const contentType = result.headers['content-type'] + const buffer = await result.response.clone().arrayBuffer() + const isBinaryData = isBinaryResponse(buffer) + + let savedTo: string | undefined + if (doSaveFile) { + savedTo = await handleSaveOutput(buffer, path, options, contentType) + } + + if (options.plain) { + printPlain(result, contentType, isBinaryData, savedTo, options) + continue + } + + printResult({ + status: result.status, + headers: result.headers, + body: isBinaryData ? null : parseBody(result.body, contentType), + ...(isBinaryData ? { binary: true } : {}), + ...(savedTo ? { savedTo } : {}), + }) } - - const outputData = getOutputData( - buffer, - outputBody, - isBinaryData, - options, - result.status, - result.headers - ) - if (!isBinaryData) { - console.log(outputData) - } - - if (doSaveFile) { - await handleSaveOutput(outputData, path, options, contentType) - } - } - }) + }) + ) } -function getOutputData( - buffer: ArrayBuffer, - outputBody: string | object, +const printPlain = ( + result: { status: number; body: string; headers: Record }, + contentType: string | undefined, isBinaryData: boolean, - options: RequestOptions, - status: number, - headers: Record -): string | ArrayBuffer | object { + savedTo: string | undefined, + options: RequestOptions +): void => { if (isBinaryData) { - return buffer + if (!savedTo) { + console.warn('Binary output can mess up your terminal.') + } + return } const headerLines: string[] = [] - headerLines.push(`${status}`) - for (const key in headers) { - headerLines.push(`\x1b[1m${key}\x1b[0m: ${headers[key]}`) + headerLines.push(`${result.status}`) + for (const key in result.headers) { + headerLines.push(`\x1b[1m${key}\x1b[0m: ${result.headers[key]}`) } const headerOutput = headerLines.join('\n') - if (options.head) { - return headerOutput + '\n' - } - if (options.include) { - return headerOutput + '\n\n' + outputBody - } - if (options.json) { - return JSON.stringify({ status: status, body: outputBody, headers: headers }, null, 2) + const body = parseBody(result.body, contentType) + const outputBody = typeof body === 'string' ? body : JSON.stringify(body, null, 2) + + if (options.head) { + console.log(headerOutput + '\n') + } else if (options.include) { + console.log(headerOutput + '\n\n' + outputBody) + } else { + console.log(outputBody) } - return outputBody } -async function handleSaveOutput( - saveData: string | ArrayBuffer | object, +const handleSaveOutput = async ( + buffer: ArrayBuffer, requestPath: string, options: RequestOptions, contentType?: string -): Promise { - let filepath: string - if (options.output) { - filepath = options.output - } else { - filepath = getFilenameFromPath(requestPath, contentType) - } +): Promise => { + const filepath = options.output ?? getFilenameFromPath(requestPath, contentType) try { - await saveFile( - typeof saveData === 'string' - ? new TextEncoder().encode(saveData).buffer - : saveData instanceof ArrayBuffer - ? saveData - : new TextEncoder().encode(JSON.stringify(saveData)).buffer, - filepath - ) - console.log(`Saved response to ${filepath}`) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - console.error(`Error saving file: ${error.message}`) + await saveFile(buffer, filepath) + console.error(`Saved response to ${filepath}`) + return filepath + } catch (error) { + console.error(`Error saving file: ${error instanceof Error ? error.message : String(error)}`) + return undefined } } @@ -172,7 +159,11 @@ export function getBuildIterator( } if (!existsSync(resolvedAppPath)) { - throw new Error(`Entry file ${entry} does not exist`) + throw new CliError( + 'ENTRY_NOT_FOUND', + `Entry file ${entry} does not exist`, + 'Pass an existing app file: hono request src/index.ts' + ) } const appFilePath = realpathSync(resolvedAppPath) @@ -231,18 +222,14 @@ export async function executeRequest( } } -const formatResponseBody = ( - responseBody: string, - contentType: string | undefined, - jsonOption: boolean -): string | object => { +/** + * Parse a JSON body into an object so it is not double-escaped in the + * JSON output. Returns the body as-is for other content types. + */ +const parseBody = (responseBody: string, contentType: string | undefined): string | object => { if (contentType && /^application\/(json|[^;\s]+\+json)($|;)/i.test(contentType)) { try { - const parsedJSON = JSON.parse(responseBody) - if (jsonOption) { - return parsedJSON - } - return JSON.stringify(parsedJSON, null, 2) + return JSON.parse(responseBody) } catch { console.error('Response indicated JSON content type but failed to parse JSON.') return responseBody From 3edb40f62ae5ec71d1654de47eb09edea0bbcd1b Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Mon, 17 Aug 2026 18:06:03 +0900 Subject: [PATCH 2/2] test(request): remove any and type assertions --- src/commands/request/index.test.ts | 112 +++++++++++++---------------- 1 file changed, 50 insertions(+), 62 deletions(-) diff --git a/src/commands/request/index.test.ts b/src/commands/request/index.test.ts index a29b4e3..7777052 100644 --- a/src/commands/request/index.test.ts +++ b/src/commands/request/index.test.ts @@ -25,24 +25,24 @@ vi.mock('../../utils/file.js', () => ({ describe('requestCommand', () => { let program: Command - let consoleLogSpy: ReturnType - let consoleWarnSpy: ReturnType - let consoleErrorSpy: ReturnType - let mockModules: any - let mockBuildAndImportApp: any - - const createBuildIterator = (app: Hono) => { - const iterator = { - next: vi - .fn() - .mockResolvedValueOnce({ value: app, done: false }) - .mockResolvedValueOnce({ value: undefined, done: true }), - return: vi.fn().mockResolvedValue({ value: undefined, done: true }), - [Symbol.asyncIterator]() { - return this - }, - } - return iterator + const spyOnConsole = (method: 'log' | 'warn' | 'error') => + vi.spyOn(console, method).mockImplementation(() => {}) + let consoleLogSpy: ReturnType + let consoleWarnSpy: ReturnType + let consoleErrorSpy: ReturnType + const getMockModules = async () => ({ + existsSync: vi.mocked((await import('node:fs')).existsSync), + realpathSync: vi.mocked((await import('node:fs')).realpathSync), + resolve: vi.mocked((await import('node:path')).resolve), + }) + const getMockBuildAndImportApp = async () => + vi.mocked((await import('../../utils/build.js')).buildAndImportApp) + + let mockModules: Awaited> + let mockBuildAndImportApp: Awaited> + + async function* createBuildIterator(app: Hono): AsyncGenerator { + yield app } const setupBasicMocks = (appPath: string, mockApp: Hono) => { @@ -57,18 +57,13 @@ describe('requestCommand', () => { beforeEach(async () => { program = new Command() requestCommand(program) - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + consoleLogSpy = spyOnConsole('log') + consoleWarnSpy = spyOnConsole('warn') + consoleErrorSpy = spyOnConsole('error') // Get mocked modules - mockModules = { - existsSync: vi.mocked((await import('node:fs')).existsSync), - realpathSync: vi.mocked((await import('node:fs')).realpathSync), - resolve: vi.mocked((await import('node:path')).resolve), - } - - mockBuildAndImportApp = vi.mocked((await import('../../utils/build.js')).buildAndImportApp) + mockModules = await getMockModules() + mockBuildAndImportApp = await getMockBuildAndImportApp() vi.clearAllMocks() }) @@ -86,7 +81,7 @@ describe('requestCommand', () => { mockApp.get('/data', (c) => c.json(jsonBody)) setupBasicMocks('test-app.js', mockApp) await program.parseAsync(['node', 'test', 'request', '-P', '/data', 'test-app.js']) - expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({ ok: true, data: { status: 200, @@ -102,7 +97,7 @@ describe('requestCommand', () => { mockApp.get('/data', (c) => c.text(text)) setupBasicMocks('test-app.js', mockApp) await program.parseAsync(['node', 'test', 'request', '-P', '/data', 'test-app.js']) - expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({ ok: true, data: { status: 200, @@ -130,7 +125,7 @@ describe('requestCommand', () => { sourcemap: true, }) - expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({ ok: true, data: { status: 200, @@ -158,7 +153,7 @@ describe('requestCommand', () => { sourcemap: true, }) - expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({ ok: true, data: { status: 200, @@ -180,7 +175,7 @@ describe('requestCommand', () => { await program.parseAsync(['node', 'test', 'request', '-P', '/json-charset', 'test-app.js']) - expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({ ok: true, data: { status: 200, @@ -200,7 +195,7 @@ describe('requestCommand', () => { await program.parseAsync(['node', 'test', 'request', '-P', '/json-obj', 'test-app.js']) - expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({ ok: true, data: { status: 200, @@ -236,7 +231,7 @@ describe('requestCommand', () => { // Verify resolve was called with correct arguments expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'test-app.js') - expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({ ok: true, data: { status: 201, @@ -253,7 +248,7 @@ describe('requestCommand', () => { const expectedPath = 'src/index.js' // Override existsSync to only return true for the resolved path of src/index.js - mockModules.existsSync.mockImplementation((path: string) => { + mockModules.existsSync.mockImplementation((path) => { const resolvedPath = `${process.cwd()}/${expectedPath}` return path === resolvedPath }) @@ -270,7 +265,7 @@ describe('requestCommand', () => { expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'src/index.tsx') expect(mockModules.resolve).toHaveBeenCalledWith(process.cwd(), 'src/index.js') - expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({ ok: true, data: { status: 200, @@ -304,7 +299,7 @@ describe('requestCommand', () => { 'test-app.js', ]) - expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({ ok: true, data: { status: 200, @@ -341,7 +336,7 @@ describe('requestCommand', () => { 'test-app.js', ]) - expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({ ok: true, data: { status: 200, @@ -364,7 +359,7 @@ describe('requestCommand', () => { await program.parseAsync(['node', 'test', 'request', '-P', '/api/noheader', 'test-app.js']) // Should not include any custom headers, only default ones - const output = consoleLogSpy.mock.calls[0][0] as string + const output = consoleLogSpy.mock.calls[0][0] const result = JSON.parse(output) expect(result.ok).toBe(true) expect(result.data.status).toBe(200) @@ -394,7 +389,7 @@ describe('requestCommand', () => { ]) // Should still work, malformed header is ignored - expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({ ok: true, data: { status: 200, @@ -446,7 +441,7 @@ describe('requestCommand', () => { mockApp.get('/image.png', (c) => c.body(pngData.buffer, 200, { 'Content-Type': 'image/png' })) setupBasicMocks('test-app.js', mockApp) await program.parseAsync(['node', 'test', 'request', '-P', '/image.png', 'test-app.js']) - expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string)).toEqual({ + expect(JSON.parse(consoleLogSpy.mock.calls[0][0])).toEqual({ ok: true, data: { status: 200, @@ -487,18 +482,11 @@ describe('requestCommand', () => { const text = 'Hello, World!' mockApp2.get('/resource', (c) => c.text(text)) - const iterator = { - next: vi - .fn() - .mockResolvedValueOnce({ value: mockApp1, done: false }) - .mockResolvedValueOnce({ value: mockApp2, done: false }) - .mockResolvedValueOnce({ value: undefined, done: true }), - return: vi.fn().mockResolvedValue({ value: undefined, done: true }), - [Symbol.asyncIterator]() { - return this - }, + async function* iterator(): AsyncGenerator { + yield mockApp1 + yield mockApp2 } - mockBuildAndImportApp.mockReturnValue(iterator) + mockBuildAndImportApp.mockReturnValue(iterator()) mockModules.existsSync.mockReturnValue(true) mockModules.realpathSync.mockReturnValue('test-app.js') @@ -543,10 +531,10 @@ describe('requestCommand', () => { ]) const saved = mockSaveFile.mock.calls[0] - expect(new TextDecoder().decode(saved[0] as ArrayBuffer)).toBe(JSON.stringify(jsonBody)) + expect(new TextDecoder().decode(saved[0])).toBe(JSON.stringify(jsonBody)) expect(saved[1]).toBe(outputPath) expect(consoleErrorSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) - expect(JSON.parse(consoleLogSpy.mock.calls[0][0] as string).data.savedTo).toBe(outputPath) + expect(JSON.parse(consoleLogSpy.mock.calls[0][0]).data.savedTo).toBe(outputPath) }) it('should save binary response to specified file with -o option', async () => { @@ -573,7 +561,7 @@ describe('requestCommand', () => { ]) const saved = mockSaveFile.mock.calls[0] - expect(new Uint8Array(saved[0] as ArrayBuffer)).toEqual(new Uint8Array(binaryData)) + expect(new Uint8Array(saved[0])).toEqual(new Uint8Array(binaryData)) expect(saved[1]).toBe(outputPath) expect(consoleErrorSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) }) @@ -595,7 +583,7 @@ describe('requestCommand', () => { expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/index.html', 'text/html; charset=UTF-8') const saved = mockSaveFile.mock.calls[0] - expect(new TextDecoder().decode(saved[0] as ArrayBuffer)).toBe(htmlContent) + expect(new TextDecoder().decode(saved[0])).toBe(htmlContent) expect(saved[1]).toBe('index.html') expect(consoleErrorSpy).toHaveBeenCalledWith(`Saved response to index.html`) }) @@ -617,7 +605,7 @@ describe('requestCommand', () => { expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/image.png', 'image/png') const saved = mockSaveFile.mock.calls[0] - expect(new Uint8Array(saved[0] as ArrayBuffer)).toEqual(new Uint8Array(pngData)) + expect(new Uint8Array(saved[0])).toEqual(new Uint8Array(pngData)) expect(saved[1]).toBe('image.png') expect(consoleErrorSpy).toHaveBeenCalledWith(`Saved response to image.png`) }) @@ -639,7 +627,7 @@ describe('requestCommand', () => { expect(mockGetFilenameFromPath).toHaveBeenCalledWith('/', 'text/html; charset=UTF-8') const saved = mockSaveFile.mock.calls[0] - expect(new TextDecoder().decode(saved[0] as ArrayBuffer)).toBe(htmlContent) + expect(new TextDecoder().decode(saved[0])).toBe(htmlContent) expect(saved[1]).toBe('index') expect(consoleErrorSpy).toHaveBeenCalledWith(`Saved response to index`) }) @@ -672,7 +660,7 @@ describe('requestCommand', () => { expect(mockGetFilenameFromPath).not.toHaveBeenCalled() const saved = mockSaveFile.mock.calls[0] - expect(new TextDecoder().decode(saved[0] as ArrayBuffer)).toBe(textContent) + expect(new TextDecoder().decode(saved[0])).toBe(textContent) expect(saved[1]).toBe(outputPath) expect(consoleErrorSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) }) @@ -699,7 +687,7 @@ describe('requestCommand', () => { ]) const saved = mockSaveFile.mock.calls[0] - expect(new TextDecoder().decode(saved[0] as ArrayBuffer)).toBe(JSON.stringify(jsonBody)) + expect(new TextDecoder().decode(saved[0])).toBe(JSON.stringify(jsonBody)) expect(saved[1]).toBe(outputPath) expect(consoleErrorSpy).toHaveBeenCalledWith(`Saved response to ${outputPath}`) }) @@ -957,7 +945,7 @@ describe('requestCommand', () => { await program.parseAsync(['node', 'test', 'request', 'missing.ts']) - const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) + const parsed = JSON.parse(consoleLogSpy.mock.calls[0][0]) expect(parsed.ok).toBe(false) expect(parsed.error.code).toBe('ENTRY_NOT_FOUND') expect(parsed.error.hint).toBeDefined()