Skip to content

Commit fe38e32

Browse files
fix: Binary request bodies are inlined as raw binary characters breaking the script (#1120)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 27f337a commit fe38e32

File tree

3 files changed

+173
-16
lines changed

3 files changed

+173
-16
lines changed

src/codegen/codegen.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
generateImports,
2727
generateParameterizationCustomCode,
2828
generateRequestSnippetsFromSchemas,
29+
isBinaryContent,
2930
} from './codegen'
3031

3132
const fakeDate = new Date('2000-01-01T00:00:00Z')
@@ -183,6 +184,19 @@ describe('Code generation', () => {
183184
)
184185
).toBe(expectedResult)
185186
})
187+
188+
it('should generate imports with encoding when hasBinaryContent is true', async () => {
189+
const expectedResult = await prettify(`
190+
import { group, sleep, check } from 'k6'
191+
import http from 'k6/http'
192+
import execution from "k6/execution";
193+
import encoding from "k6/encoding";
194+
`)
195+
196+
expect(
197+
await prettify(generateImports(generator, { hasBinaryContent: true }))
198+
).toBe(expectedResult)
199+
})
186200
})
187201

188202
describe('generateVariableDeclarations', () => {
@@ -728,4 +742,110 @@ describe('Code generation', () => {
728742
)
729743
})
730744
})
745+
746+
describe('isBinaryContent', () => {
747+
it('should return true for content with null bytes', () => {
748+
expect(isBinaryContent('hello\x00world')).toBe(true)
749+
})
750+
751+
it('should return true for content with control characters', () => {
752+
expect(isBinaryContent('data\x01\x02\x03')).toBe(true)
753+
})
754+
755+
it('should return false for normal text', () => {
756+
expect(isBinaryContent('hello world')).toBe(false)
757+
})
758+
759+
it('should return false for text with tabs, newlines, and carriage returns', () => {
760+
expect(isBinaryContent('hello\tworld\nfoo\rbar')).toBe(false)
761+
})
762+
763+
it('should return false for empty string', () => {
764+
expect(isBinaryContent('')).toBe(false)
765+
})
766+
767+
it('should return false for JSON content', () => {
768+
expect(isBinaryContent('{"key": "value"}')).toBe(false)
769+
})
770+
})
771+
772+
describe('generateSingleRequestSnippet with binary content', () => {
773+
it('should use encoding.b64decode for binary content', () => {
774+
const binaryContent = 'hello\x00\x01\x02world'
775+
const schema = {
776+
data: createProxyData({
777+
id: '1',
778+
request: createRequest({
779+
method: 'POST',
780+
url: '/api/v1/upload',
781+
content: binaryContent,
782+
headers: [['content-type', 'application/octet-stream']],
783+
}),
784+
}),
785+
before: [],
786+
after: [],
787+
checks: [],
788+
}
789+
790+
const result = generateRequestSnippetsFromSchemas([schema], thinkTime)
791+
const snippet = result[0]?.snippet ?? ''
792+
793+
expect(snippet).toContain("encoding.b64decode('")
794+
expect(snippet).not.toContain('`hello')
795+
})
796+
797+
it('should use backtick wrapping for text content', () => {
798+
const schema = {
799+
data: createProxyData({
800+
id: '1',
801+
request: createRequest({
802+
method: 'POST',
803+
url: '/api/v1/users',
804+
content: '{"user": "test"}',
805+
headers: [['content-type', 'application/json']],
806+
}),
807+
}),
808+
before: [],
809+
after: [],
810+
checks: [],
811+
}
812+
813+
const result = generateRequestSnippetsFromSchemas([schema], thinkTime)
814+
const snippet = result[0]?.snippet ?? ''
815+
816+
expect(snippet).toContain('`{"user": "test"}`')
817+
expect(snippet).not.toContain('encoding.b64decode')
818+
})
819+
820+
it('should base64 encode binary content when btoa cannot handle input', () => {
821+
const binaryContent = "\x00\u0100'data"
822+
const bytes = new TextEncoder().encode(binaryContent)
823+
let utf8Binary = ''
824+
for (const byte of bytes) {
825+
utf8Binary += String.fromCharCode(byte)
826+
}
827+
const expectedBase64Content = btoa(utf8Binary)
828+
const schema = {
829+
data: createProxyData({
830+
id: '1',
831+
request: createRequest({
832+
method: 'POST',
833+
url: '/api/v1/upload',
834+
content: binaryContent,
835+
headers: [['content-type', 'application/octet-stream']],
836+
}),
837+
}),
838+
before: [],
839+
after: [],
840+
checks: [],
841+
}
842+
843+
const result = generateRequestSnippetsFromSchemas([schema], thinkTime)
844+
const snippet = result[0]?.snippet ?? ''
845+
846+
expect(snippet).toContain(
847+
`encoding.b64decode('${expectedBase64Content}')`
848+
)
849+
})
850+
})
731851
})

