Skip to content

Commit e0dde6a

Browse files
authored
fix(ui): escape html for console log view (#4724)
1 parent 26a9fb0 commit e0dde6a

11 files changed

+74
-87
lines changed

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

+3-9
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,13 @@ import { getNames } from '@vitest/ws-client'
33
import { client, currentLogs as logs } from '~/composables/client'
44
import { isDark } from '~/composables/dark'
55
import { createAnsiToHtmlFilter } from '~/composables/error'
6+
import { escapeHtml } from "~/utils/escape"
67
78
const formattedLogs = computed(() => {
89
const data = logs.value
910
if (data) {
1011
const filter = createAnsiToHtmlFilter(isDark.value)
11-
return data.map(({ taskId, type, time, content }) => {
12-
const trimmed = content.trim()
13-
const value = filter.toHtml(trimmed)
14-
return value !== trimmed
15-
? { taskId, type, time, html: true, content: value }
16-
: { taskId, type, time, html: false, content }
17-
})
12+
return data.map(({ taskId, type, time, content }) => ({ taskId, type, time, content: filter.toHtml(escapeHtml(content)) }))
1813
}
1914
})
2015
@@ -26,13 +21,12 @@ function getTaskName(id?: string) {
2621

2722
<template>
2823
<div v-if="formattedLogs?.length" h-full class="scrolls" flex flex-col data-testid="logs">
29-
<div v-for="{ taskId, type, time, html, content } of formattedLogs" :key="taskId" font-mono>
24+
<div v-for="{ taskId, type, time, content } of formattedLogs" :key="taskId" font-mono>
3025
<ViewConsoleOutputEntry
3126
:task-name="getTaskName(taskId)"
3227
:type="type"
3328
:time="time"
3429
:content="content"
35-
:html="html"
3630
/>
3731
</div>
3832
</div>

packages/ui/client/components/views/ViewConsoleOutputEntry.cy.tsx

-18
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,8 @@ import Filter from 'ansi-to-html'
22
import ViewConsoleOutputEntry from './ViewConsoleOutputEntry.vue'
33

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

76
describe('ViewConsoleOutputEntry', () => {
8-
it('test plain entry', () => {
9-
const content = new Date().toISOString()
10-
const container = cy.mount(
11-
<ViewConsoleOutputEntry
12-
task-name="test/text"
13-
type="stderr"
14-
time={Date.now()}
15-
html={false}
16-
content={content}
17-
/>,
18-
).get(textSelector)
19-
container.should('exist')
20-
container.invoke('text').then((t) => {
21-
expect(t, 'the message has the correct message').equals(content)
22-
})
23-
})
247
it('test html entry', () => {
258
const now = new Date().toISOString()
269
const content = new Filter().toHtml(`\x1B[33m${now}\x1B[0m`)
@@ -29,7 +12,6 @@ describe('ViewConsoleOutputEntry', () => {
2912
task-name="test/html"
3013
type="stderr"
3114
time={Date.now()}
32-
html={true}
3315
content={content}
3416
/>,
3517
).get(htmlSelector)

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

+1-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ defineProps<{
66
type: UserConsoleLog['type']
77
time: UserConsoleLog['time']
88
content: UserConsoleLog['content']
9-
html: boolean
109
}>()
1110
1211
function formatTime(t: number) {
@@ -22,7 +21,6 @@ function formatTime(t: number) {
2221
>
2322
{{ formatTime(time) }} | {{ taskName }} | {{ type }}
2423
</div>
25-
<pre v-if="html" data-type="html" v-html="content" />
26-
<pre v-else data-type="text" v-text="content" />
24+
<pre data-type="html" v-html="content" />
2725
</div>
2826
</template>

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

+1-9
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ViewReportError from './ViewReportError.vue'
55
import { isDark } from '~/composables/dark'
66
import { createAnsiToHtmlFilter } from '~/composables/error'
77
import { config } from '~/composables/client'
8+
import { escapeHtml } from '~/utils/escape'
89
910
const props = defineProps<{
1011
file?: File
@@ -24,15 +25,6 @@ function collectFailed(task: Task, level: number): LeveledTask[] {
2425
return [{ ...task, level }, ...task.tasks.flatMap(t => collectFailed(t, level + 1))]
2526
}
2627
27-
function escapeHtml(unsafe: string) {
28-
return unsafe
29-
.replace(/&/g, '&amp;')
30-
.replace(/</g, '&lt;')
31-
.replace(/>/g, '&gt;')
32-
.replace(/"/g, '&quot;')
33-
.replace(/'/g, '&#039;')
34-
}
35-
3628
function createHtmlError(filter: Convert, error: ErrorWithDiff) {
3729
let htmlError = ''
3830
if (error.message?.includes('\x1B'))

packages/ui/client/utils/escape.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export function escapeHtml(unsafe: string) {
2+
return unsafe
3+
.replace(/&/g, '&amp;')
4+
.replace(/</g, '&lt;')
5+
.replace(/>/g, '&gt;')
6+
.replace(/"/g, '&quot;')
7+
.replace(/'/g, '&#39;')
8+
}

pnpm-lock.yaml

+16-45
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/ui/fixtures/console.test.ts

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/* eslint-disable no-console */
2+
3+
import { it } from "vitest";
4+
import { prettyDOM } from "@testing-library/dom"
5+
6+
// https://github.com/vitest-dev/vitest/issues/2765
7+
it('regexp', () => {
8+
console.log(/(?<char>\w)/)
9+
})
10+
11+
// https://github.com/vitest-dev/vitest/issues/3934
12+
it('html-raw', async () => {
13+
console.log(`
14+
<form>
15+
<label for="email">Email Address</label>
16+
<input name="email" />
17+
<button>Submit</button>
18+
</form>
19+
`);
20+
})
21+
22+
// https://github.com/vitest-dev/vitest/issues/1279
23+
it('html-pretty', () => {
24+
const div = document.createElement("div");
25+
div.innerHTML = `
26+
<form>
27+
<label for="email">Email Address</label>
28+
<input name="email" />
29+
<button>Submit</button>
30+
</form>
31+
`.replaceAll(/\n */gm, ""); // strip new liens
32+
console.log(prettyDOM(div))
33+
})

test/ui/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
},
99
"devDependencies": {
1010
"@playwright/test": "^1.39.0",
11-
"execa": "^6.1.0",
11+
"@testing-library/dom": "^9.3.3",
12+
"happy-dom": "latest",
1213
"vitest": "workspace:*"
1314
}
1415
}

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ test.describe('html report', () => {
3232
await page.goto(pageUrl)
3333

3434
// dashbaord
35-
await expect(page.locator('[aria-labelledby=tests]')).toContainText('1 Pass 0 Fail 1 Total')
35+
await expect(page.locator('[aria-labelledby=tests]')).toContainText('4 Pass 0 Fail 4 Total')
3636

3737
// report
3838
await page.getByText('sample.test.ts').click()

test/ui/test/ui.spec.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ test.describe('ui', () => {
2323
await page.goto(pageUrl)
2424

2525
// dashbaord
26-
await expect(page.locator('[aria-labelledby=tests]')).toContainText('1 Pass 0 Fail 1 Total')
26+
await expect(page.locator('[aria-labelledby=tests]')).toContainText('4 Pass 0 Fail 4 Total')
2727

2828
// report
2929
await page.getByText('sample.test.ts').click()
@@ -40,4 +40,11 @@ test.describe('ui', () => {
4040

4141
expect(pageErrors).toEqual([])
4242
})
43+
44+
test('console', async ({ page }) => {
45+
await page.goto(pageUrl)
46+
await page.getByText('fixtures/console.test.ts').click()
47+
await page.getByTestId('btn-console').click()
48+
await page.getByText('/(?<char>\\w)/').click()
49+
})
4350
})

test/ui/vitest.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ import { defineConfig } from 'vitest/config'
33
export default defineConfig({
44
test: {
55
dir: './fixtures',
6+
environment: 'happy-dom',
67
},
78
})

0 commit comments

Comments
 (0)