Skip to content

Commit c69be1f

Browse files
authored
feat(ui): show test annotations and metadata in the test report tab (#8093)
1 parent 0f33506 commit c69be1f

21 files changed

+649
-184
lines changed

packages/ui/client/auto-imports.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ declare global {
1212
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
1313
const calcExternalLabels: typeof import('./composables/module-graph')['calcExternalLabels']
1414
const codemirrorRef: typeof import('./composables/codemirror')['codemirrorRef']
15+
const columnNumber: typeof import('./composables/params')['columnNumber']
1516
const computed: typeof import('vue')['computed']
1617
const computedAsync: typeof import('@vueuse/core')['computedAsync']
1718
const computedEager: typeof import('@vueuse/core')['computedEager']
@@ -49,6 +50,7 @@ declare global {
4950
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
5051
const effectScope: typeof import('vue')['effectScope']
5152
const extendRef: typeof import('@vueuse/core')['extendRef']
53+
const getAttachmentUrl: typeof import('./composables/attachments')['getAttachmentUrl']
5254
const getCurrentBrowserIframe: typeof import('./composables/api')['getCurrentBrowserIframe']
5355
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
5456
const getCurrentScope: typeof import('vue')['getCurrentScope']
@@ -61,13 +63,16 @@ declare global {
6163
const injectLocal: typeof import('@vueuse/core')['injectLocal']
6264
const isDark: typeof import('./composables/dark')['isDark']
6365
const isDefined: typeof import('@vueuse/core')['isDefined']
66+
const isExternalAttachment: typeof import('./composables/attachments')['isExternalAttachment']
6467
const isProxy: typeof import('vue')['isProxy']
6568
const isReactive: typeof import('vue')['isReactive']
6669
const isReadonly: typeof import('vue')['isReadonly']
6770
const isRef: typeof import('vue')['isRef']
71+
const isTestFile: typeof import('./composables/error')['isTestFile']
6872
const lineNumber: typeof import('./composables/params')['lineNumber']
6973
const mainSizes: typeof import('./composables/navigation')['mainSizes']
7074
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
75+
const mapLeveledTaskStacks: typeof import('./composables/error')['mapLeveledTaskStacks']
7176
const markRaw: typeof import('vue')['markRaw']
7277
const navigateTo: typeof import('./composables/navigation')['navigateTo']
7378
const nextTick: typeof import('vue')['nextTick']
@@ -94,6 +99,7 @@ declare global {
9499
const onUpdated: typeof import('vue')['onUpdated']
95100
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
96101
const openInEditor: typeof import('./composables/error')['openInEditor']
102+
const openScreenshot: typeof import('./composables/screenshot')['openScreenshot']
97103
const openedTreeItems: typeof import('./composables/navigation')['openedTreeItems']
98104
const panels: typeof import('./composables/navigation')['panels']
99105
const params: typeof import('./composables/params')['params']
@@ -120,19 +126,23 @@ declare global {
120126
const resolveComponent: typeof import('vue')['resolveComponent']
121127
const resolveRef: typeof import('@vueuse/core')['resolveRef']
122128
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
129+
const sanitizeFilePath: typeof import('./composables/attachments')['sanitizeFilePath']
123130
const selectedTest: typeof import('./composables/params')['selectedTest']
124131
const setIframeViewport: typeof import('./composables/api')['setIframeViewport']
125132
const shallowReactive: typeof import('vue')['shallowReactive']
126133
const shallowReadonly: typeof import('vue')['shallowReadonly']
127134
const shallowRef: typeof import('vue')['shallowRef']
128135
const shouldOpenInEditor: typeof import('./composables/error')['shouldOpenInEditor']
136+
const showAnnotationSource: typeof import('./composables/codemirror')['showAnnotationSource']
129137
const showCoverage: typeof import('./composables/navigation')['showCoverage']
130138
const showDashboard: typeof import('./composables/navigation')['showDashboard']
131139
const showLine: typeof import('./composables/codemirror')['showLine']
140+
const showLocationSource: typeof import('./composables/codemirror')['showLocationSource']
132141
const showNavigationPanel: typeof import('./composables/navigation')['showNavigationPanel']
133142
const showReport: typeof import('./composables/navigation')['showReport']
134143
const showRightPanel: typeof import('./composables/navigation')['showRightPanel']
135144
const showSource: typeof import('./composables/codemirror')['showSource']
145+
const showTaskSource: typeof import('./composables/codemirror')['showTaskSource']
136146
const syncRef: typeof import('@vueuse/core')['syncRef']
137147
const syncRefs: typeof import('@vueuse/core')['syncRefs']
138148
const templateRef: typeof import('@vueuse/core')['templateRef']
@@ -279,6 +289,7 @@ declare global {
279289
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
280290
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
281291
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
292+
const useScreenshot: typeof import('./composables/screenshot')['useScreenshot']
282293
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
283294
const useScroll: typeof import('@vueuse/core')['useScroll']
284295
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']

packages/ui/client/components.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export {}
77
/* prettier-ignore */
88
declare module 'vue' {
99
export interface GlobalComponents {
10+
AnnotationAttachmentImage: typeof import('./components/AnnotationAttachmentImage.vue')['default']
1011
BrowserIframe: typeof import('./components/BrowserIframe.vue')['default']
1112
CodeMirrorContainer: typeof import('./components/CodeMirrorContainer.vue')['default']
1213
ConnectionOverlay: typeof import('./components/ConnectionOverlay.vue')['default']
@@ -38,5 +39,6 @@ declare module 'vue' {
3839
ViewModuleGraph: typeof import('./components/views/ViewModuleGraph.vue')['default']
3940
ViewReport: typeof import('./components/views/ViewReport.vue')['default']
4041
ViewReportError: typeof import('./components/views/ViewReportError.vue')['default']
42+
ViewTestReport: typeof import('./components/views/ViewTestReport.vue')['default']
4143
}
4244
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<script setup lang="ts">
2+
import type { TestAnnotation } from 'vitest'
3+
import { getAttachmentUrl, isExternalAttachment } from '~/composables/attachments'
4+
5+
const props = defineProps<{
6+
annotation: TestAnnotation
7+
}>()
8+
9+
const href = computed<string>(() => {
10+
const attachment = props.annotation.attachment!
11+
const potentialUrl = attachment.path || attachment.body
12+
if (typeof potentialUrl === 'string' && (potentialUrl.startsWith('http://') || potentialUrl.startsWith('https://'))) {
13+
return potentialUrl
14+
}
15+
else {
16+
return getAttachmentUrl(attachment)
17+
}
18+
})
19+
</script>
20+
21+
<template>
22+
<a
23+
v-if="annotation.attachment && annotation.attachment.contentType?.startsWith('image/')"
24+
target="_blank"
25+
class="inline-block mt-2"
26+
:style="{ maxWidth: '600px' }"
27+
:href="href"
28+
:referrerPolicy="isExternalAttachment(annotation.attachment) ? 'no-referrer' : undefined"
29+
>
30+
<img
31+
:src="href"
32+
>
33+
</a>
34+
</template>

packages/ui/client/components/FileDetails.vue

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script setup lang="ts">
2+
import type { RunnerTask, RunnerTestCase } from 'vitest'
23
import type { ModuleGraph } from '~/composables/module-graph'
34
import type { Params } from '~/composables/params'
45
import { toJSON } from 'flatted'
@@ -9,10 +10,11 @@ import {
910
currentLogs,
1011
isReport,
1112
} from '~/composables/client'
13+
import { explorerTree } from '~/composables/explorer'
1214
import { hasFailedSnapshot } from '~/composables/explorer/collector'
1315
import { getModuleGraph } from '~/composables/module-graph'
1416
import { viewMode } from '~/composables/params'
15-
import { getProjectNameColor } from '~/utils/task'
17+
import { getProjectNameColor, getProjectTextColor } from '~/utils/task'
1618
1719
const graph = ref<ModuleGraph>({ nodes: [], links: [] })
1820
const draft = ref(false)
@@ -21,6 +23,12 @@ const loadingModuleGraph = ref(false)
2123
const currentFilepath = ref<string | undefined>(undefined)
2224
const hideNodeModules = ref(true)
2325
26+
const test = computed(() => {
27+
return selectedTest.value
28+
? client.state.idMap.get(selectedTest.value) as RunnerTestCase
29+
: undefined
30+
})
31+
2432
const graphData = computed(() => {
2533
const c = current.value
2634
if (!c || !c.filepath) {
@@ -137,18 +145,26 @@ debouncedWatch(
137145
)
138146
139147
const projectNameColor = computed(() => {
140-
return getProjectNameColor(current.value?.file.projectName)
148+
const projectName = current.value?.file.projectName || ''
149+
return explorerTree.colors.get(projectName) || getProjectNameColor(current.value?.file.projectName)
141150
})
142151
143-
const projectNameTextColor = computed(() => {
144-
switch (projectNameColor.value) {
145-
case 'blue':
146-
case 'green':
147-
case 'magenta':
148-
return 'white'
149-
default:
150-
return 'black'
152+
const projectNameTextColor = computed(() => getProjectTextColor(projectNameColor.value))
153+
154+
const testTitle = computed(() => {
155+
const testId = selectedTest.value
156+
if (!testId) {
157+
return current.value?.name
158+
}
159+
const names: string[] = []
160+
let node: RunnerTask | undefined = client.state.idMap.get(testId)
161+
while (node) {
162+
names.push(node.name)
163+
node = node.suite
164+
? node.suite
165+
: (node === node.file ? undefined : node.file)
151166
}
167+
return names.reverse().join(' > ')
152168
})
153169
</script>
154170

@@ -174,7 +190,7 @@ const projectNameTextColor = computed(() => {
174190
{{ current.file.projectName }}
175191
</span>
176192
<div flex-1 font-light op-50 ws-nowrap truncate text-sm>
177-
{{ current?.name }}
193+
{{ testTitle }}
178194
</div>
179195
<div class="flex text-lg">
180196
<IconButton
@@ -263,7 +279,8 @@ const projectNameTextColor = computed(() => {
263279
:file="current"
264280
data-testid="console"
265281
/>
266-
<ViewReport v-else-if="!viewMode" :file="current" data-testid="report" />
282+
<ViewReport v-else-if="!viewMode && !test && current" :file="current" data-testid="report" />
283+
<ViewTestReport v-else-if="!viewMode && test" :test="test" data-testid="report" />
267284
</div>
268285
</div>
269286
</template>

packages/ui/client/components/explorer/ExplorerItem.vue

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import type { TaskTreeNodeType } from '~/composables/explorer/types'
44
import { Tooltip as VueTooltip } from 'floating-vue'
55
import { nextTick } from 'vue'
66
import { client, isReport, runFiles, runTask } from '~/composables/client'
7-
import { showSource } from '~/composables/codemirror'
7+
import { showTaskSource } from '~/composables/codemirror'
88
import { explorerTree } from '~/composables/explorer'
99
import { hasFailedSnapshot } from '~/composables/explorer/collector'
1010
import { escapeHtml, highlightRegex } from '~/composables/explorer/state'
1111
import { coverageEnabled } from '~/composables/navigation'
12+
import { getProjectTextColor } from '~/utils/task'
1213
1314
// TODO: better handling of "opened" - it means to forcefully open the tree item and set in TasksList right now
1415
const {
@@ -149,26 +150,11 @@ function showDetails() {
149150
onItemClick?.(t)
150151
}
151152
else {
152-
showSource(t)
153+
showTaskSource(t)
153154
}
154155
}
155156
156-
const projectNameTextColor = computed(() => {
157-
switch (projectNameColor) {
158-
case 'blue':
159-
case 'green':
160-
case 'magenta':
161-
case 'black':
162-
case 'red':
163-
return 'white'
164-
165-
case 'yellow':
166-
case 'cyan':
167-
case 'white':
168-
default:
169-
return 'black'
170-
}
171-
})
157+
const projectNameTextColor = computed(() => getProjectTextColor(projectNameColor))
172158
</script>
173159

174160
<template>
@@ -223,7 +209,7 @@ const projectNameTextColor = computed(() => {
223209
>
224210
<IconButton
225211
data-testid="btn-open-details"
226-
icon="i-carbon:intrusion-prevention"
212+
:icon="type === 'file' ? 'i-carbon:intrusion-prevention' : 'i-carbon:code-reference'"
227213
@click.prevent.stop="showDetails"
228214
/>
229215
<template #popper>

packages/ui/client/components/views/ViewEditor.vue

Lines changed: 9 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
<script setup lang="ts">
2-
import type { Task, TestAttachment } from '@vitest/runner'
2+
import type { Task } from '@vitest/runner'
33
import type CodeMirror from 'codemirror'
44
import type { ErrorWithDiff, File, TestAnnotation, TestError } from 'vitest'
55
import { createTooltip, destroyTooltip } from 'floating-vue'
6+
import { getAttachmentUrl, sanitizeFilePath } from '~/composables/attachments'
67
import { client, isReport } from '~/composables/client'
78
import { finished } from '~/composables/client/state'
89
import { codemirrorRef } from '~/composables/codemirror'
910
import { openInEditor } from '~/composables/error'
10-
import { lineNumber } from '~/composables/params'
11+
import { columnNumber, lineNumber } from '~/composables/params'
1112
1213
const props = defineProps<{
1314
file?: File
@@ -56,12 +57,12 @@ watch(
5657
{ immediate: true },
5758
)
5859
59-
watch(() => [loading.value, saving.value, props.file, lineNumber.value] as const, ([loadingFile, s, _, l]) => {
60+
watch(() => [loading.value, saving.value, props.file, lineNumber.value, columnNumber.value] as const, ([loadingFile, s, _, l, c]) => {
6061
if (!loadingFile && !s) {
6162
if (l != null) {
6263
nextTick(() => {
6364
const cp = currentPosition.value
64-
const line = cp ?? { line: l ?? 0, ch: 0 }
65+
const line = cp ?? { line: (l ?? 1) - 1, ch: c ?? 0 }
6566
// restore caret position: the watchDebounced below will use old value
6667
if (cp) {
6768
currentPosition.value = undefined
@@ -155,7 +156,7 @@ function createErrorElement(e: ErrorWithDiff) {
155156
div.className = 'op80 flex gap-x-2 items-center'
156157
const pre = document.createElement('pre')
157158
pre.className = 'c-red-600 dark:c-red-400'
158-
pre.textContent = `${' '.repeat(stack.column)}^ ${e?.nameStr || e.name}: ${
159+
pre.textContent = `${' '.repeat(stack.column)}^ ${e.name}: ${
159160
e?.message || ''
160161
}`
161162
div.appendChild(pre)
@@ -184,7 +185,6 @@ function createErrorElement(e: ErrorWithDiff) {
184185
185186
function createAnnotationElement(annotation: TestAnnotation) {
186187
if (!annotation.location) {
187-
// TODO(v4): print unknown annotations somewhere
188188
return
189189
}
190190
@@ -222,9 +222,8 @@ function createAnnotationElement(annotation: TestAnnotation) {
222222
if (attachment.contentType?.startsWith('image/')) {
223223
const link = document.createElement('a')
224224
const img = document.createElement('img')
225-
img.classList.add('mt-3', 'inline-block')
226-
img.width = 600
227-
img.width = 400
225+
link.classList.add('inline-block', 'mt-3')
226+
link.style.maxWidth = '50vw'
228227
const potentialUrl = attachment.path || attachment.body
229228
if (typeof potentialUrl === 'string' && (potentialUrl.startsWith('http://') || potentialUrl.startsWith('https://'))) {
230229
img.setAttribute('src', potentialUrl)
@@ -241,7 +240,7 @@ function createAnnotationElement(annotation: TestAnnotation) {
241240
else {
242241
const download = document.createElement('a')
243242
download.href = getAttachmentUrl(attachment)
244-
download.download = sanitizeFilePath(annotation.message)
243+
download.download = sanitizeFilePath(annotation.message, attachment.contentType)
245244
download.classList.add('flex', 'w-min', 'gap-2', 'items-center', 'font-sans', 'underline', 'cursor-pointer')
246245
const icon = document.createElement('div')
247246
icon.classList.add('i-carbon:download', 'block')
@@ -254,19 +253,6 @@ function createAnnotationElement(annotation: TestAnnotation) {
254253
widgets.push(codemirrorRef.value!.addLineWidget(line - 1, notice))
255254
}
256255
257-
function getAttachmentUrl(attachment: TestAttachment) {
258-
// html reporter always saves files into /data/ folder
259-
if (isReport) {
260-
return `/data/${attachment.path}`
261-
}
262-
const contentType = attachment.contentType ?? 'application/octet-stream'
263-
if (attachment.path) {
264-
return `/__vitest_attachment__?path=${encodeURIComponent(attachment.path)}&contentType=${contentType}&token=${(window as any).VITEST_API_TOKEN}`
265-
}
266-
// attachment.body is always a string outside of the test frame
267-
return `data:${contentType};base64,${attachment.body}`
268-
}
269-
270256
const { pause, resume } = watch(
271257
[codemirrorRef, errors, annotations, finished] as const,
272258
([cmValue, errors, annotations, end]) => {
@@ -389,11 +375,6 @@ async function onSave(content: string) {
389375
390376
// we need to remove listeners before unmounting the component: the watcher will not be called
391377
onBeforeUnmount(clearListeners)
392-
393-
function sanitizeFilePath(s: string): string {
394-
// eslint-disable-next-line no-control-regex
395-
return s.replace(/[\x00-\x2C\x2E\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-')
396-
}
397378
</script>
398379

399380
<template>

0 commit comments

Comments
 (0)