src/codegen/codegen.ts

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CustomCodeValue, ParameterizationRule, TestRule } from '@/types/rules'
77
import { DataFile, Variable } from '@/types/testData'
88
import { ThinkTime } from '@/types/testOptions'
99
import { getFileNameWithoutExtension } from '@/utils/file'
10+
import { safeBtoa } from '@/utils/format'
1011
import { groupProxyData } from '@/utils/groups'
1112
import { getContentTypeWithCharsetHeader } from '@/utils/headers'
1213
import { exhaustive } from '@/utils/typescript'
@@ -29,10 +30,14 @@ export function generateScript({
2930
recording,
3031
generator,
3132
}: GenerateScriptParams): string {
33+
const hasBinaryContent = recording.some(
34+
({ request }) => request.content != null && isBinaryContent(request.content)
35+
)
36+
3237
return `
3338
// ${generateScriptHeader()}
3439
35-
${generateImports(generator)}
40+
${generateImports(generator, { hasBinaryContent })}
3641
3742
export const options = ${generateOptions(generator.options)}
3843
@@ -46,7 +51,14 @@ export function generateScript({
4651
`
4752
}
4853

49-
export function generateImports(generator: GeneratorFileData): string {
54+
interface GenerateImportsOptions {
55+
hasBinaryContent?: boolean
56+
}
57+
58+
export function generateImports(
59+
generator: GeneratorFileData,
60+
options: GenerateImportsOptions = {}
61+
): string {
5062
const hasCSVDataFiles = generator.testData.files.some(({ name }) =>
5163
name.toLowerCase().endsWith('csv')
5264
)
@@ -61,6 +73,8 @@ export function generateImports(generator: GeneratorFileData): string {
6173
...(hasCSVDataFiles
6274
? [K6_EXPORTS['k6/experimental/csv'], K6_EXPORTS['k6/experimental/fs']]
6375
: []),
76+
// Import k6/encoding for binary content
77+
...(options.hasBinaryContent ? [K6_EXPORTS['k6/encoding']] : []),
6478
]
6579

6680
return imports.map(generateImportStatement).join('\n')
@@ -230,19 +244,24 @@ export function generateSingleRequestSnippet(
230244

231245
try {
232246
if (request.content) {
233-
const escapedContent = escapeBackticks(request.content)
234-
content = `\`${escapedContent}\``
235-
236-
// if we have postData parameters we need to pass an object to the k6 post function because if it receives
237-
// a stringified json it won't correctly post the data.
238-
const contentTypeHeader =
239-
getContentTypeWithCharsetHeader(request.headers) ?? ''
240-
if (contentTypeHeader.includes('application/x-www-form-urlencoded')) {
241-
content = `JSON.parse(\`${escapedContent}\`)`
242-
}
243-
244-
if (contentTypeHeader.includes('multipart/form-data')) {
245-
content = `\`${escapedContent.replace(/(?:\r\n|\r|\n)/g, '\\r\\n')}\``
247+
if (isBinaryContent(request.content)) {
248+
const base64Content = safeBtoa(request.content)
249+
content = `encoding.b64decode('${base64Content}')`
250+
} else {
251+
const escapedContent = escapeBackticks(request.content)
252+
content = `\`${escapedContent}\``
253+
254+
// if we have postData parameters we need to pass an object to the k6 post function because if it receives
255+
// a stringified json it won't correctly post the data.
256+
const contentTypeHeader =
257+
getContentTypeWithCharsetHeader(request.headers) ?? ''
258+
if (contentTypeHeader.includes('application/x-www-form-urlencoded')) {
259+
content = `JSON.parse(\`${escapedContent}\`)`
260+
}
261+
262+
if (contentTypeHeader.includes('multipart/form-data')) {
263+
content = `\`${escapedContent.replace(/(?:\r\n|\r|\n)/g, '\\r\\n')}\``
264+
}
246265
}
247266
}
248267
} catch (error) {
@@ -316,6 +335,17 @@ export function generateParameterizationCustomCode(
316335
.join('\n')
317336
}
318337

338+
export function isBinaryContent(content: string): boolean {
339+
for (let i = 0; i < content.length; i++) {
340+
const code = content.charCodeAt(i)
341+
// Null byte or control character that isn't whitespace (tab, newline, carriage return)
342+
if (code === 0 || (code < 32 && code !== 9 && code !== 10 && code !== 13)) {
343+
return true
344+
}
345+
}
346+
return false
347+
}
348+
319349
function escapeBackticks(content: string): string {
320350
return content.replace(/`/g, '\\`')
321351
}

src/utils/format.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,14 @@ export function safeBtoa(content: string) {
3838
try {
3939
return btoa(content)
4040
} catch {
41-
return content
41+
const bytes = new TextEncoder().encode(content)
42+
let binary = ''
43+
44+
for (const byte of bytes) {
45+
binary += String.fromCharCode(byte)
46+
}
47+
48+
return btoa(binary)
4249
}
4350
}
4451

0 commit comments

Comments
 (0)