[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat: add browser frame to UI (#5808)

authored by

Vladimir and committed by
GitHub
(Jun 1, 2024, 1:33 PM +0200) 3796dd7e 7900f9f8

+408 -186
+9
packages/ui/types.ts
··· 1 + import type { ResolvedConfig } from 'vitest' 2 + 1 3 export interface WSMessage { 2 4 /** 3 5 * Message type ··· 11 13 } 12 14 13 15 export type RunState = 'idle' | 'running' 16 + 17 + export interface BrowserRunnerState { 18 + files: string[] 19 + config: ResolvedConfig 20 + type: 'orchestrator' 21 + wrapModule: <T>(module: () => T) => T 22 + }
+1
packages/ui/client/components.d.ts
··· 7 7 /* prettier-ignore */ 8 8 declare module 'vue' { 9 9 export interface GlobalComponents { 10 + BrowserIframe: typeof import('./components/BrowserIframe.vue')['default'] 10 11 CodeMirror: typeof import('./components/CodeMirror.vue')['default'] 11 12 ConnectionOverlay: typeof import('./components/ConnectionOverlay.vue')['default'] 12 13 Coverage: typeof import('./components/Coverage.vue')['default']
+1
test/browser/test/dom.test.ts
··· 3 3 4 4 test('renders div', () => { 5 5 const div = createNode() 6 + document.body.style.background = '#f3f3f3' 6 7 expect(div.textContent).toBe('Hello World') 7 8 })
-32
packages/browser/src/client/index.html
··· 1 - <!DOCTYPE html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8" /> 5 - <link rel="icon" href="{__VITEST_FAVICON__}" type="image/svg+xml"> 6 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 - <title>Vitest Browser Runner</title> 8 - <style> 9 - html { 10 - overflow: hidden; 11 - padding: 0; 12 - margin: 0; 13 - } 14 - body { 15 - padding: 0; 16 - margin: 0; 17 - } 18 - #vitest-ui { 19 - width: 100vw; 20 - height: 100vh; 21 - border: none; 22 - } 23 - </style> 24 - <script>{__VITEST_INJECTOR__}</script> 25 - {__VITEST_SCRIPTS__} 26 - </head> 27 - <body> 28 - {__VITEST_UI__} 29 - <script type="module" src="/main.ts"></script> 30 - <div id="vitest-tester"></div> 31 - </body> 32 - </html>
-131
packages/browser/src/client/main.ts
··· 1 - import { channel, client } from './client' 2 - import { rpcDone } from './rpc' 3 - import { getBrowserState, getConfig } from './utils' 4 - 5 - const url = new URL(location.href) 6 - 7 - const ID_ALL = '__vitest_all__' 8 - 9 - const iframes = new Map<string, HTMLIFrameElement>() 10 - 11 - function debug(...args: unknown[]) { 12 - const debug = getConfig().env.VITEST_BROWSER_DEBUG 13 - if (debug && debug !== 'false') 14 - client.rpc.debug(...args.map(String)) 15 - } 16 - 17 - function createIframe(container: HTMLDivElement, file: string) { 18 - if (iframes.has(file)) { 19 - container.removeChild(iframes.get(file)!) 20 - iframes.delete(file) 21 - } 22 - 23 - const iframe = document.createElement('iframe') 24 - iframe.setAttribute('loading', 'eager') 25 - iframe.setAttribute('src', `${url.pathname}__vitest_test__/__test__/${encodeURIComponent(file)}`) 26 - iframes.set(file, iframe) 27 - container.appendChild(iframe) 28 - return iframe 29 - } 30 - 31 - async function done() { 32 - await rpcDone() 33 - await client.rpc.finishBrowserTests() 34 - } 35 - 36 - interface IframeDoneEvent { 37 - type: 'done' 38 - filenames: string[] 39 - } 40 - 41 - interface IframeErrorEvent { 42 - type: 'error' 43 - error: any 44 - errorType: string 45 - files: string[] 46 - } 47 - 48 - type IframeChannelEvent = IframeDoneEvent | IframeErrorEvent 49 - 50 - client.ws.addEventListener('open', async () => { 51 - const config = getConfig() 52 - const container = document.querySelector('#vitest-tester') as HTMLDivElement 53 - const testFiles = getBrowserState().files 54 - 55 - debug('test files', testFiles.join(', ')) 56 - 57 - // TODO: fail tests suite because no tests found? 58 - if (!testFiles.length) { 59 - await done() 60 - return 61 - } 62 - 63 - const runningFiles = new Set<string>(testFiles) 64 - 65 - channel.addEventListener('message', async (e: MessageEvent<IframeChannelEvent>): Promise<void> => { 66 - debug('channel event', JSON.stringify(e.data)) 67 - switch (e.data.type) { 68 - case 'done': { 69 - const filenames = e.data.filenames 70 - filenames.forEach(filename => runningFiles.delete(filename)) 71 - 72 - if (!runningFiles.size) { 73 - await done() 74 - } 75 - else { 76 - // keep the last iframe 77 - const iframeId = filenames.length > 1 ? ID_ALL : filenames[0] 78 - iframes.get(iframeId)?.remove() 79 - iframes.delete(iframeId) 80 - } 81 - break 82 - } 83 - // error happened at the top level, this should never happen in user code, but it can trigger during development 84 - case 'error': { 85 - const iframeId = e.data.files.length > 1 ? ID_ALL : e.data.files[0] 86 - iframes.delete(iframeId) 87 - await client.rpc.onUnhandledError(e.data.error, e.data.errorType) 88 - if (iframeId === ID_ALL) 89 - runningFiles.clear() 90 - else 91 - runningFiles.delete(iframeId) 92 - if (!runningFiles.size) 93 - await done() 94 - break 95 - } 96 - default: { 97 - await client.rpc.onUnhandledError({ 98 - name: 'Unexpected Event', 99 - message: `Unexpected event: ${(e.data as any).type}`, 100 - }, 'Unexpected Event') 101 - await done() 102 - } 103 - } 104 - }) 105 - 106 - if (config.isolate === false) { 107 - createIframe( 108 - container, 109 - ID_ALL, 110 - ) 111 - } 112 - else { 113 - // otherwise, we need to wait for each iframe to finish before creating the next one 114 - // this is the most stable way to run tests in the browser 115 - for (const file of testFiles) { 116 - createIframe( 117 - container, 118 - file, 119 - ) 120 - await new Promise<void>((resolve) => { 121 - channel.addEventListener('message', function handler(e: MessageEvent<IframeChannelEvent>) { 122 - // done and error can only be triggered by the previous iframe 123 - if (e.data.type === 'done' || e.data.type === 'error') { 124 - channel.removeEventListener('message', handler) 125 - resolve() 126 - } 127 - }) 128 - }) 129 - } 130 - } 131 - })
+33
packages/browser/src/client/orchestrator.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <link rel="icon" href="{__VITEST_FAVICON__}" type="image/svg+xml"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <title>Vitest Browser Runner</title> 8 + <style> 9 + html { 10 + overflow: hidden; 11 + padding: 0; 12 + margin: 0; 13 + } 14 + body { 15 + padding: 0; 16 + margin: 0; 17 + } 18 + html, 19 + body, 20 + iframe[data-vitest], 21 + #vitest-tester { 22 + width: 100%; 23 + height: 100%; 24 + } 25 + </style> 26 + <script>{__VITEST_INJECTOR__}</script> 27 + {__VITEST_SCRIPTS__} 28 + </head> 29 + <body> 30 + <script type="module" src="/orchestrator.ts"></script> 31 + <div id="vitest-tester"></div> 32 + </body> 33 + </html>
+184
packages/browser/src/client/orchestrator.ts
··· 1 + import type { ResolvedConfig } from 'vitest' 2 + import { generateHash } from '@vitest/runner/utils' 3 + import { relative } from 'pathe' 4 + import { channel, client } from './client' 5 + import { rpcDone } from './rpc' 6 + import { getBrowserState, getConfig } from './utils' 7 + import { getUiAPI } from './ui' 8 + 9 + const url = new URL(location.href) 10 + 11 + const ID_ALL = '__vitest_all__' 12 + 13 + const iframes = new Map<string, HTMLIFrameElement>() 14 + 15 + function debug(...args: unknown[]) { 16 + const debug = getConfig().env.VITEST_BROWSER_DEBUG 17 + if (debug && debug !== 'false') 18 + client.rpc.debug(...args.map(String)) 19 + } 20 + 21 + function createIframe(container: HTMLDivElement, file: string) { 22 + if (iframes.has(file)) { 23 + container.removeChild(iframes.get(file)!) 24 + iframes.delete(file) 25 + } 26 + 27 + const iframe = document.createElement('iframe') 28 + iframe.setAttribute('loading', 'eager') 29 + iframe.setAttribute('src', `${url.pathname}__vitest_test__/__test__/${encodeURIComponent(file)}`) 30 + iframe.setAttribute('data-vitest', 'true') 31 + 32 + iframe.style.display = 'block' 33 + iframe.style.border = 'none' 34 + iframe.style.pointerEvents = 'none' 35 + iframe.setAttribute('allowfullscreen', 'true') 36 + iframe.setAttribute('allow', 'clipboard-write;') 37 + 38 + iframes.set(file, iframe) 39 + container.appendChild(iframe) 40 + return iframe 41 + } 42 + 43 + async function done() { 44 + await rpcDone() 45 + await client.rpc.finishBrowserTests() 46 + } 47 + 48 + interface IframeDoneEvent { 49 + type: 'done' 50 + filenames: string[] 51 + } 52 + 53 + interface IframeErrorEvent { 54 + type: 'error' 55 + error: any 56 + errorType: string 57 + files: string[] 58 + } 59 + 60 + type IframeChannelEvent = IframeDoneEvent | IframeErrorEvent 61 + 62 + async function getContainer(config: ResolvedConfig): Promise<HTMLDivElement> { 63 + if (config.browser.ui) { 64 + const element = document.querySelector('#tester-ui') 65 + if (!element) { 66 + return new Promise<HTMLDivElement>((resolve) => { 67 + setTimeout(() => { 68 + resolve(getContainer(config)) 69 + }, 30) 70 + }) 71 + } 72 + return element as HTMLDivElement 73 + } 74 + return document.querySelector('#vitest-tester') as HTMLDivElement 75 + } 76 + 77 + client.ws.addEventListener('open', async () => { 78 + const config = getConfig() 79 + const testFiles = getBrowserState().files 80 + 81 + debug('test files', testFiles.join(', ')) 82 + 83 + // TODO: fail tests suite because no tests found? 84 + if (!testFiles.length) { 85 + await done() 86 + return 87 + } 88 + 89 + const container = await getContainer(config) 90 + const runningFiles = new Set<string>(testFiles) 91 + 92 + channel.addEventListener('message', async (e: MessageEvent<IframeChannelEvent>): Promise<void> => { 93 + debug('channel event', JSON.stringify(e.data)) 94 + switch (e.data.type) { 95 + case 'done': { 96 + const filenames = e.data.filenames 97 + filenames.forEach(filename => runningFiles.delete(filename)) 98 + 99 + if (!runningFiles.size) { 100 + const ui = getUiAPI() 101 + // in isolated mode we don't change UI because it will slow down tests, 102 + // so we only select it when the run is done 103 + if (ui && filenames.length > 1) { 104 + const id = generateFileId(filenames[filenames.length - 1]) 105 + ui.setCurrentById(id) 106 + } 107 + await done() 108 + } 109 + else { 110 + // keep the last iframe 111 + const iframeId = filenames.length > 1 ? ID_ALL : filenames[0] 112 + iframes.get(iframeId)?.remove() 113 + iframes.delete(iframeId) 114 + } 115 + break 116 + } 117 + // error happened at the top level, this should never happen in user code, but it can trigger during development 118 + case 'error': { 119 + const iframeId = e.data.files.length > 1 ? ID_ALL : e.data.files[0] 120 + iframes.delete(iframeId) 121 + await client.rpc.onUnhandledError(e.data.error, e.data.errorType) 122 + if (iframeId === ID_ALL) 123 + runningFiles.clear() 124 + else 125 + runningFiles.delete(iframeId) 126 + if (!runningFiles.size) 127 + await done() 128 + break 129 + } 130 + default: { 131 + await client.rpc.onUnhandledError({ 132 + name: 'Unexpected Event', 133 + message: `Unexpected event: ${(e.data as any).type}`, 134 + }, 'Unexpected Event') 135 + await done() 136 + } 137 + } 138 + }) 139 + 140 + if (config.browser.ui) { 141 + container.className = '' 142 + container.textContent = '' 143 + } 144 + 145 + if (config.isolate === false) { 146 + createIframe( 147 + container, 148 + ID_ALL, 149 + ) 150 + } 151 + else { 152 + // otherwise, we need to wait for each iframe to finish before creating the next one 153 + // this is the most stable way to run tests in the browser 154 + for (const file of testFiles) { 155 + const ui = getUiAPI() 156 + 157 + if (ui) { 158 + const id = generateFileId(file) 159 + ui.setCurrentById(id) 160 + } 161 + 162 + createIframe( 163 + container, 164 + file, 165 + ) 166 + await new Promise<void>((resolve) => { 167 + channel.addEventListener('message', function handler(e: MessageEvent<IframeChannelEvent>) { 168 + // done and error can only be triggered by the previous iframe 169 + if (e.data.type === 'done' || e.data.type === 'error') { 170 + channel.removeEventListener('message', handler) 171 + resolve() 172 + } 173 + }) 174 + }) 175 + } 176 + } 177 + }) 178 + 179 + function generateFileId(file: string) { 180 + const config = getConfig() 181 + const project = config.name || '' 182 + const path = relative(config.root, file) 183 + return generateHash(`${path}${project}`) 184 + }
+2 -1
packages/browser/src/client/tester.html
··· 13 13 body { 14 14 padding: 0; 15 15 margin: 0; 16 + min-height: 100vh; 16 17 } 17 18 </style> 18 19 <script>{__VITEST_INJECTOR__}</script> 19 20 {__VITEST_SCRIPTS__} 20 21 </head> 21 - <body> 22 + <body style="width: 100%; height: 100%; transform: scale(1); transform-origin: left top;"> 22 23 <script type="module" src="/tester.ts"></script> 23 24 {__VITEST_APPEND__} 24 25 </body>
+11
packages/browser/src/client/ui.ts
··· 1 + import type { File } from '@vitest/runner' 2 + 3 + interface UiAPI { 4 + currentModule: File 5 + setCurrentById: (fileId: string) => void 6 + } 7 + 8 + export function getUiAPI(): UiAPI | undefined { 9 + // @ts-expect-error not typed global 10 + return window.__vitest_ui_api__ 11 + }
+2 -1
packages/browser/src/client/vite.config.ts
··· 13 13 outDir: '../../dist/client', 14 14 emptyOutDir: false, 15 15 assetsDir: '__vitest_browser__', 16 + manifest: true, 16 17 rollupOptions: { 17 18 input: { 18 - main: resolve(__dirname, './index.html'), 19 + orchestrator: resolve(__dirname, './orchestrator.html'), 19 20 tester: resolve(__dirname, './tester.html'), 20 21 }, 21 22 },
+23 -5
packages/browser/src/node/index.ts
··· 31 31 }, 32 32 async configureServer(server) { 33 33 const testerHtml = readFile(resolve(distRoot, 'client/tester.html'), 'utf8') 34 - const runnerHtml = readFile(resolve(distRoot, 'client/index.html'), 'utf8') 34 + const orchestratorHtml = project.config.browser.ui 35 + ? readFile(resolve(distRoot, 'client/__vitest__/index.html'), 'utf8') 36 + : readFile(resolve(distRoot, 'client/orchestrator.html'), 'utf8') 35 37 const injectorJs = readFile(resolve(distRoot, 'client/esm-client-injector.js'), 'utf8') 38 + const manifest = (async () => { 39 + return JSON.parse(await readFile(`${distRoot}/client/.vite/manifest.json`, 'utf8')) 40 + })() 36 41 const favicon = `${base}favicon.svg` 37 42 const testerPrefix = `${base}__vitest_test__/__test__/` 38 43 server.middlewares.use((_req, res, next) => { ··· 71 76 if (!indexScripts) 72 77 indexScripts = await formatScripts(project.config.browser.indexScripts, server) 73 78 74 - const html = replacer(await runnerHtml, { 79 + let baseHtml = await orchestratorHtml 80 + 81 + // if UI is enabled, use UI HTML and inject the orchestrator script 82 + if (project.config.browser.ui) { 83 + const manifestContent = await manifest 84 + const jsEntry = manifestContent['orchestrator.html'].file 85 + baseHtml = baseHtml.replaceAll('./assets/', `${base}__vitest__/assets/`).replace( 86 + '<!-- !LOAD_METADATA! -->', 87 + [ 88 + '<script>{__VITEST_INJECTOR__}</script>', 89 + '{__VITEST_SCRIPTS__}', 90 + `<script type="module" crossorigin src="${jsEntry}"></script>`, 91 + ].join('\n'), 92 + ) 93 + } 94 + 95 + const html = replacer(baseHtml, { 75 96 __VITEST_FAVICON__: favicon, 76 97 __VITEST_TITLE__: 'Vitest Browser Runner', 77 98 __VITEST_SCRIPTS__: indexScripts, 78 99 __VITEST_INJECTOR__: injector, 79 - __VITEST_UI__: project.config.browser.ui 80 - ? '<iframe id="vitest-ui" src="/__vitest__/"></iframe>' 81 - : '', 82 100 }) 83 101 res.write(html, 'utf-8') 84 102 res.end()
+92
packages/ui/client/components/BrowserIframe.vue
··· 1 + <script setup lang="ts"> 2 + const viewport = ref('custom') 3 + 4 + function changeViewport(name: string) { 5 + if (viewport.value === name) { 6 + viewport.value = 'custom' 7 + } else { 8 + viewport.value = name 9 + } 10 + } 11 + </script> 12 + 13 + <template> 14 + <div h="full" flex="~ col"> 15 + <div 16 + p="3" 17 + h-10 18 + flex="~ gap-2" 19 + items-center 20 + bg-header 21 + border="b base" 22 + > 23 + <div class="i-carbon-content-delivery-network" /> 24 + <span 25 + pl-1 26 + font-bold 27 + text-sm 28 + flex-auto 29 + ws-nowrap 30 + overflow-hidden 31 + truncate 32 + >Browser UI</span> 33 + </div> 34 + <div 35 + p="l3 y2 r2" 36 + flex="~ gap-2" 37 + items-center 38 + bg-header 39 + border="b-2 base" 40 + > 41 + <!-- TODO: these are only for preview (thank you Storybook!), we need to support more different and custom sizes (as a dropdown) --> 42 + <IconButton 43 + v-tooltip.bottom="'Small mobile'" 44 + title="Small mobile" 45 + icon="i-carbon:mobile" 46 + :active="viewport === 'small-mobile'" 47 + @click="changeViewport('small-mobile')" 48 + /> 49 + <IconButton 50 + v-tooltip.bottom="'Large mobile'" 51 + title="Large mobile" 52 + icon="i-carbon:mobile-add" 53 + :active="viewport === 'large-mobile'" 54 + @click="changeViewport('large-mobile')" 55 + /> 56 + <IconButton 57 + v-tooltip.bottom="'Tablet'" 58 + title="Tablet" 59 + icon="i-carbon:tablet" 60 + :active="viewport === 'tablet'" 61 + @click="changeViewport('tablet')" 62 + /> 63 + </div> 64 + <div flex-auto overflow-auto> 65 + <div id="tester-ui" class="flex h-full justify-center items-center font-light op70" style="overflow: auto; width: 100%; height: 100%" :data-viewport="viewport"> 66 + Select a test to run 67 + </div> 68 + </div> 69 + </div> 70 + </template> 71 + 72 + <style> 73 + [data-viewport="custom"] iframe { 74 + width: 100%; 75 + height: 100%; 76 + } 77 + 78 + [data-viewport="small-mobile"] iframe { 79 + width: 320px; 80 + height: 568px; 81 + } 82 + 83 + [data-viewport="large-mobile"] iframe { 84 + width: 414px; 85 + height: 896px; 86 + } 87 + 88 + [data-viewport="tablet"] iframe { 89 + width: 834px; 90 + height: 1112px; 91 + } 92 + </style>
+2 -2
packages/ui/client/components/ConnectionOverlay.vue
··· 1 1 <script setup lang="ts"> 2 - import { client, isConnected, isConnecting } from '~/composables/client' 2 + import { client, isConnected, isConnecting, browserState } from '~/composables/client' 3 3 </script> 4 4 5 5 <template> ··· 27 27 {{ isConnecting ? 'Connecting...' : 'Disconnected' }} 28 28 </div> 29 29 <div text-lg op50> 30 - Check your terminal or start a new server with `vitest --ui` 30 + Check your terminal or start a new server with `{{ browserState ? `vitest --browser=${browserState.config.browser.name}` : 'vitest --ui' }}` 31 31 </div> 32 32 </div> 33 33 </div>
+3 -3
packages/ui/client/components/FileDetails.vue
··· 1 1 <script setup lang="ts"> 2 2 import type { ModuleGraphData } from 'vitest' 3 - import { client, current, currentLogs, isReport } from '~/composables/client' 3 + import { client, current, currentLogs, isReport, browserState } from '~/composables/client' 4 4 import type { Params } from '~/composables/params' 5 5 import { viewMode } from '~/composables/params' 6 6 import type { ModuleGraph } from '~/composables/module-graph' ··· 17 17 async (c, o) => { 18 18 if (c && c.filepath !== o?.filepath) { 19 19 const project = c.file.projectName || '' 20 - data.value = await client.rpc.getModuleGraph(project, c.filepath) 20 + data.value = await client.rpc.getModuleGraph(project, c.filepath, !!browserState) 21 21 graph.value = getModuleGraph(data.value, c.filepath) 22 22 } 23 23 }, ··· 50 50 <div> 51 51 <div p="2" h-10 flex="~ gap-2" items-center bg-header border="b base"> 52 52 <StatusIcon :task="current" /> 53 - <div font-light op-50 text-sm :style="{ color: getProjectNameColor(current?.file.projectName) }"> 53 + <div v-if="current?.file.projectName" font-light op-50 text-sm :style="{ color: getProjectNameColor(current?.file.projectName) }"> 54 54 [{{ current?.file.projectName || '' }}] 55 55 </div> 56 56 <div flex-1 font-light op-50 ws-nowrap truncate text-sm>
+3 -3
packages/ui/client/components/IconButton.vue
··· 1 1 <script setup lang="ts"> 2 - defineProps<{ icon?: `i-${string}` | `dark:i-${string}`; title?: string; disabled?: boolean }>() 2 + defineProps<{ icon?: `i-${string}` | `dark:i-${string}`; title?: string; disabled?: boolean; active?: boolean }>() 3 3 </script> 4 4 5 5 <template> ··· 9 9 :opacity="disabled ? 10 : 70" 10 10 rounded 11 11 :disabled="disabled" 12 - :hover="disabled ? '' : 'bg-active op100'" 13 - class="w-1.4em h-1.4em flex" 12 + :hover="disabled || active ? '' : 'bg-active op100'" 13 + :class="['w-1.4em h-1.4em flex', { 'bg-gray-500:35 op100': active }]" 14 14 > 15 15 <slot> 16 16 <div :class="icon" ma />
+12
packages/ui/client/composables/navigation.ts
··· 12 12 return coverageConfigured.value 13 13 && coverage.value.reporter.map(([reporterName]) => reporterName).includes('html') 14 14 }) 15 + 16 + // @ts-expect-error not typed global 17 + window.__vitest_ui_api__ = { 18 + get currentModule() { 19 + return currentModule.value 20 + }, 21 + setCurrentById(fileId: string) { 22 + activeFileId.value = fileId 23 + currentModule.value = findById(fileId) 24 + showDashboard(false) 25 + }, 26 + } 15 27 export const openedTreeItems = useLocalStorage<string[]>('vitest-ui_task-tree-opened', []) 16 28 // TODO 17 29 // For html report preview, "coverage.reportsDirectory" must be explicitly set as a subdirectory of html report.
+14 -1
packages/ui/client/pages/index.vue
··· 1 1 <script setup lang="ts"> 2 2 // @ts-expect-error missing types 3 3 import { Pane, Splitpanes } from 'splitpanes' 4 + import { browserState } from '~/composables/client'; 4 5 import { coverageUrl, coverageVisible, initializeNavigation } from '../composables/navigation' 5 6 6 7 const dashboardVisible = initializeNavigation() ··· 41 42 <Navigation /> 42 43 </Pane> 43 44 <Pane :size="mainSizes[1]"> 44 - <transition> 45 + <transition v-if="!browserState"> 45 46 <Dashboard v-if="dashboardVisible" key="summary" /> 46 47 <Coverage v-else-if="coverageVisible" key="coverage" :src="coverageUrl" /> 47 48 <FileDetails v-else /> 49 + </transition> 50 + <transition v-else> 51 + <Splitpanes key="detail" @resized="onModuleResized"> 52 + <Pane :size="detailSizes[0]"> 53 + <BrowserIframe /> 54 + </Pane> 55 + <Pane :size="detailSizes[1]"> 56 + <Dashboard v-if="dashboardVisible" key="summary" /> 57 + <Coverage v-else-if="coverageVisible" key="coverage" :src="coverageUrl" /> 58 + <FileDetails v-else /> 59 + </Pane> 60 + </Splitpanes> 48 61 </transition> 49 62 </Pane> 50 63 </Splitpanes>
+2 -2
packages/vitest/src/api/setup.ts
··· 77 77 return result 78 78 } 79 79 }, 80 - async getModuleGraph(project: string, id: string): Promise<ModuleGraphData> { 81 - return getModuleGraph(ctx, project, id) 80 + async getModuleGraph(project: string, id: string, browser?: boolean): Promise<ModuleGraphData> { 81 + return getModuleGraph(ctx, project, id, browser) 82 82 }, 83 83 updateSnapshot(file?: File) { 84 84 if (!file)
+1 -1
packages/vitest/src/api/types.ts
··· 15 15 getTestFiles: () => Promise<[{ name: string; root: string }, file: string][]> 16 16 getPaths: () => string[] 17 17 getConfig: () => ResolvedConfig 18 - getModuleGraph: (projectName: string, id: string) => Promise<ModuleGraphData> 18 + getModuleGraph: (projectName: string, id: string, browser?: boolean) => Promise<ModuleGraphData> 19 19 getTransformResult: (id: string) => Promise<TransformResultWithSource | undefined> 20 20 readTestFile: (id: string) => Promise<string | null> 21 21 saveTestFile: (id: string, content: string) => Promise<void>
+9 -3
packages/vitest/src/utils/graph.ts
··· 1 1 import type { ModuleNode } from 'vite' 2 2 import type { ModuleGraphData, Vitest } from '../types' 3 3 4 - export async function getModuleGraph(ctx: Vitest, projectName: string, id: string): Promise<ModuleGraphData> { 4 + export async function getModuleGraph(ctx: Vitest, projectName: string, id: string, browser = false): Promise<ModuleGraphData> { 5 5 const graph: Record<string, string[]> = {} 6 6 const externalized = new Set<string>() 7 7 const inlined = new Set<string>() ··· 18 18 return seen.get(mod) 19 19 let id = clearId(mod.id) 20 20 seen.set(mod, id) 21 - const rewrote = await project.vitenode.shouldExternalize(id) 21 + const rewrote = browser 22 + ? (mod.file?.includes(project.browser!.config.cacheDir) ? mod.id : false) 23 + : await project.vitenode.shouldExternalize(id) 22 24 if (rewrote) { 23 25 id = rewrote 24 26 externalized.add(id) ··· 31 33 graph[id] = (await Promise.all(mods.map(m => get(m, seen)))).filter(Boolean) as string[] 32 34 return id 33 35 } 34 - await get(project.server.moduleGraph.getModuleById(id)) 36 + if (browser && project.browser) 37 + await get(project.browser.moduleGraph.getModuleById(id)) 38 + else 39 + await get(project.server.moduleGraph.getModuleById(id)) 40 + 35 41 return { 36 42 graph, 37 43 externalized: Array.from(externalized),
+4 -1
packages/ui/client/composables/client/index.ts
··· 4 4 import type { Ref } from 'vue' 5 5 import { reactive } from 'vue' 6 6 import { createFileTask } from '@vitest/runner/utils' 7 - import type { RunState } from '../../../types' 7 + import type { BrowserRunnerState, RunState } from '../../../types' 8 8 import { ENTRY_URL, isReport } from '../../constants' 9 9 import { parseError } from '../error' 10 10 import { activeFileId } from '../params' ··· 75 75 if (current.value) 76 76 return runFiles([current.value]) 77 77 } 78 + 79 + // @ts-expect-error not typed global 80 + export const browserState = window.__vitest_browser_runner__ as BrowserRunnerState | undefined 78 81 79 82 watch( 80 83 () => client.ws,