diff --git a/packages/ui/client/components/explorer/ExplorerItem.vue b/packages/ui/client/components/explorer/ExplorerItem.vue
index a9cf324be..44f797e43 100644
--- a/packages/ui/client/components/explorer/ExplorerItem.vue
+++ b/packages/ui/client/components/explorer/ExplorerItem.vue
@@ -7,8 +7,9 @@ import { client, config, isReport, runFiles, runTask } from '~/composables/clien
import { showTaskSource } from '~/composables/codemirror'
import { explorerTree } from '~/composables/explorer'
import { hasFailedSnapshot } from '~/composables/explorer/collector'
-import { escapeHtml, highlightRegex } from '~/composables/explorer/state'
+import { highlightRegex } from '~/composables/explorer/state'
import { coverageEnabled, disableCoverage } from '~/composables/navigation'
+import { escapeHtml } from '~/utils/escape'
import { getProjectBadgeStyle } from '~/utils/task'
import IconAction from '../IconAction.vue'
import IconButton from '../IconButton.vue'
@@ -124,13 +125,18 @@ const runButtonTitle = computed(() => {
: 'Run current test'
})
-const escapedName = computed(() => escapeHtml(name))
const highlighted = computed(() => {
const regex = highlightRegex.value
- const useName = escapedName.value
- return regex
- ? useName.replace(regex, (match) => `${match}`)
- : useName
+ if (!regex) {
+ return escapeHtml(name)
+ }
+ // the capture group puts every match at an odd index
+ return name
+ .split(regex)
+ .map((part, i) =>
+ i % 2 ? `${escapeHtml(part)}` : escapeHtml(part),
+ )
+ .join('')
})
const disableShowDetails = computed(() => type !== 'file' && disableTaskLocation)
diff --git a/packages/ui/client/composables/explorer/state.ts b/packages/ui/client/composables/explorer/state.ts
index b24cd73c9..e13948fac 100644
--- a/packages/ui/client/composables/explorer/state.ts
+++ b/packages/ui/client/composables/explorer/state.ts
@@ -13,6 +13,7 @@ import { availableProjects } from '~/composables/client'
import { caseInsensitiveMatch } from '~/utils/task'
// importing from the source because we need to bundle it
import { createTagsFilter } from '../../../../vitest/src/runtime/runner/utils/tags'
+import { escapeRegExp } from '../../../../vitest/src/utils/base'
import { config } from '../client'
import { explorerTree } from './index'
@@ -83,19 +84,8 @@ function createSafeFilter(query: string) {
return { matcher: () => false, error: error.message }
}
}
-const htmlEntities: Record = {
- '&': '&',
- '<': '<',
- '>': '>',
- '"': '"',
- "'": ''',
-}
-export function escapeHtml(str: string) {
- return str.replace(/[&<>"']/g, (m) => htmlEntities[m])
-}
export const highlightRegex = computed(() => {
- const searchString = search.value.toLowerCase()
- return searchString.length ? new RegExp(`(${escapeHtml(searchString)})`, 'gi') : null
+ return search.value ? new RegExp(`(${escapeRegExp(search.value)})`, 'gi') : null
})
export const isFiltered = computed(() => search.value.trim() !== '')
export const filter = reactive({
diff --git a/test/ui/test/ui.spec.ts b/test/ui/test/ui.spec.ts
index feffc0917..51cf006dd 100644
--- a/test/ui/test/ui.spec.ts
+++ b/test/ui/test/ui.spec.ts
@@ -773,6 +773,12 @@ async function testFilter(page: Page, options: { mode: 'ui' | 'static' }) {
await page.getByPlaceholder('Search...').fill('char () - Square root of nine (9)')
const testItem = getExplorerItem(page, 'char () - Square root of nine (9)')
await expect(testItem).toBeVisible()
+ await expect(testItem.locator('.highlight')).toHaveText('char () - Square root of nine (9)')
+
+ // a partial search with an unclosed parenthesis
+ await page.getByPlaceholder('Search...').fill('nine (9')
+ await expect(testItem).toBeVisible()
+ await expect(testItem.locator('.highlight')).toHaveText('nine (9')
if (options.mode === 'ui') {
await testItem.hover()
await testItem.getByLabel('Run current test').click()