Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/api/advanced/artifacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,22 @@ export interface TestAttachment {
path?: string
/** Inline attachment content as a string or raw binary data */
body?: string | Uint8Array
/**
* @experimental
* How the string `body` is encoded.
* - `'base64'` (default): body is already base64-encoded
* - `'utf-8'`: body is a utf8 string
*/
bodyEncoding?: 'base64' | 'utf-8'
}
```

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

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

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.

### `TestArtifactLocation`

```ts
Expand Down
6 changes: 6 additions & 0 deletions docs/guide/test-annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ test('hello world', async ({ annotate }) => {

const file = createTestSpecificFile()
await annotate('creates a file', { body: file })

await annotate('creates a file with text', {
contentType: 'text/markdown',
body: 'Hello **markdown**',
bodyEncoding: 'utf-8',
})
})
```

Expand Down
6 changes: 6 additions & 0 deletions packages/runner/src/artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,14 @@ export function manageArtifactAttachment(attachment: TestAttachment): void {
if (attachment.body && attachment.path) {
throw new TypeError(`Test attachment requires only one of "body" or "path" to be set. Both are specified.`)
}
if (attachment.path && attachment.bodyEncoding) {
throw new TypeError(`Test attachment with "path" should not have "bodyEncoding" specified.`)
}
// convert to a string so it's easier to serialise
if (attachment.body instanceof Uint8Array) {
attachment.body = encodeUint8Array(attachment.body)
}
if (attachment.body != null) {
attachment.bodyEncoding ??= 'base64'
}
}
11 changes: 11 additions & 0 deletions packages/runner/src/types/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1263,6 +1263,17 @@ export interface TestAttachment {
path?: string
/** Inline attachment content as a string or raw binary data */
body?: string | Uint8Array | undefined
// TODO: change default to utf-8 on next major
/**
* @experimental
* How the string `body` is encoded.
* - `'base64'` (default): body is already base64-encoded
* - `'utf-8'`: body is a utf8 string
*
* `body: Uint8Array` is always auto-encoded to string with `bodyEncoding: 'base64'`
* regardless of this option.
*/
bodyEncoding?: 'base64' | 'utf-8'
}

export interface Location {
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/client/composables/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export function getAttachmentUrl(attachment: TestAttachment): string {
return `/__vitest_attachment__?path=${encodeURIComponent(attachment.path)}&contentType=${contentType}&token=${(window as any).VITEST_API_TOKEN}`
}
// attachment.body is always a string outside of the test frame
if (attachment.bodyEncoding === 'utf-8') {
return `data:${contentType},${encodeURIComponent(attachment.body as string)}`
}
return `data:${contentType};base64,${attachment.body}`
}

Expand Down
63 changes: 57 additions & 6 deletions test/cli/test/annotations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ test('simple', async ({ annotate }) => {
await annotate('with base64 body', { body: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' })
await annotate('with Uint8Array body', { body: new Uint8Array(Array.from({ length: 256 }).map((_, i) => i)) })
await annotate('with contentType', { body: '', contentType: 'text/plain' })
await annotate('bodyEncoding utf-8', { body: 'Hello world', bodyEncoding: 'utf-8', contentType: 'text/plain' })
await annotate('bodyEncoding base64', { body: btoa('Hello world'), bodyEncoding: 'base64', contentType: 'text/plain' })
})

describe('suite', () => {
Expand Down Expand Up @@ -112,6 +114,8 @@ describe('API', () => {
"[annotate] simple with base64 body notice path=undefined contentType=undefined body=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
"[annotate] simple with Uint8Array body notice path=undefined contentType=undefined body=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==",
"[annotate] simple with contentType notice path=undefined contentType=text/plain body=",
"[annotate] simple bodyEncoding utf-8 notice path=undefined contentType=text/plain body=Hello world",
"[annotate] simple bodyEncoding base64 notice path=undefined contentType=text/plain body=SGVsbG8gd29ybGQ=",
"[result] simple",
"[ready] second",
"[annotate] second 5 notice path=undefined contentType=undefined body=undefined",
Expand All @@ -133,7 +137,7 @@ describe('API', () => {
"location": {
"column": 11,
"file": "<root>/basic.test.ts",
"line": 18,
"line": 20,
},
"message": "5",
"type": "notice",
Expand All @@ -145,7 +149,7 @@ describe('API', () => {
"location": {
"column": 11,
"file": "<root>/basic.test.ts",
"line": 19,
"line": 21,
},
"message": "6",
"type": "notice",
Expand Down Expand Up @@ -208,6 +212,7 @@ describe('API', () => {
{
"attachment": {
"body": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
"bodyEncoding": "base64",
},
"location": {
"column": 9,
Expand All @@ -220,6 +225,7 @@ describe('API', () => {
{
"attachment": {
"body": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==",
"bodyEncoding": "base64",
},
"location": {
"column": 9,
Expand All @@ -232,6 +238,7 @@ describe('API', () => {
{
"attachment": {
"body": "",
"bodyEncoding": "base64",
"contentType": "text/plain",
},
"location": {
Expand All @@ -242,6 +249,34 @@ describe('API', () => {
"message": "with contentType",
"type": "notice",
},
{
"attachment": {
"body": "Hello world",
"bodyEncoding": "utf-8",
"contentType": "text/plain",
},
"location": {
"column": 9,
"file": "<root>/basic.test.ts",
"line": 14,
},
"message": "bodyEncoding utf-8",
"type": "notice",
},
{
"attachment": {
"body": "SGVsbG8gd29ybGQ=",
"bodyEncoding": "base64",
"contentType": "text/plain",
},
"location": {
"column": 9,
"file": "<root>/basic.test.ts",
"line": 15,
},
"message": "bodyEncoding base64",
"type": "notice",
},
],
}
`)
Expand Down Expand Up @@ -290,6 +325,8 @@ describe('reporters', () => {
# notice: with base64 body
# notice: with Uint8Array body
# notice: with contentType
# notice: bodyEncoding utf-8
# notice: bodyEncoding base64
ok 2 - suite # time=<time> {
1..1
ok 1 - second # time=<time>
Expand Down Expand Up @@ -323,6 +360,8 @@ describe('reporters', () => {
# notice: with base64 body
# notice: with Uint8Array body
# notice: with contentType
# notice: bodyEncoding utf-8
# notice: bodyEncoding base64
ok 2 - basic.test.ts > suite > second # time=<time>
# notice: 5
# notice: 6
Expand Down Expand Up @@ -367,6 +406,10 @@ describe('reporters', () => {
</property>
<property name="notice" value="with contentType">
</property>
<property name="notice" value="bodyEncoding utf-8">
</property>
<property name="notice" value="bodyEncoding base64">
</property>
</properties>
</testcase>
<testcase classname="basic.test.ts" name="suite &gt; second" time="0">
Expand Down Expand Up @@ -417,9 +460,13 @@ describe('reporters', () => {

::notice file=<root>/basic.test.ts,line=13,column=9::with contentType

::notice file=<root>/basic.test.ts,line=18,column=11::5
::notice file=<root>/basic.test.ts,line=14,column=9::bodyEncoding utf-8

::notice file=<root>/basic.test.ts,line=15,column=9::bodyEncoding base64

::notice file=<root>/basic.test.ts,line=20,column=11::5

::notice file=<root>/basic.test.ts,line=19,column=11::6
::notice file=<root>/basic.test.ts,line=21,column=11::6
"
`)
})
Expand Down Expand Up @@ -460,12 +507,16 @@ describe('reporters', () => {
↳ with Uint8Array body
❯ basic.test.ts:13:9 notice
↳ with contentType
❯ basic.test.ts:14:9 notice
↳ bodyEncoding utf-8
❯ basic.test.ts:15:9 notice
↳ bodyEncoding base64

✓ basic.test.ts > suite > second <time>

❯ basic.test.ts:18:11 notice
❯ basic.test.ts:20:11 notice
↳ 5
❯ basic.test.ts:19:11 notice
❯ basic.test.ts:21:11 notice
↳ 6

"
Expand Down
3 changes: 3 additions & 0 deletions test/cli/test/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ describe('API', () => {
"attachments": [
{
"body": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
"bodyEncoding": "base64",
},
],
"location": {
Expand All @@ -209,6 +210,7 @@ describe('API', () => {
"attachments": [
{
"body": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==",
"bodyEncoding": "base64",
},
],
"location": {
Expand All @@ -222,6 +224,7 @@ describe('API', () => {
"attachments": [
{
"body": "",
"bodyEncoding": "base64",
"contentType": "text/plain",
},
],
Expand Down
16 changes: 11 additions & 5 deletions test/ui/fixtures/annotated.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ test('annotated image test', async ({ annotate }) => {
})
})

test('annotated with body', async ({ annotate }) => {
await annotate('body annotation', {
test('annotated with body base64', async ({ annotate }) => {
await annotate('body base64 annotation', {
contentType: 'text/markdown',
// requires pre-encoded base64 for raw string
// https://github.com/vitest-dev/vitest/issues/9633
body: btoa('Hello **markdown**'),
body: btoa('Hello base64 **markdown**'),
})
})

test('annotated with body utf-8', async ({ annotate }) => {
await annotate('body utf-8 annotation', {
contentType: 'text/markdown',
body: 'Hello utf-8 **markdown**',
bodyEncoding: 'utf-8',
})
})
41 changes: 32 additions & 9 deletions test/ui/test/html-report.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ test.describe('html report', () => {
await page.goto(pageUrl)

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

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

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

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

await expect(annotation).toContainText('body annotation')
await expect(annotation).toContainText('body base64 annotation')
await expect(annotation).toContainText('notice')
await expect(annotation).toContainText('fixtures/annotated.test.ts:25:9')

const downloadPromise = page.waitForEvent('download')
await annotation.getByRole('link').click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('body-annotation.md')
expect(download.suggestedFilename()).toBe('body-base64-annotation.md')
const downloadPath = await download.path()
const content = readFileSync(downloadPath, 'utf-8')
expect(content).toBe('Hello **markdown**')
expect(content).toBe('Hello base64 **markdown**')
})

await test.step('annotated with body utf-8', async () => {
const item = page.getByLabel('annotated with body utf-8')
await item.click({ force: true })
await page.getByTestId('btn-report').click({ force: true })

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

await expect(annotation).toContainText('body utf-8 annotation')
await expect(annotation).toContainText('notice')
await expect(annotation).toContainText('fixtures/annotated.test.ts:32:9')

const downloadPromise = page.waitForEvent('download')
await annotation.getByRole('link').click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('body-utf-8-annotation.md')
const downloadPath = await download.path()
const content = readFileSync(downloadPath, 'utf-8')
expect(content).toBe('Hello utf-8 **markdown**')
})
})

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

const annotations = page.getByRole('note')
await expect(annotations).toHaveCount(6)
await expect(annotations).toHaveCount(7)

await expect(annotations.first()).toHaveText('notice: hello world')
await expect(annotations.nth(1)).toHaveText('notice: second annotation')
await expect(annotations.nth(2)).toHaveText('warning: beware!')
await expect(annotations.nth(3)).toHaveText(/notice: file annotation/)
await expect(annotations.nth(4)).toHaveText('notice: image annotation')
await expect(annotations.nth(5)).toHaveText(/notice: body annotation/)
await expect(annotations.nth(5)).toHaveText(/notice: body base64 annotation/)
await expect(annotations.nth(6)).toHaveText(/notice: body utf-8 annotation/)

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

test('tags filter', async ({ page }) => {
Expand Down
Loading
Loading