Skip to content

Commit

Permalink
feat(ui): fix file error + add colored support (#1108)
Browse files Browse the repository at this point in the history
  • Loading branch information
userquin committed Apr 7, 2022
1 parent a63cfa2 commit b737476
Show file tree
Hide file tree
Showing 11 changed files with 310 additions and 23 deletions.
1 change: 1 addition & 0 deletions packages/ui/client/components.d.ts
Expand Up @@ -24,6 +24,7 @@ declare module 'vue' {
TestsEntry: typeof import('./components/dashboard/TestsEntry.vue')['default']
TestsFilesContainer: typeof import('./components/dashboard/TestsFilesContainer.vue')['default']
ViewConsoleOutput: typeof import('./components/views/ViewConsoleOutput.vue')['default']
ViewConsoleOutputEntry: typeof import('./components/views/ViewConsoleOutputEntry.vue')['default']
ViewEditor: typeof import('./components/views/ViewEditor.vue')['default']
ViewModuleGraph: typeof import('./components/views/ViewModuleGraph.vue')['default']
ViewReport: typeof import('./components/views/ViewReport.vue')['default']
Expand Down
43 changes: 24 additions & 19 deletions packages/ui/client/components/views/ViewConsoleOutput.vue
@@ -1,15 +1,22 @@
<script setup lang="ts">
import { getNames } from '@vitest/ws-client'
import { client, currentLogs as logs } from '~/composables/client'
import { isDark } from '~/composables/dark'
import { createAnsiToHtmlFilter } from '~/composables/error'
function formatTime(t: number) {
return (new Date(t)).toLocaleTimeString()
}
function formatConsoleLog(log: string) {
// TODO: support ASNI colors
return log.trim()
}
const formattedLogs = computed(() => {
const data = logs.value
if (data) {
const filter = createAnsiToHtmlFilter(isDark.value)
return data.map(({ taskId, type, time, content }) => {
const trimmed = content.trim()
const value = filter.toHtml(trimmed)
return value !== trimmed
? { taskId, type, time, html: true, content: value }
: { taskId, type, time, html: false, content }
})
}
})
function getTaskName(id?: string) {
const task = id && client.state.idMap.get(id)
Expand All @@ -18,17 +25,15 @@ function getTaskName(id?: string) {
</script>

<template>
<div v-if="logs?.length" h-full class="scrolls" flex flex-col data-testid="logs">
<div v-for="log of logs" :key="log.taskId" font-mono>
<div border="b base" p-4>
<div
text-xs mb-1
:class="log.type === 'stderr' ? 'text-red-600 dark:text-red-300': 'op30'"
>
{{ formatTime(log.time) }} | {{ getTaskName(log.taskId) }} | {{ log.type }}
</div>
<pre v-text="formatConsoleLog(log.content)" />
</div>
<div v-if="formattedLogs?.length" h-full class="scrolls" flex flex-col data-testid="logs">
<div v-for="{ taskId, type, time, html, content } of formattedLogs" :key="taskId" font-mono>
<ViewConsoleOutputEntry
:task-name="getTaskName(taskId)"
:type="type"
:time="time"
:content="content"
:html="html"
/>
</div>
</div>
<p v-else p6>
Expand Down
42 changes: 42 additions & 0 deletions packages/ui/client/components/views/ViewConsoleOutputEntry.cy.tsx
@@ -0,0 +1,42 @@
import Filter from 'ansi-to-html'
import ViewConsoleOutputEntry from './ViewConsoleOutputEntry.vue'

const htmlSelector = '[data-type=html]'
const textSelector = '[data-type=text]'

describe('ViewConsoleOutputEntry', () => {
it('test plain entry', () => {
const content = new Date().toISOString()
const container = cy.mount(
<ViewConsoleOutputEntry
task-name="test/text"
type="stderr"
time={Date.now()}
html={false}
content={content}
/>,
).get(textSelector)
container.should('exist')
container.invoke('text').then((t) => {
expect(t, 'the message has the correct message').equals(content)
})
})
it('test html entry', () => {
const now = new Date().toISOString()
const content = new Filter().toHtml(`\x1B[33m${now}\x1B[0m`)
const container = cy.mount(
<ViewConsoleOutputEntry
task-name="test/html"
type="stderr"
time={Date.now()}
html={true}
content={content}
/>,
).get(htmlSelector)
container.should('exist')
container.children('span').then((c) => {
expect(c, 'the message has the correct message').to.have.text(now)
expect(c, 'the message has the correct text color').to.have.attr('style', 'color:#A50')
})
})
})
28 changes: 28 additions & 0 deletions packages/ui/client/components/views/ViewConsoleOutputEntry.vue
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { UserConsoleLog } from '#types'
const props = defineProps<{
taskName: string
type: UserConsoleLog['type']
time: UserConsoleLog['time']
content: UserConsoleLog['content']
html: boolean
}>()
function formatTime(t: number) {
return (new Date(t)).toLocaleTimeString()
}
</script>
<template>
<div border="b base" p-4>
<div
text-xs mb-1
:class="props.type === 'stderr' ? 'text-red-600 dark:text-red-300': 'op30'"
>
{{ formatTime(props.time) }} | {{ props.taskName }} | {{ props.type }}
</div>
<pre v-if="props.html" data-type="html" v-html="props.content" />
<pre v-else data-type="text" v-text="props.content" />
</div>
</template>
120 changes: 120 additions & 0 deletions packages/ui/client/components/views/ViewReport.cy.tsx
@@ -0,0 +1,120 @@
import ViewReport from './ViewReport.vue'
import type { File } from '#types'

const taskErrorSelector = '.task-error'

describe('ViewReport', () => {
it('test plain stack trace', () => {
const file: File = {
id: 'f-1',
name: 'test/plain-stack-trace.ts',
type: 'suite',
mode: 'run',
filepath: 'test/plain-stack-trace.ts',
result: {
state: 'fail',
error: {
name: 'Do some test',
stacks: [{ line: 10, column: 20, file: 'test/plain-stack-trace.ts', method: 'dummy test' }],
message: 'Error: Transform failed with 1 error:',
},
},
tasks: [],
}
const container = cy.mount(<ViewReport file={file} />)
.get(taskErrorSelector)
container.should('exist')
container.children().then((c) => {
c.get().forEach((e, idx) => {
if (idx === 0) {
expect(e.children[0].tagName, 'error contains <b> element').equals('B')
expect(e.children[0].innerHTML, 'the <b> error element is correct').equals('Do some test')
expect(e.innerText, 'error has the correct plain text').equals('Do some test: Error: Transform failed with 1 error:')
}
else {
expect(e.children.length, 'the stack children elements is correct: stack and open in editor icon').equals(2)
expect(e.children[0].innerHTML, 'stack has the correct message').equals(' - test/plain-stack-trace.ts:10:20')
}
})
})
})
it('test html stack trace without html message', () => {
const file: File = {
id: 'f-1',
name: 'test/plain-stack-trace.ts',
type: 'suite',
mode: 'run',
filepath: 'test/plain-stack-trace.ts',
result: {
state: 'fail',
error: {
name: 'Do some test',
stack: '\x1B[33mtest/plain-stack-trace.ts\x1B[0m',
message: 'Error: Transform failed with 1 error:',
},
},
tasks: [],
}
const container = cy.mount(<ViewReport file={file} />)
.get(taskErrorSelector)
container.should('exist')
container.children('pre').then((c) => {
expect(c.text(), 'error has the correct plain text').equals('Do some test: Error: Transform failed with 1 error:test/plain-stack-trace.ts')
const children = c.children().get()
expect(children.length, 'the pre container has the correct children').equals(2)
children.forEach((e, idx) => {
if (idx === 0) {
expect(e.tagName, 'error contains <b> element').equals('B')
expect(e.innerHTML, 'the <b> error element is correct').equals('Do some test')
}
else {
expect(e.children.length, 'the stack children elements is correct').equals(0)
expect(e.innerHTML, 'stack has the correct message').equals('test/plain-stack-trace.ts')
expect(e, 'the stack has the correct text color').to.have.attr('style', 'color:#A50')
}
})
})
})
it('test html stack trace and message', () => {
const file: File = {
id: 'f-1',
name: 'test/plain-stack-trace.ts',
type: 'suite',
mode: 'run',
filepath: 'test/plain-stack-trace.ts',
result: {
state: 'fail',
error: {
name: 'Do some test',
stack: '\x1B[33mtest/plain-stack-trace.ts\x1B[0m',
message: '\x1B[44mError: Transform failed with 1 error:\x1B[0m',
},
},
tasks: [],
}
const container = cy.mount(<ViewReport file={file} />)
.get(taskErrorSelector)
container.should('exist')
container.children('pre').then((c) => {
expect(c.text(), 'error has the correct plain text').equals('Do some test: Error: Transform failed with 1 error:test/plain-stack-trace.ts')
const children = c.children().get()
expect(children.length, 'the pre container has the correct children').equals(3)
children.forEach((e, idx) => {
switch (idx) {
case 0:
expect(e.tagName, 'error contains <b> element').equals('B')
expect(e.innerHTML, 'the <b> error element is correct').equals('Do some test')
break
case 1:
expect(e.innerHTML, 'the error has the correct message').equals('Error: Transform failed with 1 error:')
expect(e, 'the error has the correct background color').to.have.attr('style', 'background-color:#00A')
break
case 2:
expect(e.children.length, 'the stack children elements is correct').equals(0)
expect(e.innerHTML, 'stack has the correct message').equals('test/plain-stack-trace.ts')
expect(e, 'the stack has the correct text color').to.have.attr('style', 'color:#A50')
}
})
})
})
})
76 changes: 72 additions & 4 deletions packages/ui/client/components/views/ViewReport.vue
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { openInEditor, shouldOpenInEditor } from '../../composables/error'
import type { File, Task } from '#types'
import type { File, Suite, Task } from '#types'
import { config } from '~/composables/client'
import { isDark } from '~/composables/dark'
import { createAnsiToHtmlFilter } from '~/composables/error'
const props = defineProps<{
file?: File
Expand All @@ -21,7 +23,63 @@ function collectFailed(task: Task, level: number): LeveledTask[] {
return [{ ...task, level }, ...task.tasks.flatMap(t => collectFailed(t, level + 1))]
}
const failed = computed(() => props.file?.tasks.flatMap(t => collectFailed(t, 0)) || [])
function escapeHtml(unsafe: string) {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
function mapLeveledTaskStacks(dark: boolean, tasks: LeveledTask[]) {
const filter = createAnsiToHtmlFilter(dark)
return tasks.map((t) => {
const result = t.result
if (result) {
const error = result.error
if (error) {
let htmlError = ''
if (error.message.includes('\x1B'))
htmlError = `<b>${error.nameStr || error.name}</b>: ${filter.toHtml(escapeHtml(error.message))}`
const startStrWithX1B = error.stackStr?.includes('\x1B')
if (startStrWithX1B || error.stack?.includes('\x1B')) {
if (htmlError.length > 0)
htmlError += filter.toHtml(escapeHtml((startStrWithX1B ? error.stackStr : error.stack) as string))
else
htmlError = `<b>${error.nameStr || error.name}</b>: ${error.message}${filter.toHtml(escapeHtml((startStrWithX1B ? error.stackStr : error.stack) as string))}`
}
if (htmlError.length > 0)
result.htmlError = htmlError
}
}
return t
})
}
const failed = computed(() => {
const file = props.file
const failedFlatMap = file?.tasks?.flatMap(t => collectFailed(t, 0)) ?? []
const result = file?.result
const fileError = result?.error
// we must check also if the test cannot compile
if (fileError) {
// create a dummy one
const fileErrorTask: Suite & { level: number } = {
id: file!.id,
name: file!.name,
level: 0,
type: 'suite',
mode: 'run',
tasks: [],
result,
}
failedFlatMap.unshift(fileErrorTask)
}
return failedFlatMap.length > 0 ? mapLeveledTaskStacks(isDark.value, failedFlatMap) : failedFlatMap
})
function relative(p: string) {
if (p.startsWith(config.value.root))
Expand All @@ -34,9 +92,19 @@ function relative(p: string) {
<div h-full class="scrolls">
<template v-if="failed.length">
<div v-for="task of failed" :key="task.id">
<div bg="red-500/10" text="red-500 sm" p="x3 y2" m-2 rounded :style="{ 'margin-left': `${2 * task.level + 0.5}rem`}">
<div
bg="red-500/10"
text="red-500 sm"
p="x3 y2"
m-2
rounded
:style="{ 'margin-left': `${task.result?.htmlError ? 0.5 : (2 * task.level + 0.5)}rem`}"
>
{{ task.name }}
<div v-if="task.result?.error" class="scrolls scrolls-rounded task-error">
<div v-if="task.result?.htmlError" class="scrolls scrolls-rounded task-error">
<pre v-html="task.result.htmlError" />
</div>
<div v-else-if="task.result?.error" class="scrolls scrolls-rounded task-error">
<pre><b>{{ task.result.error.name || task.result.error.nameStr }}</b>: {{ task.result.error.message }}</pre>
<div v-for="({ file: efile, line, column }, i) of task.result.error.stacks || []" :key="i" class="op80 flex gap-x-2 items-center">
<pre> - {{ relative(efile) }}:{{ line }}:{{ column }}</pre>
Expand Down
9 changes: 9 additions & 0 deletions packages/ui/client/composables/error.ts
@@ -1,3 +1,5 @@
import Filter from 'ansi-to-html'

export function shouldOpenInEditor(name: string, fileName?: string) {
return fileName && name.endsWith(fileName)
}
Expand All @@ -6,3 +8,10 @@ export async function openInEditor(name: string, line: number, column: number) {
const url = encodeURI(`${name}:${line}:${column}`)
await fetch(`/__open-in-editor?file=${url}`)
}

export function createAnsiToHtmlFilter(dark: boolean) {
return new Filter({
fg: dark ? '#FFF' : '#000',
bg: dark ? '#000' : '#FFF',
})
}
1 change: 1 addition & 0 deletions packages/ui/client/styles/main.css
Expand Up @@ -211,3 +211,4 @@ html.dark {
.v-popper__popper .v-popper__arrow-outer {
border-color: var(--background-color);
}

2 changes: 2 additions & 0 deletions packages/ui/package.json
Expand Up @@ -48,6 +48,7 @@
"@vitejs/plugin-vue-jsx": "^1.3.9",
"@vitest/ws-client": "workspace:*",
"@vueuse/core": "^8.2.4",
"ansi-to-html": "^0.7.2",
"birpc": "^0.2.2",
"codemirror": "^5.65.2",
"codemirror-theme-vars": "^0.1.1",
Expand All @@ -73,6 +74,7 @@
"@cypress/vue",
"@faker-js/faker",
"@vueuse/core",
"ansi-to-html",
"birpc",
"codemirror",
"codemirror/addon/display/placeholder",
Expand Down

0 comments on commit b737476

Please sign in to comment.