chore: ui mode fixes (#21546)
For https://github.com/microsoft/playwright/issues/21541
This commit is contained in:
		
							parent
							
								
									e737ff83b4
								
							
						
					
					
						commit
						0106a54e6e
					
				|  | @ -94,12 +94,6 @@ | |||
|   color: var(--blue); | ||||
| } | ||||
| 
 | ||||
| .call-error-message { | ||||
|   font-family: var(--vscode-editor-font-family); | ||||
|   font-weight: var(--vscode-editor-font-weight); | ||||
|   font-size: var(--vscode-editor-font-size); | ||||
|   background-color: var(--vscode-inputValidation-errorBackground); | ||||
|   white-space: pre; | ||||
|   overflow: auto; | ||||
| .call-tab .error-message { | ||||
|   padding: 5px; | ||||
| } | ||||
|  |  | |||
|  | @ -14,7 +14,6 @@ | |||
|  * limitations under the License. | ||||
|  */ | ||||
| 
 | ||||
| import ansi2html from 'ansi-to-html'; | ||||
| import type { SerializedValue } from '@protocol/channels'; | ||||
| import type { ActionTraceEvent } from '@trace/trace'; | ||||
| import { msToString } from '@web/uiUtils'; | ||||
|  | @ -23,6 +22,7 @@ import './callTab.css'; | |||
| import { CopyToClipboard } from './copyToClipboard'; | ||||
| import { asLocator } from '@isomorphic/locatorGenerators'; | ||||
| import type { Language } from '@isomorphic/locatorGenerators'; | ||||
| import { ErrorMessage } from './errorMessage'; | ||||
| 
 | ||||
| export const CallTab: React.FunctionComponent<{ | ||||
|   action: ActionTraceEvent | undefined, | ||||
|  | @ -39,7 +39,7 @@ export const CallTab: React.FunctionComponent<{ | |||
|   const wallTime = action.wallTime ? new Date(action.wallTime).toLocaleString() : null; | ||||
|   const duration = action.endTime ? msToString(action.endTime - action.startTime) : 'Timed Out'; | ||||
|   return <div className='call-tab'> | ||||
|     {!!error && <ErrorMessage error={error}></ErrorMessage>} | ||||
|     {!!error && <ErrorMessage error={error} />} | ||||
|     {!!error && <div className='call-section'>Call</div>} | ||||
|     <div className='call-line'>{action.apiName}</div> | ||||
|     {<> | ||||
|  | @ -144,40 +144,3 @@ function parseSerializedValue(value: SerializedValue, handles: any[] | undefined | |||
|   } | ||||
|   return '<object>'; | ||||
| } | ||||
| 
 | ||||
| const ErrorMessage: React.FC<{ | ||||
|   error: string; | ||||
| }> = ({ error }) => { | ||||
|   const html = React.useMemo(() => { | ||||
|     const config: any = { | ||||
|       bg: 'var(--vscode-panel-background)', | ||||
|       fg: 'var(--vscode-foreground)', | ||||
|     }; | ||||
|     config.colors = ansiColors; | ||||
|     return new ansi2html(config).toHtml(escapeHTML(error)); | ||||
|   }, [error]); | ||||
|   return <div className='call-error-message' dangerouslySetInnerHTML={{ __html: html || '' }}></div>; | ||||
| }; | ||||
| 
 | ||||
| const ansiColors = { | ||||
|   0: '#000', | ||||
|   1: '#C00', | ||||
|   2: '#0C0', | ||||
|   3: '#C50', | ||||
|   4: '#00C', | ||||
|   5: '#C0C', | ||||
|   6: '#0CC', | ||||
|   7: '#CCC', | ||||
|   8: '#555', | ||||
|   9: '#F55', | ||||
|   10: '#5F5', | ||||
|   11: '#FF5', | ||||
|   12: '#55F', | ||||
|   13: '#F5F', | ||||
|   14: '#5FF', | ||||
|   15: '#FFF' | ||||
| }; | ||||
| 
 | ||||
| function escapeHTML(text: string): string { | ||||
|   return text.replace(/[&"<>]/g, c => ({ '&': '&', '"': '"', '<': '<', '>': '>' }[c]!)); | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,24 @@ | |||
| /* | ||||
|   Copyright (c) Microsoft Corporation. | ||||
| 
 | ||||
|   Licensed under the Apache License, Version 2.0 (the "License"); | ||||
|   you may not use this file except in compliance with the License. | ||||
|   You may obtain a copy of the License at | ||||
| 
 | ||||
|       http://www.apache.org/licenses/LICENSE-2.0 | ||||
| 
 | ||||
|   Unless required by applicable law or agreed to in writing, software | ||||
|   distributed under the License is distributed on an "AS IS" BASIS, | ||||
|   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|   See the License for the specific language governing permissions and | ||||
|   limitations under the License. | ||||
| */ | ||||
| 
 | ||||
| .error-message { | ||||
|   font-family: var(--vscode-editor-font-family); | ||||
|   font-weight: var(--vscode-editor-font-weight); | ||||
|   font-size: var(--vscode-editor-font-size); | ||||
|   background-color: var(--vscode-inputValidation-errorBackground); | ||||
|   white-space: pre; | ||||
|   overflow: auto; | ||||
| } | ||||
|  | @ -0,0 +1,56 @@ | |||
| /** | ||||
|  * Copyright (c) Microsoft Corporation. | ||||
|  * | ||||
|  * Licensed under the Apache License, Version 2.0 (the "License"); | ||||
|  * you may not use this file except in compliance with the License. | ||||
|  * You may obtain a copy of the License at | ||||
|  * | ||||
|  *     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
|  * | ||||
|  * Unless required by applicable law or agreed to in writing, software | ||||
|  * distributed under the License is distributed on an "AS IS" BASIS, | ||||
|  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|  * See the License for the specific language governing permissions and | ||||
|  * limitations under the License. | ||||
|  */ | ||||
| 
 | ||||
| import ansi2html from 'ansi-to-html'; | ||||
| import * as React from 'react'; | ||||
| import './errorMessage.css'; | ||||
| 
 | ||||
| export const ErrorMessage: React.FC<{ | ||||
|   error: string; | ||||
| }> = ({ error }) => { | ||||
|   const html = React.useMemo(() => { | ||||
|     const config: any = { | ||||
|       bg: 'var(--vscode-panel-background)', | ||||
|       fg: 'var(--vscode-foreground)', | ||||
|     }; | ||||
|     config.colors = ansiColors; | ||||
|     return new ansi2html(config).toHtml(escapeHTML(error)); | ||||
|   }, [error]); | ||||
|   return <div className='error-message' dangerouslySetInnerHTML={{ __html: html || '' }}></div>; | ||||
| }; | ||||
| 
 | ||||
| const ansiColors = { | ||||
|   0: '#000', | ||||
|   1: '#C00', | ||||
|   2: '#0C0', | ||||
|   3: '#C50', | ||||
|   4: '#00C', | ||||
|   5: '#C0C', | ||||
|   6: '#0CC', | ||||
|   7: '#CCC', | ||||
|   8: '#555', | ||||
|   9: '#F55', | ||||
|   10: '#5F5', | ||||
|   11: '#FF5', | ||||
|   12: '#55F', | ||||
|   13: '#F5F', | ||||
|   14: '#5FF', | ||||
|   15: '#FFF' | ||||
| }; | ||||
| 
 | ||||
| function escapeHTML(text: string): string { | ||||
|   return text.replace(/[&"<>]/g, c => ({ '&': '&', '"': '"', '<': '<', '>': '>' }[c]!)); | ||||
| } | ||||
|  | @ -37,12 +37,16 @@ let updateRootSuite: (rootSuite: Suite, progress: Progress) => void = () => {}; | |||
| let updateStepsProgress: () => void = () => {}; | ||||
| let runWatchedTests = () => {}; | ||||
| let runVisibleTests = () => {}; | ||||
| let xtermSize = { cols: 80, rows: 24 }; | ||||
| 
 | ||||
| const xtermDataSource: XtermDataSource = { | ||||
|   pending: [], | ||||
|   clear: () => {}, | ||||
|   write: data => xtermDataSource.pending.push(data), | ||||
|   resize: (cols: number, rows: number) => sendMessageNoReply('resizeTerminal', { cols, rows }), | ||||
|   resize: (cols: number, rows: number) => { | ||||
|     xtermSize = { cols, rows }; | ||||
|     sendMessageNoReply('resizeTerminal', { cols, rows }); | ||||
|   }, | ||||
| }; | ||||
| 
 | ||||
| export const WatchModeView: React.FC<{}> = ({ | ||||
|  | @ -76,6 +80,18 @@ export const WatchModeView: React.FC<{}> = ({ | |||
|   }; | ||||
| 
 | ||||
|   const runTests = (testIds: string[]) => { | ||||
|     // Clear test results.
 | ||||
|     { | ||||
|       const testIdSet = new Set(testIds); | ||||
|       for (const test of rootSuite.value?.allTests() || []) { | ||||
|         if (testIdSet.has(test.id)) | ||||
|           test.results = []; | ||||
|       } | ||||
|       setRootSuite({ ...rootSuite }); | ||||
|     } | ||||
| 
 | ||||
|     const time = '  [' + new Date().toLocaleTimeString() + ']'; | ||||
|     xtermDataSource.write('\x1B[2m—'.repeat(Math.max(0, xtermSize.cols - time.length)) + time + '\x1B[22m'); | ||||
|     setProgress({ total: testIds.length, passed: 0, failed: 0 }); | ||||
|     setIsRunningTest(true); | ||||
|     sendMessage('run', { testIds }).then(() => { | ||||
|  | @ -83,13 +99,14 @@ export const WatchModeView: React.FC<{}> = ({ | |||
|     }); | ||||
|   }; | ||||
| 
 | ||||
|   const result = selectedTest?.results[0]; | ||||
|   return <div className='vbox'> | ||||
|     <SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}> | ||||
|       <TraceView test={selectedTest}></TraceView> | ||||
|       {(result && result.duration >= 0) ? <FinishedTraceView testResult={result} /> : <InProgressTraceView testResult={result} />} | ||||
|       <div className='vbox watch-mode-sidebar'> | ||||
|         <Toolbar> | ||||
|           <div className='section-title' style={{ cursor: 'pointer' }} onClick={() => setSettingsVisible(false)}>Tests</div> | ||||
|           <ToolbarButton icon='play' title='Run' onClick={runVisibleTests} disabled={isRunningTest}></ToolbarButton> | ||||
|           <ToolbarButton icon='play' title='Run' onClick={() => runVisibleTests()} disabled={isRunningTest}></ToolbarButton> | ||||
|           <ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest}></ToolbarButton> | ||||
|           <ToolbarButton icon='refresh' title='Reload' onClick={() => refreshRootSuite(true)} disabled={isRunningTest}></ToolbarButton> | ||||
|           <ToolbarButton icon='eye-watch' title='Watch' toggled={isWatchingFiles} onClick={() => setIsWatchingFiles(!isWatchingFiles)}></ToolbarButton> | ||||
|  | @ -108,7 +125,7 @@ export const WatchModeView: React.FC<{}> = ({ | |||
|       </div> | ||||
|     </SplitView> | ||||
|     <div className='status-line'> | ||||
|         Running: {progress.total} tests | {progress.passed} passed | {progress.failed} failed | ||||
|       Running: {progress.total} tests | {progress.passed} passed | {progress.failed} failed | ||||
|     </div> | ||||
|   </div>; | ||||
| }; | ||||
|  | @ -134,23 +151,22 @@ export const TestList: React.FC<{ | |||
|     refreshRootSuite(true); | ||||
|   }, []); | ||||
| 
 | ||||
|   const { rootItem, treeItemMap, visibleTestIds } = React.useMemo(() => { | ||||
|   const { rootItem, treeItemMap } = React.useMemo(() => { | ||||
|     const rootItem = createTree(rootSuite.value, projects); | ||||
|     filterTree(rootItem, filterText); | ||||
|     hideOnlyTests(rootItem); | ||||
|     const treeItemMap = new Map<string, TreeItem>(); | ||||
|     const visibleTestIds = new Set<string>(); | ||||
|     const visit = (treeItem: TreeItem) => { | ||||
|       if (treeItem.kind === 'test') | ||||
|         visibleTestIds.add(treeItem.id); | ||||
|       treeItem.children?.forEach(visit); | ||||
|       if (treeItem.kind === 'case') | ||||
|         treeItem.tests.forEach(t => visibleTestIds.add(t.id)); | ||||
|       treeItem.children.forEach(visit); | ||||
|       treeItemMap.set(treeItem.id, treeItem); | ||||
|     }; | ||||
|     visit(rootItem); | ||||
|     hideOnlyTests(rootItem); | ||||
|     return { rootItem, treeItemMap, visibleTestIds }; | ||||
|   }, [filterText, rootSuite, projects]); | ||||
| 
 | ||||
|   runVisibleTests = () => runTests([...visibleTestIds]); | ||||
|     runVisibleTests = () => runTests([...visibleTestIds]); | ||||
|     return { rootItem, treeItemMap }; | ||||
|   }, [filterText, rootSuite, projects, runTests]); | ||||
| 
 | ||||
|   const { selectedTreeItem } = React.useMemo(() => { | ||||
|     const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined; | ||||
|  | @ -168,7 +184,6 @@ export const TestList: React.FC<{ | |||
|   }, [selectedTreeItem, isWatchingFiles]); | ||||
| 
 | ||||
|   const runTreeItem = (treeItem: TreeItem) => { | ||||
|     // expandedItems.set(treeItem.id, true);
 | ||||
|     setSelectedTreeItemId(treeItem.id); | ||||
|     runTests(collectTestIds(treeItem)); | ||||
|   }; | ||||
|  | @ -209,6 +224,8 @@ export const TestList: React.FC<{ | |||
|           return 'codicon-error'; | ||||
|         if (treeItem.status === 'passed') | ||||
|           return 'codicon-check'; | ||||
|         if (treeItem.status === 'skipped') | ||||
|           return 'codicon-circle-slash'; | ||||
|         return 'codicon-circle-outline'; | ||||
|       }} | ||||
|       selectedItem={selectedTreeItem} | ||||
|  | @ -252,33 +269,38 @@ export const SettingsView: React.FC<{ | |||
|   </div>; | ||||
| }; | ||||
| 
 | ||||
| export const TraceView: React.FC<{ | ||||
|   test: TestCase | undefined, | ||||
| }> = ({ test }) => { | ||||
| export const InProgressTraceView: React.FC<{ | ||||
|   testResult: TestResult | undefined, | ||||
| }> = ({ testResult }) => { | ||||
|   const [model, setModel] = React.useState<MultiTraceModel | undefined>(); | ||||
|   const [stepsProgress, setStepsProgress] = React.useState(0); | ||||
|   updateStepsProgress = () => setStepsProgress(stepsProgress + 1); | ||||
| 
 | ||||
|   React.useEffect(() => { | ||||
|     (async () => { | ||||
|       if (!test) { | ||||
|         setModel(undefined); | ||||
|         return; | ||||
|       } | ||||
|     setModel(testResult ? stepsToModel(testResult) : undefined); | ||||
|   }, [stepsProgress, testResult]); | ||||
| 
 | ||||
|       const result = test.results?.[0]; | ||||
|       if (result) { | ||||
|         const attachment = result.attachments.find(a => a.name === 'trace'); | ||||
|         if (attachment && attachment.path) | ||||
|           loadSingleTraceFile(attachment.path).then(setModel); | ||||
|         else | ||||
|           setModel(stepsToModel(result)); | ||||
|       } else { | ||||
|         setModel(undefined); | ||||
|       } | ||||
|     })(); | ||||
|   }, [test, stepsProgress]); | ||||
|   return <TraceView model={model} />; | ||||
| }; | ||||
| 
 | ||||
| export const FinishedTraceView: React.FC<{ | ||||
|   testResult: TestResult, | ||||
| }> = ({ testResult }) => { | ||||
|   const [model, setModel] = React.useState<MultiTraceModel | undefined>(); | ||||
| 
 | ||||
|   React.useEffect(() => { | ||||
|     // Test finished.
 | ||||
|     const attachment = testResult.attachments.find(a => a.name === 'trace'); | ||||
|     if (attachment && attachment.path) | ||||
|       loadSingleTraceFile(attachment.path).then(setModel); | ||||
|   }, [testResult]); | ||||
| 
 | ||||
|   return <TraceView model={model} />; | ||||
| }; | ||||
| 
 | ||||
| export const TraceView: React.FC<{ | ||||
|   model: MultiTraceModel | undefined, | ||||
| }> = ({ model }) => { | ||||
|   const xterm = <XtermWrapper source={xtermDataSource}></XtermWrapper>; | ||||
|   return <Workbench model={model} output={xterm} rightToolbar={[ | ||||
|     <ToolbarButton icon='trash' title='Clear output' onClick={() => xtermDataSource.clear()}></ToolbarButton>, | ||||
|  | @ -412,7 +434,7 @@ type TreeItemBase = { | |||
|   title: string; | ||||
|   location: Location, | ||||
|   children: TreeItem[]; | ||||
|   status: 'none' | 'running' | 'passed' | 'failed'; | ||||
|   status: 'none' | 'running' | 'passed' | 'failed' | 'skipped'; | ||||
| }; | ||||
| 
 | ||||
| type GroupItem = TreeItemBase & { | ||||
|  | @ -476,12 +498,14 @@ function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean> | |||
|         parentGroup.children.push(testCaseItem); | ||||
|       } | ||||
| 
 | ||||
|       let status: 'none' | 'running' | 'passed' | 'failed' = 'none'; | ||||
|       let status: 'none' | 'running' | 'passed' | 'failed' | 'skipped' = 'none'; | ||||
|       if (test.results.some(r => r.duration === -1)) | ||||
|         status = 'running'; | ||||
|       else if (test.results.length && test.outcome() === 'skipped') | ||||
|         status = 'skipped'; | ||||
|       else if (test.results.length && test.outcome() !== 'expected') | ||||
|         status = 'failed'; | ||||
|       else if (test.outcome() === 'expected') | ||||
|       else if (test.results.length && test.outcome() === 'expected') | ||||
|         status = 'passed'; | ||||
| 
 | ||||
|       testCaseItem.tests.push(test); | ||||
|  | @ -508,11 +532,13 @@ function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean> | |||
|       propagateStatus(child); | ||||
| 
 | ||||
|     let allPassed = treeItem.children.length > 0; | ||||
|     let allSkipped = treeItem.children.length > 0; | ||||
|     let hasFailed = false; | ||||
|     let hasRunning = false; | ||||
| 
 | ||||
|     for (const child of treeItem.children) { | ||||
|       allPassed = allPassed && child.status === 'passed'; | ||||
|       allSkipped = allSkipped && child.status === 'skipped'; | ||||
|       allPassed = allPassed && (child.status === 'passed' || child.status === 'skipped'); | ||||
|       hasFailed = hasFailed || child.status === 'failed'; | ||||
|       hasRunning = hasRunning || child.status === 'running'; | ||||
|     } | ||||
|  | @ -521,6 +547,8 @@ function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean> | |||
|       treeItem.status = 'running'; | ||||
|     else if (hasFailed) | ||||
|       treeItem.status = 'failed'; | ||||
|     else if (allSkipped) | ||||
|       treeItem.status = 'skipped'; | ||||
|     else if (allPassed) | ||||
|       treeItem.status = 'passed'; | ||||
|   }; | ||||
|  |  | |||
|  | @ -117,6 +117,10 @@ body.dark-mode .CodeMirror span.cm-type { | |||
| } | ||||
| 
 | ||||
| .CodeMirror .CodeMirror-gutters { | ||||
|   z-index: 0; | ||||
| } | ||||
| 
 | ||||
| .CodeMirror .CodeMirror-gutterwrapper { | ||||
|   background: var(--vscode-editor-background); | ||||
|   border-right: 1px solid var(--vscode-editorGroup-border); | ||||
|   color: var(--vscode-editorLineNumber-foreground); | ||||
|  |  | |||
|  | @ -54,6 +54,7 @@ | |||
| 
 | ||||
| .list-view-content:focus .list-view-entry.selected * { | ||||
|   color: var(--vscode-list-activeSelectionForeground) !important; | ||||
|   background-color: transparent !important; | ||||
| } | ||||
| 
 | ||||
| .list-view-content:focus .list-view-entry.error.selected { | ||||
|  |  | |||
|  | @ -121,11 +121,19 @@ export function ListView<T>({ | |||
|           onMouseLeave={() => setHighlightedItem(undefined)} | ||||
|         > | ||||
|           {indentation ? <div style={{ minWidth: indentation * 16 }}></div> : undefined} | ||||
|           {icon && <div className={'codicon ' + (icon(item) || 'blank')} style={{ minWidth: 16, marginRight: 4 }} onClick={e => { | ||||
|             e.stopPropagation(); | ||||
|             e.preventDefault(); | ||||
|             onIconClicked?.(item); | ||||
|           }}></div>} | ||||
|           {icon && <div | ||||
|             className={'codicon ' + (icon(item) || 'blank')} | ||||
|             style={{ minWidth: 16, marginRight: 4 }} | ||||
|             onDoubleClick={e => { | ||||
|               e.preventDefault(); | ||||
|               e.stopPropagation(); | ||||
|             }} | ||||
|             onClick={e => { | ||||
|               e.stopPropagation(); | ||||
|               e.preventDefault(); | ||||
|               onIconClicked?.(item); | ||||
|             }} | ||||
|           ></div>} | ||||
|           {typeof rendered === 'string' ? <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{rendered}</div> : rendered} | ||||
|         </div>; | ||||
|       })} | ||||
|  |  | |||
|  | @ -16,6 +16,10 @@ | |||
| 
 | ||||
| @import '../third_party/vscode/colors.css'; | ||||
| 
 | ||||
| .xterm-wrapper { | ||||
|   padding-left: 5px; | ||||
| } | ||||
| 
 | ||||
| .xterm-wrapper .xterm-viewport { | ||||
|   background-color: var(--vscode-panel-background) !important; | ||||
|   color: var(--vscode-foreground) !important; | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue