From 89ca0e2549db38a35e8c996beba45716ffb35a5a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Tue, 31 Mar 2026 19:24:09 +0900 Subject: [PATCH] feat(experimental): add `TestAttachment.bodyEncoding` (#9969) --- docs/api/advanced/artifacts.md | 9 +++ docs/guide/test-annotations.md | 6 ++ packages/runner/src/artifact.ts | 6 ++ packages/runner/src/types/tasks.ts | 11 ++++ packages/ui/client/composables/attachments.ts | 3 + test/cli/test/annotations.test.ts | 63 +++++++++++++++++-- test/cli/test/artifacts.test.ts | 3 + test/ui/fixtures/annotated.test.ts | 16 +++-- test/ui/test/html-report.spec.ts | 41 +++++++++--- test/ui/test/ui.spec.ts | 41 +++++++++--- 10 files changed, 170 insertions(+), 29 deletions(-) diff --git a/docs/api/advanced/artifacts.md b/docs/api/advanced/artifacts.md index b1db9f49d..114e99457 100644 --- a/docs/api/advanced/artifacts.md +++ b/docs/api/advanced/artifacts.md @@ -82,6 +82,13 @@ 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' } ``` @@ -89,6 +96,8 @@ The `TestAttachment` interface represents a file or data attachment associated w 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 diff --git a/docs/guide/test-annotations.md b/docs/guide/test-annotations.md index ab5f7dd3f..075d68402 100644 --- a/docs/guide/test-annotations.md +++ b/docs/guide/test-annotations.md @@ -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', + }) }) ``` diff --git a/packages/runner/src/artifact.ts b/packages/runner/src/artifact.ts index 6f64d8567..3d0ba04db 100644 --- a/packages/runner/src/artifact.ts +++ b/packages/runner/src/artifact.ts @@ -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' + } } diff --git a/packages/runner/src/types/tasks.ts b/packages/runner/src/types/tasks.ts index 8a1528dbb..1c725112c 100644 --- a/packages/runner/src/types/tasks.ts +++ b/packages/runner/src/types/tasks.ts @@ -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 { diff --git a/packages/ui/client/composables/attachments.ts b/packages/ui/client/composables/attachments.ts index 3ed41a1cc..a2d327775 100644 --- a/packages/ui/client/composables/attachments.ts +++ b/packages/ui/client/composables/attachments.ts @@ -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}` } diff --git a/test/cli/test/annotations.test.ts b/test/cli/test/annotations.test.ts index dabb7c4b6..ce0f00eb7 100644 --- a/test/cli/test/annotations.test.ts +++ b/test/cli/test/annotations.test.ts @@ -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', () => { @@ -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", @@ -133,7 +137,7 @@ describe('API', () => { "location": { "column": 11, "file": "/basic.test.ts", - "line": 18, + "line": 20, }, "message": "5", "type": "notice", @@ -145,7 +149,7 @@ describe('API', () => { "location": { "column": 11, "file": "/basic.test.ts", - "line": 19, + "line": 21, }, "message": "6", "type": "notice", @@ -208,6 +212,7 @@ describe('API', () => { { "attachment": { "body": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", + "bodyEncoding": "base64", }, "location": { "column": 9, @@ -220,6 +225,7 @@ describe('API', () => { { "attachment": { "body": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w==", + "bodyEncoding": "base64", }, "location": { "column": 9, @@ -232,6 +238,7 @@ describe('API', () => { { "attachment": { "body": "", + "bodyEncoding": "base64", "contentType": "text/plain", }, "location": { @@ -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": "/basic.test.ts", + "line": 14, + }, + "message": "bodyEncoding utf-8", + "type": "notice", + }, + { + "attachment": { + "body": "SGVsbG8gd29ybGQ=", + "bodyEncoding": "base64", + "contentType": "text/plain", + }, + "location": { + "column": 9, + "file": "/basic.test.ts", + "line": 15, + }, + "message": "bodyEncoding base64", + "type": "notice", + }, ], } `) @@ -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=