Skip to content

Commit 89ca0e2

Browse files
authored
feat(experimental): add TestAttachment.bodyEncoding (#9969)
1 parent 487990a commit 89ca0e2

File tree

10 files changed

+170
-29
lines changed

10 files changed

+170
-29
lines changed

docs/api/advanced/artifacts.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,22 @@ export interface TestAttachment {
8282
path?: string
8383
/** Inline attachment content as a string or raw binary data */
8484
body?: string | Uint8Array
85+
/**
86+
* @experimental
87+
* How the string `body` is encoded.
88+
* - `'base64'` (default): body is already base64-encoded
89+
* - `'utf-8'`: body is a utf8 string
90+
*/
91+
bodyEncoding?: 'base64' | 'utf-8'
8592
}
8693
```
8794

8895
The `TestAttachment` interface represents a file or data attachment associated with a test artifact.
8996

9097
Attachments can be either file-based (via `path`) or inline content (via `body`). The `contentType` helps consumers understand how to interpret the attachment data.
9198

99+
If you pass a string `body`, Vitest assumes it is already base64-encoded unless you set `bodyEncoding: 'utf-8'`. When you pass `body` as a `Uint8Array`, Vitest automatically encodes it as base64. The `bodyEncoding` option only applies to inline `body` attachments, not `path` attachments.
100+
92101
### `TestArtifactLocation`
93102

94103
```ts

docs/guide/test-annotations.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ test('hello world', async ({ annotate }) => {
1717

1818
const file = createTestSpecificFile()
1919
await annotate('creates a file', { body: file })
20+
21+
await annotate('creates a file with text', {
22+
contentType: 'text/markdown',
23+
body: 'Hello **markdown**',
24+
bodyEncoding: 'utf-8',
25+
})
2026
})
2127
```
2228

packages/runner/src/artifact.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,14 @@ export function manageArtifactAttachment(attachment: TestAttachment): void {
174174
if (attachment.body && attachment.path) {
175175
throw new TypeError(`Test attachment requires only one of "body" or "path" to be set. Both are specified.`)
176176
}
177+
if (attachment.path && attachment.bodyEncoding) {
178+
throw new TypeError(`Test attachment with "path" should not have "bodyEncoding" specified.`)
179+
}
177180
// convert to a string so it's easier to serialise
178181
if (attachment.body instanceof Uint8Array) {
179182
attachment.body = encodeUint8Array(attachment.body)
180183
}
184+
if (attachment.body != null) {
185+
attachment.bodyEncoding ??= 'base64'
186+
}
181187
}

packages/runner/src/types/tasks.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,6 +1263,17 @@ export interface TestAttachment {
12631263
path?: string
12641264
/** Inline attachment content as a string or raw binary data */
12651265
body?: string | Uint8Array | undefined
1266+
// TODO: change default to utf-8 on next major
1267+
/**
1268+
* @experimental
1269+
* How the string `body` is encoded.
1270+
* - `'base64'` (default): body is already base64-encoded
1271+
* - `'utf-8'`: body is a utf8 string
1272+
*
1273+
* `body: Uint8Array` is always auto-encoded to string with `bodyEncoding: 'base64'`
1274+
* regardless of this option.
1275+
*/
1276+
bodyEncoding?: 'base64' | 'utf-8'
12661277
}
12671278

12681279
export interface Location {

packages/ui/client/composables/attachments.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ export function getAttachmentUrl(attachment: TestAttachment): string {
1313
return `/__vitest_attachment__?path=${encodeURIComponent(attachment.path)}&contentType=${contentType}&token=${(window as any).VITEST_API_TOKEN}`
1414
}
1515
// attachment.body is always a string outside of the test frame
16+
if (attachment.bodyEncoding === 'utf-8') {
17+
return `data:${contentType},${encodeURIComponent(attachment.body as string)}`
18+
}
1619
return `data:${contentType};base64,${attachment.body}`
1720
}
1821

test/cli/test/annotations.test.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ test('simple', async ({ annotate }) => {
2323
await annotate('with base64 body', { body: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' })
2424
await annotate('with Uint8Array body', { body: new Uint8Array(Array.from({ length: 256 }).map((_, i) => i)) })
2525
await annotate('with contentType', { body: '', contentType: 'text/plain' })
26+
await annotate('bodyEncoding utf-8', { body: 'Hello world', bodyEncoding: 'utf-8', contentType: 'text/plain' })
27+
await annotate('bodyEncoding base64', { body: btoa('Hello world'), bodyEncoding: 'base64', contentType: 'text/plain' })
2628
})
2729
2830
describe('suite', () => {
@@ -112,6 +114,8 @@ describe('API', () => {
112114
"[annotate] simple with base64 body notice path=undefined contentType=undefined body=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
113115
"[annotate] simple with Uint8Array body notice path=undefined contentType=undefined body=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==",
114116
"[annotate] simple with contentType notice path=undefined contentType=text/plain body=",
117+
"[annotate] simple bodyEncoding utf-8 notice path=undefined contentType=text/plain body=Hello world",
118+
"[annotate] simple bodyEncoding base64 notice path=undefined contentType=text/plain body=SGVsbG8gd29ybGQ=",
115119
"[result] simple",
116120
"[ready] second",
117121
"[annotate] second 5 notice path=undefined contentType=undefined body=undefined",
@@ -133,7 +137,7 @@ describe('API', () => {
133137
"location": {
134138
"column": 11,
135139
"file": "<root>/basic.test.ts",
136-
"line": 18,
140+
"line": 20,
137141
},
138142
"message": "5",
139143
"type": "notice",
@@ -145,7 +149,7 @@ describe('API', () => {
145149
"location": {
146150
"column": 11,
147151
"file": "<root>/basic.test.ts",
148-
"line": 19,
152+
"line": 21,
149153
},
150154
"message": "6",
151155
"type": "notice",
@@ -208,6 +212,7 @@ describe('API', () => {
208212
{
209213
"attachment": {
210214
"body": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
215+
"bodyEncoding": "base64",
211216
},
212217
"location": {
213218
"column": 9,
@@ -220,6 +225,7 @@ describe('API', () => {
220225
{
221226
"attachment": {
222227
"body": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==",
228+
"bodyEncoding": "base64",
223229
},
224230
"location": {
225231
"column": 9,
@@ -232,6 +238,7 @@ describe('API', () => {
232238
{
233239
"attachment": {
234240
"body": "",
241+
"bodyEncoding": "base64",
235242
"contentType": "text/plain",
236243
},
237244
"location": {
@@ -242,6 +249,34 @@ describe('API', () => {
242249
"message": "with contentType",
243250
"type": "notice",
244251
},
252+
{
253+
"attachment": {
254+
"body": "Hello world",
255+
"bodyEncoding": "utf-8",
256+
"contentType": "text/plain",
257+
},
258+
"location": {
259+
"column": 9,
260+
"file": "<root>/basic.test.ts",
261+
"line": 14,
262+
},
263+
"message": "bodyEncoding utf-8",
264+
"type": "notice",
265+
},
266+
{
267+
"attachment": {
268+
"body": "SGVsbG8gd29ybGQ=",
269+
"bodyEncoding": "base64",
270+
"contentType": "text/plain",
271+
},
272+
"location": {
273+
"column": 9,
274+
"file": "<root>/basic.test.ts",
275+
"line": 15,
276+
},
277+
"message": "bodyEncoding base64",
278+
"type": "notice",
279+
},
245280
],
246281
}
247282
`)
@@ -290,6 +325,8 @@ describe('reporters', () => {
290325
# notice: with base64 body
291326
# notice: with Uint8Array body
292327
# notice: with contentType
328+
# notice: bodyEncoding utf-8
329+
# notice: bodyEncoding base64
293330
ok 2 - suite # time=<time> {
294331
1..1
295332
ok 1 - second # time=<time>
@@ -323,6 +360,8 @@ describe('reporters', () => {
323360
# notice: with base64 body
324361
# notice: with Uint8Array body
325362
# notice: with contentType
363+
# notice: bodyEncoding utf-8
364+
# notice: bodyEncoding base64
326365
ok 2 - basic.test.ts > suite > second # time=<time>
327366
# notice: 5
328367
# notice: 6
@@ -367,6 +406,10 @@ describe('reporters', () => {
367406
</property>
368407
<property name="notice" value="with contentType">
369408
</property>
409+
<property name="notice" value="bodyEncoding utf-8">
410+
</property>
411+
<property name="notice" value="bodyEncoding base64">
412+
</property>
370413
</properties>
371414
</testcase>
372415
<testcase classname="basic.test.ts" name="suite &gt; second" time="0">
@@ -417,9 +460,13 @@ describe('reporters', () => {
417460
418461
::notice file=<root>/basic.test.ts,line=13,column=9::with contentType
419462
420-
::notice file=<root>/basic.test.ts,line=18,column=11::5
463+
::notice file=<root>/basic.test.ts,line=14,column=9::bodyEncoding utf-8
464+
465+
::notice file=<root>/basic.test.ts,line=15,column=9::bodyEncoding base64
466+
467+
::notice file=<root>/basic.test.ts,line=20,column=11::5
421468
422-
::notice file=<root>/basic.test.ts,line=19,column=11::6
469+
::notice file=<root>/basic.test.ts,line=21,column=11::6
423470
"
424471
`)
425472
})
@@ -460,12 +507,16 @@ describe('reporters', () => {
460507
↳ with Uint8Array body
461508
❯ basic.test.ts:13:9 notice
462509
↳ with contentType
510+
❯ basic.test.ts:14:9 notice
511+
↳ bodyEncoding utf-8
512+
❯ basic.test.ts:15:9 notice
513+
↳ bodyEncoding base64
463514
464515
✓ basic.test.ts > suite > second <time>
465516
466-
❯ basic.test.ts:18:11 notice
517+
❯ basic.test.ts:20:11 notice
467518
↳ 5
468-
❯ basic.test.ts:19:11 notice
519+
❯ basic.test.ts:21:11 notice
469520
↳ 6
470521
471522
"

test/cli/test/artifacts.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ describe('API', () => {
196196
"attachments": [
197197
{
198198
"body": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
199+
"bodyEncoding": "base64",
199200
},
200201
],
201202
"location": {
@@ -209,6 +210,7 @@ describe('API', () => {
209210
"attachments": [
210211
{
211212
"body": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==",
213+
"bodyEncoding": "base64",
212214
},
213215
],
214216
"location": {
@@ -222,6 +224,7 @@ describe('API', () => {
222224
"attachments": [
223225
{
224226
"body": "",
227+
"bodyEncoding": "base64",
225228
"contentType": "text/plain",
226229
},
227230
],

test/ui/fixtures/annotated.test.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,17 @@ test('annotated image test', async ({ annotate }) => {
2121
})
2222
})
2323

24-
test('annotated with body', async ({ annotate }) => {
25-
await annotate('body annotation', {
24+
test('annotated with body base64', async ({ annotate }) => {
25+
await annotate('body base64 annotation', {
2626
contentType: 'text/markdown',
27-
// requires pre-encoded base64 for raw string
28-
// https://github.com/vitest-dev/vitest/issues/9633
29-
body: btoa('Hello **markdown**'),
27+
body: btoa('Hello base64 **markdown**'),
28+
})
29+
})
30+
31+
test('annotated with body utf-8', async ({ annotate }) => {
32+
await annotate('body utf-8 annotation', {
33+
contentType: 'text/markdown',
34+
body: 'Hello utf-8 **markdown**',
35+
bodyEncoding: 'utf-8',
3036
})
3137
})

test/ui/test/html-report.spec.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ test.describe('html report', () => {
5252
await page.goto(pageUrl)
5353

5454
// dashboard
55-
await expect(page.getByTestId('pass-entry')).toContainText('16 Pass')
55+
await expect(page.getByTestId('pass-entry')).toContainText('17 Pass')
5656
await expect(page.getByTestId('fail-entry')).toContainText('2 Fail')
57-
await expect(page.getByTestId('total-entry')).toContainText('18 Total')
57+
await expect(page.getByTestId('total-entry')).toContainText('19 Total')
5858

5959
// unhandled errors
6060
await expect(page.getByTestId('unhandled-errors')).toContainText(
@@ -160,25 +160,46 @@ test.describe('html report', () => {
160160
await expect(img).not.toHaveJSProperty('naturalWidth', 0)
161161
})
162162

163-
await test.step('annotated with body', async () => {
164-
const item = page.getByLabel('annotated with body')
163+
await test.step('annotated with body base64', async () => {
164+
const item = page.getByLabel('annotated with body base64')
165165
await item.click({ force: true })
166166
await page.getByTestId('btn-report').click({ force: true })
167167

168168
const annotation = page.getByRole('note')
169169
await expect(annotation).toHaveCount(1)
170170

171-
await expect(annotation).toContainText('body annotation')
171+
await expect(annotation).toContainText('body base64 annotation')
172172
await expect(annotation).toContainText('notice')
173173
await expect(annotation).toContainText('fixtures/annotated.test.ts:25:9')
174174

175175
const downloadPromise = page.waitForEvent('download')
176176
await annotation.getByRole('link').click()
177177
const download = await downloadPromise
178-
expect(download.suggestedFilename()).toBe('body-annotation.md')
178+
expect(download.suggestedFilename()).toBe('body-base64-annotation.md')
179179
const downloadPath = await download.path()
180180
const content = readFileSync(downloadPath, 'utf-8')
181-
expect(content).toBe('Hello **markdown**')
181+
expect(content).toBe('Hello base64 **markdown**')
182+
})
183+
184+
await test.step('annotated with body utf-8', async () => {
185+
const item = page.getByLabel('annotated with body utf-8')
186+
await item.click({ force: true })
187+
await page.getByTestId('btn-report').click({ force: true })
188+
189+
const annotation = page.getByRole('note')
190+
await expect(annotation).toHaveCount(1)
191+
192+
await expect(annotation).toContainText('body utf-8 annotation')
193+
await expect(annotation).toContainText('notice')
194+
await expect(annotation).toContainText('fixtures/annotated.test.ts:32:9')
195+
196+
const downloadPromise = page.waitForEvent('download')
197+
await annotation.getByRole('link').click()
198+
const download = await downloadPromise
199+
expect(download.suggestedFilename()).toBe('body-utf-8-annotation.md')
200+
const downloadPath = await download.path()
201+
const content = readFileSync(downloadPath, 'utf-8')
202+
expect(content).toBe('Hello utf-8 **markdown**')
182203
})
183204
})
184205

@@ -190,18 +211,20 @@ test.describe('html report', () => {
190211
await page.getByTestId('btn-code').click({ force: true })
191212

192213
const annotations = page.getByRole('note')
193-
await expect(annotations).toHaveCount(6)
214+
await expect(annotations).toHaveCount(7)
194215

195216
await expect(annotations.first()).toHaveText('notice: hello world')
196217
await expect(annotations.nth(1)).toHaveText('notice: second annotation')
197218
await expect(annotations.nth(2)).toHaveText('warning: beware!')
198219
await expect(annotations.nth(3)).toHaveText(/notice: file annotation/)
199220
await expect(annotations.nth(4)).toHaveText('notice: image annotation')
200-
await expect(annotations.nth(5)).toHaveText(/notice: body annotation/)
221+
await expect(annotations.nth(5)).toHaveText(/notice: body base64 annotation/)
222+
await expect(annotations.nth(6)).toHaveText(/notice: body utf-8 annotation/)
201223

202224
await expect(annotations.nth(3).getByRole('link')).toHaveAttribute('href', /data\/\w+/)
203225
await expect(annotations.nth(4).getByRole('link')).toHaveAttribute('href', /data\/\w+/)
204226
await expect(annotations.nth(5).getByRole('link')).toHaveAttribute('href', /^data:text\/markdown;base64,/)
227+
await expect(annotations.nth(6).getByRole('link')).toHaveAttribute('href', /^data:text\/markdown,/)
205228
})
206229

207230
test('tags filter', async ({ page }) => {

0 commit comments

Comments
 (0)