[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.

fix(ui): add errors and draft state (*) to the code editor (#7044)

authored by

Joaquín Sánchez and committed by
GitHub
(Jan 14, 2025, 2:20 PM +0100) faca4de8 9b218854

+161 -38
+10 -3
packages/ui/client/components/CodeMirrorContainer.vue
··· 4 4 const { mode, readOnly } = defineProps<{ 5 5 mode?: string 6 6 readOnly?: boolean 7 + saving?: boolean 7 8 }>() 8 9 9 10 const emit = defineEmits<{ ··· 38 39 readOnly: readOnly ? true : undefined, 39 40 extraKeys: { 40 41 'Cmd-S': function (cm) { 41 - emit('save', cm.getValue()) 42 + const isReadonly = cm.getOption('readOnly') 43 + if (!isReadonly) { 44 + emit('save', cm.getValue()) 45 + } 42 46 }, 43 47 'Ctrl-S': function (cm) { 44 - emit('save', cm.getValue()) 48 + const isReadonly = cm.getOption('readOnly') 49 + if (!isReadonly) { 50 + emit('save', cm.getValue()) 51 + } 45 52 }, 46 53 }, 47 54 }) ··· 53 60 </script> 54 61 55 62 <template> 56 - <div relative font-mono text-sm class="codemirror-scrolls"> 63 + <div relative font-mono text-sm class="codemirror-scrolls" :class="saving ? 'codemirror-busy' : undefined"> 57 64 <textarea ref="el" /> 58 65 </div> 59 66 </template>
+1 -1
packages/ui/client/components/FileDetails.vue
··· 253 253 </div> 254 254 <ViewEditor 255 255 v-if="viewMode === 'editor'" 256 - :key="current.filepath" 256 + :key="current.id" 257 257 :file="current" 258 258 data-testid="editor" 259 259 @draft="onDraft"
+4
packages/ui/client/styles/main.css
··· 211 211 .v-popper__popper .v-popper__arrow-outer { 212 212 border-color: var(--background-color); 213 213 } 214 + 215 + .codemirror-busy > .CodeMirror > .CodeMirror-scroll > .CodeMirror-sizer .CodeMirror-lines { 216 + cursor: wait !important; 217 + }
+139 -33
packages/ui/client/components/views/ViewEditor.vue
··· 3 3 import type { ErrorWithDiff, File } from 'vitest' 4 4 import { createTooltip, destroyTooltip } from 'floating-vue' 5 5 import { client, isReport } from '~/composables/client' 6 + import { finished } from '~/composables/client/state' 6 7 import { codemirrorRef } from '~/composables/codemirror' 7 8 import { openInEditor } from '~/composables/error' 8 9 import { lineNumber } from '~/composables/params' ··· 17 18 const serverCode = shallowRef<string | undefined>(undefined) 18 19 const draft = ref(false) 19 20 const loading = ref(true) 21 + const saving = ref(false) 22 + const currentPosition = ref<CodeMirror.Position | undefined>() 20 23 21 24 watch( 22 25 () => props.file, 23 26 async () => { 27 + // this watcher will be called multiple times when saving the file in the view editor 28 + // since we are saving the file and changing the content inside onSave we just return here 29 + if (saving.value) { 30 + return 31 + } 24 32 loading.value = true 25 33 try { 26 34 if (!props.file || !props.file?.filepath) { 27 35 code.value = '' 28 36 serverCode.value = code.value 29 37 draft.value = false 38 + loading.value = false 30 39 return 31 40 } 32 41 ··· 34 43 serverCode.value = code.value 35 44 draft.value = false 36 45 } 37 - finally { 38 - // fire focusing editor after loading 39 - nextTick(() => (loading.value = false)) 46 + catch (e) { 47 + console.error('cannot fetch file', e) 40 48 } 49 + 50 + await nextTick() 51 + 52 + // fire focusing editor after loading 53 + loading.value = false 41 54 }, 42 55 { immediate: true }, 43 56 ) 44 57 45 - watch(() => [loading.value, props.file, lineNumber.value] as const, ([loadingFile, _, l]) => { 46 - if (!loadingFile) { 58 + watch(() => [loading.value, saving.value, props.file, lineNumber.value] as const, ([loadingFile, s, _, l]) => { 59 + if (!loadingFile && !s) { 47 60 if (l != null) { 48 61 nextTick(() => { 49 - const line = { line: l ?? 0, ch: 0 } 50 - codemirrorRef.value?.scrollIntoView(line, 100) 51 - nextTick(() => { 52 - codemirrorRef.value?.focus() 53 - codemirrorRef.value?.setCursor(line) 54 - }) 62 + const cp = currentPosition.value 63 + const line = cp ?? { line: l ?? 0, ch: 0 } 64 + // restore caret position: the watchDebounced below will use old value 65 + if (cp) { 66 + currentPosition.value = undefined 67 + } 68 + else { 69 + codemirrorRef.value?.scrollIntoView(line, 100) 70 + nextTick(() => { 71 + codemirrorRef.value?.focus() 72 + codemirrorRef.value?.setCursor(line) 73 + }) 74 + } 55 75 }) 56 76 } 57 77 else { ··· 65 85 const ext = computed(() => props.file?.filepath?.split(/\./g).pop() || 'js') 66 86 const editor = ref<any>() 67 87 68 - const cm = computed<CodeMirror.EditorFromTextArea | undefined>( 69 - () => editor.value?.cm, 70 - ) 71 88 const failed = computed( 72 89 () => props.file?.tasks.filter(i => i.result?.state === 'fail') || [], 73 90 ) 74 - 75 91 const widgets: CodeMirror.LineWidget[] = [] 76 92 const handles: CodeMirror.LineHandle[] = [] 77 93 const listeners: [el: HTMLSpanElement, l: EventListener, t: () => void][] = [] ··· 134 150 const el: EventListener = async () => { 135 151 await openInEditor(stack.file, stack.line, stack.column) 136 152 } 153 + span.addEventListener('click', el) 137 154 div.appendChild(span) 138 155 listeners.push([span, el, () => destroyTooltip(span)]) 139 156 handles.push(codemirrorRef.value!.addLineClass(stack.line - 1, 'wrap', 'bg-red-500/10')) 140 157 widgets.push(codemirrorRef.value!.addLineWidget(stack.line - 1, div)) 141 158 } 142 159 143 - watch( 144 - [cm, failed], 145 - ([cmValue]) => { 160 + const { pause, resume } = watch( 161 + [codemirrorRef, failed, finished] as const, 162 + ([cmValue, f, end]) => { 146 163 if (!cmValue) { 164 + widgets.length = 0 165 + handles.length = 0 147 166 clearListeners() 148 167 return 149 168 } 150 169 170 + // if still running 171 + if (!end) { 172 + return 173 + } 174 + 175 + // cleanup previous data when not saving just reloading 176 + cmValue.off('changes', codemirrorChanges) 177 + 178 + // cleanup previous data 179 + clearListeners() 180 + widgets.forEach(widget => widget.clear()) 181 + handles.forEach(h => cmValue?.removeLineClass(h, 'wrap')) 182 + widgets.length = 0 183 + handles.length = 0 184 + 151 185 setTimeout(() => { 152 - clearListeners() 153 - widgets.forEach(widget => widget.clear()) 154 - handles.forEach(h => codemirrorRef.value?.removeLineClass(h, 'wrap')) 155 - widgets.length = 0 156 - handles.length = 0 157 - 158 - cmValue.on('changes', codemirrorChanges) 159 - 160 - failed.value.forEach((i) => { 186 + // add new data 187 + f.forEach((i) => { 161 188 i.result?.errors?.forEach(createErrorElement) 162 189 }) 190 + 191 + // Prevent getting access to initial state 163 192 if (!hasBeenEdited.value) { 164 193 cmValue.clearHistory() 165 - } // Prevent getting access to initial state 194 + } 195 + 196 + cmValue.on('changes', codemirrorChanges) 166 197 }, 100) 167 198 }, 168 199 { flush: 'post' }, 169 200 ) 170 201 202 + watchDebounced(() => [finished.value, saving.value, currentPosition.value] as const, ([f, s], old) => { 203 + if (f && !s && old && old[2]) { 204 + codemirrorRef.value?.setCursor(old[2]) 205 + } 206 + }, { debounce: 100, flush: 'post' }) 207 + 171 208 async function onSave(content: string) { 172 - hasBeenEdited.value = true 173 - await client.rpc.saveTestFile(props.file!.filepath, content) 174 - serverCode.value = content 175 - draft.value = false 209 + if (saving.value) { 210 + return 211 + } 212 + pause() 213 + saving.value = true 214 + await nextTick() 215 + 216 + // clear previous state 217 + const cmValue = codemirrorRef.value 218 + if (cmValue) { 219 + cmValue.setOption('readOnly', true) 220 + await nextTick() 221 + cmValue.refresh() 222 + } 223 + // save cursor position 224 + currentPosition.value = cmValue?.getCursor() 225 + cmValue?.off('changes', codemirrorChanges) 226 + 227 + // cleanup previous data 228 + clearListeners() 229 + widgets.forEach(widget => widget.clear()) 230 + handles.forEach(h => cmValue?.removeLineClass(h, 'wrap')) 231 + widgets.length = 0 232 + handles.length = 0 233 + 234 + try { 235 + hasBeenEdited.value = true 236 + // save the file changes 237 + await client.rpc.saveTestFile(props.file!.filepath, content) 238 + // update original server code 239 + serverCode.value = content 240 + // update draft indicator in the tab title (</> * Code) 241 + draft.value = false 242 + } 243 + catch (e) { 244 + console.error('error saving file', e) 245 + } 246 + 247 + // Prevent getting access to initial state 248 + if (!hasBeenEdited.value) { 249 + cmValue?.clearHistory() 250 + } 251 + 252 + try { 253 + // the server will send a few events in a row 254 + // await to re-run test 255 + await until(finished).toBe(false, { flush: 'sync', timeout: 1000, throwOnTimeout: true }) 256 + // await to finish 257 + await until(finished).toBe(true, { flush: 'sync', timeout: 1000, throwOnTimeout: false }) 258 + } 259 + catch { 260 + // ignore errors 261 + } 262 + 263 + // add new data 264 + failed.value.forEach((i) => { 265 + i.result?.errors?.forEach(createErrorElement) 266 + }) 267 + 268 + cmValue?.on('changes', codemirrorChanges) 269 + 270 + saving.value = false 271 + await nextTick() 272 + if (cmValue) { 273 + cmValue.setOption('readOnly', false) 274 + await nextTick() 275 + cmValue.refresh() 276 + } 277 + // activate watcher 278 + resume() 176 279 } 280 + 281 + // we need to remove listeners before unmounting the component: the watcher will not be called 282 + onBeforeUnmount(clearListeners) 177 283 </script> 178 284 179 285 <template> ··· 181 287 ref="editor" 182 288 v-model="code" 183 289 h-full 184 - v-bind="{ lineNumbers: true, readOnly: isReport }" 290 + v-bind="{ lineNumbers: true, readOnly: isReport, saving }" 185 291 :mode="ext" 186 292 data-testid="code-mirror" 187 293 @save="onSave"
+5 -1
packages/ui/client/composables/client/index.ts
··· 32 32 }, 33 33 onFinished(_files, errors) { 34 34 explorerTree.endRun() 35 - testRunState.value = 'idle' 35 + // don't change the testRunState.value here: 36 + // - when saving the file in the codemirror requires explorer tree endRun to finish (multiple microtasks) 37 + // - if we change here the state before the tasks states are updated, the cursor position will be lost 38 + // - line moved to composables/explorer/collector.ts::refreshExplorer after calling updateRunningTodoTests 39 + // testRunState.value = 'idle' 36 40 unhandledErrors.value = (errors || []).map(parseError) 37 41 }, 38 42 onFinishedReportCoverage() {
+2
packages/ui/client/composables/explorer/collector.ts
··· 5 5 import { toArray } from '@vitest/utils' 6 6 import { hasFailedSnapshot } from '@vitest/ws-client' 7 7 import { client, findById } from '~/composables/client' 8 + import { testRunState } from '~/composables/client/state' 8 9 import { expandNodesOnEndRun } from '~/composables/explorer/expand' 9 10 import { runFilter, testMatcher } from '~/composables/explorer/filter' 10 11 import { explorerTree } from '~/composables/explorer/index' ··· 234 235 // update only at the end 235 236 if (end) { 236 237 updateRunningTodoTests() 238 + testRunState.value = 'idle' 237 239 } 238 240 } 239 241