Skip to content

Commit 7c94c17

Browse files
hi-ogawaclaude
andauthored
refactor: copy attachmentsDir in html reporter output (#9632)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9fd4ce5 commit 7c94c17

6 files changed

Lines changed: 81 additions & 77 deletions

File tree

packages/ui/client/composables/attachments.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import type { TestAttachment } from '@vitest/runner'
22
import mime from 'mime/lite'
3+
import { basename } from 'pathe'
34
import { isReport } from '~/constants'
45

56
export function getAttachmentUrl(attachment: TestAttachment): string {
6-
// html reporter always saves files into /data/ folder
7-
if (isReport) {
8-
return `/data/${attachment.path}`
9-
}
107
const contentType = attachment.contentType ?? 'application/octet-stream'
118
if (attachment.path) {
9+
if (isReport) {
10+
// html reporter copies attachments to /data/ folder
11+
return `/data/${basename(attachment.path)}`
12+
}
1213
return `/__vitest_attachment__?path=${encodeURIComponent(attachment.path)}&contentType=${contentType}&token=${(window as any).VITEST_API_TOKEN}`
1314
}
1415
// attachment.body is always a string outside of the test frame

packages/ui/node/reporter.ts

Lines changed: 11 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
1-
import type { Task, TestAttachment } from '@vitest/runner'
21
import type { ModuleGraphData, RunnerTestFile, SerializedConfig } from 'vitest'
32
import type { HTMLOptions, Reporter, Vitest } from 'vitest/node'
4-
import crypto from 'node:crypto'
5-
import { promises as fs } from 'node:fs'
6-
import { readFile, writeFile } from 'node:fs/promises'
3+
import { existsSync, promises as fs } from 'node:fs'
74
import { fileURLToPath } from 'node:url'
85
import { promisify } from 'node:util'
96
import { gzip, constants as zlibConstants } from 'node:zlib'
107
import { stringify } from 'flatted'
11-
import mime from 'mime/lite'
12-
import { dirname, extname, relative, resolve } from 'pathe'
8+
import { dirname, relative, resolve } from 'pathe'
139
import { globSync } from 'tinyglobby'
1410
import c from 'tinyrainbow'
1511
import { getModuleGraph } from '../../vitest/src/utils/graph'
@@ -66,7 +62,6 @@ export default class HTMLReporter implements Reporter {
6662
this.reporterDir = dirname(htmlFilePath)
6763
this.htmlFilePath = htmlFilePath
6864

69-
await fs.mkdir(resolve(this.reporterDir, 'data'), { recursive: true })
7065
await fs.mkdir(resolve(this.reporterDir, 'assets'), { recursive: true })
7166
}
7267

@@ -82,30 +77,7 @@ export default class HTMLReporter implements Reporter {
8277
}
8378
const promises: Promise<void>[] = []
8479

85-
const processAttachments = (task: Task) => {
86-
if (task.type === 'test') {
87-
task.annotations.forEach((annotation) => {
88-
const attachment = annotation.attachment
89-
if (attachment) {
90-
promises.push(this.processAttachment(attachment))
91-
}
92-
})
93-
task.artifacts.forEach((artifact) => {
94-
const attachments = artifact.attachments
95-
if (attachments) {
96-
attachments.forEach((attachment) => {
97-
promises.push(this.processAttachment(attachment))
98-
})
99-
}
100-
})
101-
}
102-
else {
103-
task.tasks.forEach(processAttachments)
104-
}
105-
}
106-
10780
promises.push(...result.files.map(async (file) => {
108-
processAttachments(file)
10981
const projectName = file.projectName || ''
11082
const resolvedConfig = this.ctx.getProjectByName(projectName).config
11183
const browser = resolvedConfig.browser.enabled
@@ -132,42 +104,6 @@ export default class HTMLReporter implements Reporter {
132104
await this.writeReport(stringify(result))
133105
}
134106

135-
async processAttachment(attachment: TestAttachment): Promise<void> {
136-
if (attachment.path) {
137-
// keep external resource as is, but remove body if it's set somehow
138-
if (
139-
attachment.path.startsWith('http://')
140-
|| attachment.path.startsWith('https://')
141-
) {
142-
attachment.body = undefined
143-
return
144-
}
145-
146-
const buffer = await readFile(attachment.path)
147-
const hash = crypto.createHash('sha1').update(buffer).digest('hex')
148-
const filename = hash + extname(attachment.path)
149-
// move the file into an html directory to make access/publishing UI easier
150-
await writeFile(resolve(this.reporterDir, 'data', filename), buffer)
151-
attachment.path = filename
152-
attachment.body = undefined
153-
return
154-
}
155-
156-
if (attachment.body) {
157-
const buffer = typeof attachment.body === 'string'
158-
? Buffer.from(attachment.body, 'base64')
159-
: Buffer.from(attachment.body)
160-
161-
const hash = crypto.createHash('sha1').update(buffer).digest('hex')
162-
const extension = mime.getExtension(attachment.contentType || 'application/octet-stream') || 'dat'
163-
const filename = `${hash}.${extension}`
164-
// store the file in html directory instead of passing down as a body
165-
await writeFile(resolve(this.reporterDir, 'data', filename), buffer)
166-
attachment.path = filename
167-
attachment.body = undefined
168-
}
169-
}
170-
171107
async writeReport(report: string): Promise<void> {
172108
const metaFile = resolve(this.reporterDir, 'html.meta.json.gz')
173109

@@ -198,6 +134,15 @@ export default class HTMLReporter implements Reporter {
198134
}),
199135
)
200136

137+
// copy attachments
138+
// TODO: unify attachmentsDir and html outputFile, so both live together without extra copy
139+
if (existsSync(this.ctx.config.attachmentsDir)) {
140+
const destAttachmentsDir = resolve(this.reporterDir, 'data')
141+
await fs.rm(destAttachmentsDir, { recursive: true, force: true })
142+
await fs.mkdir(destAttachmentsDir, { recursive: true })
143+
await fs.cp(this.ctx.config.attachmentsDir, destAttachmentsDir, { recursive: true })
144+
}
145+
201146
this.ctx.logger.log(
202147
`${c.bold(c.inverse(c.magenta(' HTML ')))} ${c.magenta(
203148
'Report is generated',

test/ui/fixtures/annotated.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,12 @@ test('annotated image test', async ({ annotate }) => {
2020
path: './fixtures/cute-puppy.jpg'
2121
})
2222
})
23+
24+
test('annotated with body', async ({ annotate }) => {
25+
await annotate('body annotation', {
26+
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**'),
30+
})
31+
})

test/ui/playwright.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ export default defineConfig({
55
projects: [
66
{
77
name: 'chromium',
8-
use: devices['Desktop Chrome'],
8+
// increase viewport height so virtual scroller renders all explorer items
9+
use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 900 } },
910
},
1011
],
1112
use: {

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

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { PreviewServer } from 'vite'
2+
import { readFileSync } from 'node:fs'
23
import { Writable } from 'node:stream'
34
import { expect, test } from '@playwright/test'
45
import { preview } from 'vite'
@@ -66,7 +67,7 @@ test.describe('html report', () => {
6667
await page.goto(pageUrl)
6768

6869
// dashboard
69-
await expect(page.locator('[aria-labelledby=tests]')).toContainText('15 Pass 2 Fail 17 Total')
70+
await expect(page.locator('[aria-labelledby=tests]')).toContainText('16 Pass 2 Fail 18 Total')
7071

7172
// unhandled errors
7273
await expect(page.getByTestId('unhandled-errors')).toContainText(
@@ -169,6 +170,27 @@ test.describe('html report', () => {
169170
await expect(annotation.getByRole('link')).toHaveAttribute('href', /data\/\w+/)
170171
await expect(annotation.getByRole('img')).toHaveAttribute('src', /data\/\w+/)
171172
})
173+
174+
await test.step('annotated with body', async () => {
175+
const item = page.getByLabel('annotated with body')
176+
await item.click({ force: true })
177+
await page.getByTestId('btn-report').click({ force: true })
178+
179+
const annotation = page.getByRole('note')
180+
await expect(annotation).toHaveCount(1)
181+
182+
await expect(annotation).toContainText('body annotation')
183+
await expect(annotation).toContainText('notice')
184+
await expect(annotation).toContainText('fixtures/annotated.test.ts:25:9')
185+
186+
const downloadPromise = page.waitForEvent('download')
187+
await annotation.getByRole('link').click()
188+
const download = await downloadPromise
189+
expect(download.suggestedFilename()).toBe('body-annotation.md')
190+
const downloadPath = await download.path()
191+
const content = readFileSync(downloadPath, 'utf-8')
192+
expect(content).toBe('Hello **markdown**')
193+
})
172194
})
173195

174196
test('annotations', async ({ page }) => {
@@ -179,16 +201,18 @@ test.describe('html report', () => {
179201
await page.getByTestId('btn-code').click({ force: true })
180202

181203
const annotations = page.getByRole('note')
182-
await expect(annotations).toHaveCount(5)
204+
await expect(annotations).toHaveCount(6)
183205

184206
await expect(annotations.first()).toHaveText('notice: hello world')
185207
await expect(annotations.nth(1)).toHaveText('notice: second annotation')
186208
await expect(annotations.nth(2)).toHaveText('warning: beware!')
187209
await expect(annotations.nth(3)).toHaveText(/notice: file annotation/)
188210
await expect(annotations.nth(4)).toHaveText('notice: image annotation')
211+
await expect(annotations.nth(5)).toHaveText(/notice: body annotation/)
189212

190-
await expect(annotations.last().getByRole('link')).toHaveAttribute('href', /data\/\w+/)
191213
await expect(annotations.nth(3).getByRole('link')).toHaveAttribute('href', /data\/\w+/)
214+
await expect(annotations.nth(4).getByRole('link')).toHaveAttribute('href', /data\/\w+/)
215+
await expect(annotations.nth(5).getByRole('link')).toHaveAttribute('href', /^data:text\/markdown;base64,/)
192216
})
193217

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

test/ui/test/ui.spec.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Vitest } from 'vitest/node'
2+
import { readFileSync } from 'node:fs'
23
import { Writable } from 'node:stream'
34
import { expect, test } from '@playwright/test'
45
import { startVitest } from 'vitest/node'
@@ -70,7 +71,7 @@ test.describe('ui', () => {
7071
await page.goto(pageUrl)
7172

7273
// dashboard
73-
await expect(page.locator('[aria-labelledby=tests]')).toContainText('15 Pass 2 Fail 17 Total')
74+
await expect(page.locator('[aria-labelledby=tests]')).toContainText('16 Pass 2 Fail 18 Total')
7475

7576
// unhandled errors
7677
await expect(page.getByTestId('unhandled-errors')).toContainText(
@@ -177,6 +178,27 @@ test.describe('ui', () => {
177178
await expect(annotation.getByRole('link')).toHaveAttribute('href', /__vitest_attachment__\?path=/)
178179
await expect(annotation.getByRole('img')).toHaveAttribute('src', /__vitest_attachment__\?path=/)
179180
})
181+
182+
await test.step('annotated with body', async () => {
183+
const item = page.getByLabel('annotated with body')
184+
await item.click({ force: true })
185+
await page.getByTestId('btn-report').click({ force: true })
186+
187+
const annotation = page.getByRole('note')
188+
await expect(annotation).toHaveCount(1)
189+
190+
await expect(annotation).toContainText('body annotation')
191+
await expect(annotation).toContainText('notice')
192+
await expect(annotation).toContainText('fixtures/annotated.test.ts:25:9')
193+
194+
const downloadPromise = page.waitForEvent('download')
195+
await annotation.getByRole('link').click()
196+
const download = await downloadPromise
197+
expect(download.suggestedFilename()).toBe('body-annotation.md')
198+
const downloadPath = await download.path()
199+
const content = readFileSync(downloadPath, 'utf-8')
200+
expect(content).toBe('Hello **markdown**')
201+
})
180202
})
181203

182204
test('annotations in the editor tab', async ({ page }) => {
@@ -187,16 +209,18 @@ test.describe('ui', () => {
187209
await page.getByTestId('btn-code').click({ force: true })
188210

189211
const annotations = page.getByRole('note')
190-
await expect(annotations).toHaveCount(5)
212+
await expect(annotations).toHaveCount(6)
191213

192214
await expect(annotations.first()).toHaveText('notice: hello world')
193215
await expect(annotations.nth(1)).toHaveText('notice: second annotation')
194216
await expect(annotations.nth(2)).toHaveText('warning: beware!')
195217
await expect(annotations.nth(3)).toHaveText(/notice: file annotation/)
196218
await expect(annotations.nth(4)).toHaveText('notice: image annotation')
219+
await expect(annotations.nth(5)).toHaveText(/notice: body annotation/)
197220

198-
await expect(annotations.last().getByRole('link')).toHaveAttribute('href', /__vitest_attachment__\?path=/)
199221
await expect(annotations.nth(3).getByRole('link')).toHaveAttribute('href', /__vitest_attachment__\?path=/)
222+
await expect(annotations.nth(4).getByRole('link')).toHaveAttribute('href', /__vitest_attachment__\?path=/)
223+
await expect(annotations.nth(5).getByRole('link')).toHaveAttribute('href', /^data:text\/markdown;base64,/)
200224
})
201225

202226
test('error', async ({ page }) => {

0 commit comments

Comments
 (0)