From 82cd1789b250eb52c244ae71c9e3aac606402b32 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Sun, 19 Mar 2023 12:04:19 -0700 Subject: [PATCH] cherry-pick(#21781): chore(ui): ui polish --- .../src/server/chromium/crApp.ts | 3 + packages/trace-viewer/src/sw.ts | 3 - packages/trace-viewer/src/ui/snapshotTab.css | 1 + packages/trace-viewer/src/ui/snapshotTab.tsx | 1 - packages/trace-viewer/src/ui/sourceTab.css | 10 ++ packages/trace-viewer/src/ui/sourceTab.tsx | 15 ++- packages/trace-viewer/src/ui/watchMode.css | 26 ++--- packages/trace-viewer/src/ui/watchMode.tsx | 104 ++++++++++-------- packages/trace-viewer/src/ui/workbench.tsx | 12 +- .../web/src/components/codeMirrorWrapper.css | 2 +- .../web/src/components/codeMirrorWrapper.tsx | 6 +- packages/web/src/components/tabbedPane.tsx | 2 +- packages/web/src/components/toolbar.css | 24 +++- packages/web/src/components/toolbar.tsx | 12 +- packages/web/src/components/toolbarButton.css | 6 +- packages/web/src/components/toolbarButton.tsx | 6 +- packages/web/src/components/xtermWrapper.tsx | 50 +++++++-- packages/web/src/theme.ts | 19 +++- .../playwright-test/ui-mode-test-run.spec.ts | 21 ++++ .../ui-mode-test-source.spec.ts | 9 ++ .../playwright-test/ui-mode-test-tree.spec.ts | 4 +- .../ui-mode-test-watch.spec.ts | 6 + 22 files changed, 234 insertions(+), 108 deletions(-) diff --git a/packages/playwright-core/src/server/chromium/crApp.ts b/packages/playwright-core/src/server/chromium/crApp.ts index 1ae096078c..bfa8819f2d 100644 --- a/packages/playwright-core/src/server/chromium/crApp.ts +++ b/packages/playwright-core/src/server/chromium/crApp.ts @@ -41,6 +41,9 @@ export async function syncLocalStorageWithSettings(page: Page, appName: string) const settings = await fs.promises.readFile(settingsFile, 'utf-8').catch(() => ('{}')); await page.addInitScript( `(${String((settings: any) => { + // iframes w/ snapshots, etc. + if (location && location.protocol === 'data:') + return; Object.entries(settings).map(([k, v]) => localStorage[k] = v); (window as any).saveSettings = () => { (window as any)._saveSerializedSettings(JSON.stringify({ ...localStorage })); diff --git a/packages/trace-viewer/src/sw.ts b/packages/trace-viewer/src/sw.ts index 488b8928b8..c778d09d34 100644 --- a/packages/trace-viewer/src/sw.ts +++ b/packages/trace-viewer/src/sw.ts @@ -42,9 +42,6 @@ async function loadTrace(traceUrl: string, traceFileName: string | null, clientI try { await traceModel.load(traceUrl, progress); } catch (error: any) { - // eslint-disable-next-line no-console - console.error(error); - if (error?.message?.includes('Cannot find .trace file') && await traceModel.hasEntry('index.html')) throw new Error('Could not load trace. Did you upload a Playwright HTML report instead? Make sure to extract the archive first and then double-click the index.html file or put it on a web server.'); else if (traceFileName) diff --git a/packages/trace-viewer/src/ui/snapshotTab.css b/packages/trace-viewer/src/ui/snapshotTab.css index a83e83d792..d51ae00f10 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.css +++ b/packages/trace-viewer/src/ui/snapshotTab.css @@ -153,4 +153,5 @@ body.dark-mode .window-header { .snapshot-tab .cm-wrapper { line-height: 23px; + margin-right: 4px; } diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index 7a4cac8c7e..eefa47bd70 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -137,7 +137,6 @@ export const SnapshotTab: React.FunctionComponent<{ setHighlightedLocator(''); setIsInspecting(!pickerVisible); }}>Pick locator -
{['action', 'before', 'after'].map(tab => { return , hideStackFrames?: boolean, + rootDir?: string, fallbackLocation?: StackFrame, -}> = ({ action, sources, hideStackFrames, fallbackLocation }) => { +}> = ({ action, sources, hideStackFrames, rootDir, fallbackLocation }) => { const [lastAction, setLastAction] = React.useState(); const [selectedFrame, setSelectedFrame] = React.useState(0); @@ -41,7 +42,7 @@ export const SourceTab: React.FunctionComponent<{ } }, [action, lastAction, setLastAction, setSelectedFrame]); - const { source, targetLine, highlight } = useAsyncMemo<{ source: SourceModel, targetLine: number, highlight: SourceHighlight[] }>(async () => { + const { source, highlight, targetLine, fileName } = useAsyncMemo<{ source: SourceModel, targetLine?: number, fileName?: string, highlight: SourceHighlight[] }>(async () => { const location = action?.stack?.[selectedFrame] || fallbackLocation; if (!location?.file) return { source: { errors: [], content: undefined }, targetLine: 0, highlight: [] }; @@ -54,6 +55,7 @@ export const SourceTab: React.FunctionComponent<{ } const targetLine = location.line || 0; + const fileName = rootDir && location.file.startsWith(rootDir) ? location.file.substring(rootDir.length + 1) : location.file; const highlight: SourceHighlight[] = source.errors.map(e => ({ type: 'error', line: e.location.line, message: e.error!.message })); highlight.push({ line: targetLine, type: 'running' }); @@ -68,11 +70,14 @@ export const SourceTab: React.FunctionComponent<{ source.content = ``; } } - return { source, targetLine, highlight }; - }, [action, selectedFrame, fallbackLocation], { source: { errors: [], content: 'Loading\u2026' }, targetLine: 0, highlight: [] }); + return { source, highlight, targetLine, fileName }; + }, [action, selectedFrame, rootDir, fallbackLocation], { source: { errors: [], content: 'Loading\u2026' }, highlight: [] }); return - +
+ {fileName &&
{fileName}
} + +
; }; diff --git a/packages/trace-viewer/src/ui/watchMode.css b/packages/trace-viewer/src/ui/watchMode.css index ed8cbc0e96..f94e490c5a 100644 --- a/packages/trace-viewer/src/ui/watchMode.css +++ b/packages/trace-viewer/src/ui/watchMode.css @@ -40,16 +40,9 @@ overflow: hidden } -.watch-mode-sidebar .toolbar { - min-height: 24px; -} - -.watch-mode-sidebar .toolbar-button { - margin: 0; -} - .watch-mode .section-title { display: flex; + flex: auto; flex-direction: row; align-items: center; font-size: 11px; @@ -58,6 +51,7 @@ text-overflow: ellipsis; overflow: hidden; padding: 8px; + height: 30px; } .watch-mode-sidebar img { @@ -68,20 +62,19 @@ } .status-line { - flex: none; + flex: auto; + white-space: nowrap; line-height: 22px; - padding: 0 10px; - color: var(--vscode-statusBar-foreground); - background-color: var(--vscode-statusBar-background); + padding-left: 10px; display: flex; flex-direction: row; align-items: center; + height: 30px; } .status-line > div { - display: flex; - align-items: center; - margin: 0 5px; + overflow: hidden; + text-overflow: ellipsis; } .list-view-entry:not(.highlighted):not(.selected) .toolbar-button:not(.toggled) { @@ -103,7 +96,6 @@ flex: none; display: flex; flex-direction: column; - margin-top: 8px; } .filter-title, @@ -120,7 +112,7 @@ } .filter-summary { - line-height: 24px; + line-height: 21px; margin-top: 2px; margin-left: 20px; } diff --git a/packages/trace-viewer/src/ui/watchMode.tsx b/packages/trace-viewer/src/ui/watchMode.tsx index 5e3d7083e4..a3769c3648 100644 --- a/packages/trace-viewer/src/ui/watchMode.tsx +++ b/packages/trace-viewer/src/ui/watchMode.tsx @@ -22,7 +22,7 @@ import { TreeView } from '@web/components/treeView'; import type { TreeState } from '@web/components/treeView'; import { baseFullConfig, TeleReporterReceiver, TeleSuite } from '@testIsomorphic/teleReceiver'; import type { TeleTestCase } from '@testIsomorphic/teleReceiver'; -import type { FullConfig, Suite, TestCase, TestResult, Location } from '../../../playwright-test/types/testReporter'; +import type { FullConfig, Suite, TestCase, Location } from '../../../playwright-test/types/testReporter'; import { SplitView } from '@web/components/splitView'; import { MultiTraceModel } from './modelUtil'; import './watchMode.css'; @@ -36,7 +36,7 @@ import { toggleTheme } from '@web/theme'; import { artifactsFolderName } from '@testIsomorphic/folders'; import { settings } from '@web/uiUtils'; -let updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress) => void = () => {}; +let updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress | undefined) => void = () => {}; let runWatchedTests = (fileName: string) => {}; let xtermSize = { cols: 80, rows: 24 }; @@ -67,17 +67,17 @@ export const WatchModeView: React.FC<{}> = ({ ])); const [projectFilters, setProjectFilters] = React.useState>(new Map()); const [testModel, setTestModel] = React.useState({ config: undefined, rootSuite: undefined }); - const [progress, setProgress] = React.useState({ total: 0, passed: 0, failed: 0, skipped: 0 }); - const [selectedTest, setSelectedTest] = React.useState(undefined); + const [progress, setProgress] = React.useState(); + const [selectedItem, setSelectedItem] = React.useState<{ location?: Location, testCase?: TestCase }>({}); const [visibleTestIds, setVisibleTestIds] = React.useState([]); const [isLoading, setIsLoading] = React.useState(false); - const [runningState, setRunningState] = React.useState<{ testIds: Set, itemSelectedByUser?: boolean }>(); + const [runningState, setRunningState] = React.useState<{ testIds: Set, itemSelectedByUser?: boolean } | undefined>(); const inputRef = React.useRef(null); const reloadTests = () => { setIsLoading(true); - updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), { total: 0, passed: 0, failed: 0, skipped: 0 }); + updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), undefined); refreshRootSuite(true).then(() => { setIsLoading(false); }); @@ -88,7 +88,7 @@ export const WatchModeView: React.FC<{}> = ({ reloadTests(); }, []); - updateRootSuite = (config: FullConfig, rootSuite: Suite, newProgress: Progress) => { + updateRootSuite = (config: FullConfig, rootSuite: Suite, newProgress: Progress | undefined) => { const selectedProjects = config.configFile ? settings.getObject(config.configFile + ':projects', undefined) : undefined; for (const projectName of projectFilters.keys()) { if (!rootSuite.suites.find(s => s.title === projectName)) @@ -103,7 +103,10 @@ export const WatchModeView: React.FC<{}> = ({ setTestModel({ config, rootSuite }); setProjectFilters(new Map(projectFilters)); - setProgress(newProgress); + if (runningState && newProgress) + setProgress({ ...newProgress, total: runningState.testIds.size }); + else if (!newProgress) + setProgress(undefined); }; const runTests = (testIds: string[]) => { @@ -133,29 +136,27 @@ export const WatchModeView: React.FC<{}> = ({ }; const isRunningTest = !!runningState; - const result = selectedTest?.results[0]; - const outputDir = selectedTest ? outputDirForTestCase(selectedTest) : undefined; return
+
Output
xtermDataSource.clear()}>
setIsShowingOutput(false)}>
- ; +
- +
- +
Playwright
-
toggleTheme()} /> reloadTests()} disabled={isRunningTest || isLoading}> { setIsShowingOutput(!isShowingOutput); }} /> @@ -169,9 +170,14 @@ export const WatchModeView: React.FC<{}> = ({ setProjectFilters={setProjectFilters} testModel={testModel} runTests={() => runTests(visibleTestIds)} /> - -
Tests
-
+ + {!isRunningTest && !progress &&
Tests
} + {!isRunningTest && progress &&
+
{progress.passed}/{progress.total} passed ({(progress.passed / progress.total) * 100 | 0}%)
+
} + {isRunningTest && progress &&
+
Running {progress.passed}/{runningState.testIds.size} passed ({(progress.passed / runningState.testIds.size) * 100 | 0}%)
+
} runTests(visibleTestIds)} disabled={isRunningTest || isLoading}> sendMessageNoReply('stop')} disabled={!isRunningTest || isLoading}>
@@ -182,19 +188,10 @@ export const WatchModeView: React.FC<{}> = ({ testModel={testModel} runningState={runningState} runTests={runTests} - onTestSelected={setSelectedTest} + onItemSelected={setSelectedItem} setVisibleTestIds={setVisibleTestIds} />
-
-
Total: {progress.total}
- {isRunningTest &&
{`Running ${visibleTestIds.length}\u2026`}
} - {isLoading &&
{'Loading\u2026'}
} - {!isRunningTest &&
Showing: {visibleTestIds.length}
} -
{progress.passed} passed
-
{progress.failed} failed
-
{progress.skipped} skipped
-
; }; @@ -276,11 +273,11 @@ const TestList: React.FC<{ runTests: (testIds: string[]) => void, runningState?: { testIds: Set, itemSelectedByUser?: boolean }, setVisibleTestIds: (testIds: string[]) => void, - onTestSelected: (test: TestCase | undefined) => void, -}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, onTestSelected, setVisibleTestIds }) => { + onItemSelected: (item: { testCase?: TestCase, location?: Location }) => void, +}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, onItemSelected, setVisibleTestIds }) => { const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); - const [watchedTreeIds] = React.useState>(new Set()); + const [watchedTreeIds, innerSetWatchedTreeIds] = React.useState<{ value: Set }>({ value: new Set() }); const { rootItem, treeItemMap } = React.useMemo(() => { const rootItem = createTree(testModel.rootSuite, projectFilters); @@ -323,14 +320,15 @@ const TestList: React.FC<{ const { selectedTreeItem } = React.useMemo(() => { const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined; + const location = selectedTreeItem?.location; let selectedTest: TestCase | undefined; if (selectedTreeItem?.kind === 'test') selectedTest = selectedTreeItem.test; else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1) selectedTest = selectedTreeItem.tests[0]; - onTestSelected(selectedTest); + onItemSelected({ testCase: selectedTest, location }); return { selectedTreeItem }; - }, [onTestSelected, selectedTreeItemId, treeItemMap]); + }, [onItemSelected, selectedTreeItemId, treeItemMap]); const setWatchedTreeIds = (watchedTreeIds: Set) => { const fileNames = new Set(); @@ -339,6 +337,7 @@ const TestList: React.FC<{ fileNames.add(fileNameForTreeItem(treeItem)!); } sendMessageNoReply('watch', { fileNames: [...fileNames] }); + innerSetWatchedTreeIds({ value: watchedTreeIds }); }; const runTreeItem = (treeItem: TreeItem) => { @@ -348,7 +347,7 @@ const TestList: React.FC<{ runWatchedTests = (fileName: string) => { const testIds: string[] = []; - for (const treeId of watchedTreeIds) { + for (const treeId of watchedTreeIds.value) { const treeItem = treeItemMap.get(treeId)!; if (fileNameForTreeItem(treeItem) === fileName) testIds.push(...collectTestIds(treeItem)); @@ -367,12 +366,12 @@ const TestList: React.FC<{ runTreeItem(treeItem)} disabled={!!runningState}> sendMessageNoReply('open', { location: locationToOpen(treeItem) })}> { - if (watchedTreeIds.has(treeItem.id)) - watchedTreeIds.delete(treeItem.id); + if (watchedTreeIds.value.has(treeItem.id)) + watchedTreeIds.value.delete(treeItem.id); else - watchedTreeIds.add(treeItem.id); - setWatchedTreeIds(watchedTreeIds); - }} toggled={watchedTreeIds.has(treeItem.id)}> + watchedTreeIds.value.add(treeItem.id); + setWatchedTreeIds(watchedTreeIds.value); + }} toggled={watchedTreeIds.value.has(treeItem.id)}> ; }} icon={treeItem => { @@ -398,14 +397,19 @@ const TestList: React.FC<{ }; const TraceView: React.FC<{ - outputDir: string | undefined, - testCase: TestCase | undefined, - result: TestResult | undefined, -}> = ({ outputDir, testCase, result }) => { + item: { location?: Location, testCase?: TestCase }, + rootDir?: string, +}> = ({ item, rootDir }) => { const [model, setModel] = React.useState(); const [counter, setCounter] = React.useState(0); const pollTimer = React.useRef(null); + const { outputDir, result } = React.useMemo(() => { + const outputDir = item.testCase ? outputDirForTestCase(item.testCase) : undefined; + const result = item.testCase?.results[0]; + return { outputDir, result }; + }, [item]); + React.useEffect(() => { if (pollTimer.current) clearTimeout(pollTimer.current); @@ -427,7 +431,7 @@ const TraceView: React.FC<{ return; } - const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${testCase?.id}.json`; + const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${item.testCase?.id}.json`; // Start polling running test. pollTimer.current = setTimeout(async () => { try { @@ -443,9 +447,16 @@ const TraceView: React.FC<{ if (pollTimer.current) clearTimeout(pollTimer.current); }; - }, [result, outputDir, testCase, setModel, counter, setCounter]); + }, [result, outputDir, item, setModel, counter, setCounter]); - return ; + return ; }; declare global { @@ -478,7 +489,6 @@ const refreshRootSuite = (eraseResults: boolean): Promise => { let rootSuite: Suite; const progress: Progress = { - total: 0, passed: 0, failed: 0, skipped: 0, @@ -489,7 +499,6 @@ const refreshRootSuite = (eraseResults: boolean): Promise => { if (!rootSuite) rootSuite = suite; config = c; - progress.total = suite.allTests().length; progress.passed = 0; progress.failed = 0; progress.skipped = 0; @@ -587,7 +596,6 @@ const collectTestIds = (treeItem?: TreeItem): string[] => { }; type Progress = { - total: number; passed: number; failed: number; skipped: number; diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index f5caccf209..0c5d754ca6 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -37,14 +37,17 @@ export const Workbench: React.FunctionComponent<{ hideTimelineBars?: boolean, hideStackFrames?: boolean, showSourcesFirst?: boolean, + rootDir?: string, defaultSourceLocation?: Location, -}> = ({ model, hideTimelineBars, hideStackFrames, showSourcesFirst, defaultSourceLocation }) => { +}> = ({ model, hideTimelineBars, hideStackFrames, showSourcesFirst, rootDir, defaultSourceLocation }) => { const [selectedAction, setSelectedAction] = React.useState(undefined); const [highlightedAction, setHighlightedAction] = React.useState(); const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState('actions'); const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState(showSourcesFirst ? 'source' : 'call'); const activeAction = model ? highlightedAction || selectedAction : undefined; + const sources = React.useMemo(() => model?.sources || new Map(), [model]); + React.useEffect(() => { if (selectedAction && model?.actions.includes(selectedAction)) return; @@ -68,7 +71,12 @@ export const Workbench: React.FunctionComponent<{ const sourceTab: TabbedPaneTabModel = { id: 'source', title: 'Source', - render: () => + render: () => }; const consoleTab: TabbedPaneTabModel = { id: 'console', diff --git a/packages/web/src/components/codeMirrorWrapper.css b/packages/web/src/components/codeMirrorWrapper.css index a6af4f88b3..2adf030c08 100644 --- a/packages/web/src/components/codeMirrorWrapper.css +++ b/packages/web/src/components/codeMirrorWrapper.css @@ -109,7 +109,7 @@ body.dark-mode .CodeMirror span.cm-type { } .CodeMirror-cursor { - border-left: 1px solid #bebebe; + border-left: 1px solid var(--vscode-editor-foreground) !important; } .CodeMirror div.CodeMirror-selected { diff --git a/packages/web/src/components/codeMirrorWrapper.tsx b/packages/web/src/components/codeMirrorWrapper.tsx index c333e6ee0e..e51910f587 100644 --- a/packages/web/src/components/codeMirrorWrapper.tsx +++ b/packages/web/src/components/codeMirrorWrapper.tsx @@ -149,9 +149,9 @@ export const CodeMirrorWrapper: React.FC = ({ codemirrorRef.current!.highlight = highlight; codemirrorRef.current!.widgets = widgets; } - - if (revealLine && codemirrorRef.current!.cm.lineCount() >= revealLine) - codemirror.scrollIntoView({ line: revealLine - 1, ch: 0 }, 50); + // Line-less locations have line = 0, but they mean to reveal the file. + if (typeof revealLine === 'number' && codemirrorRef.current!.cm.lineCount() >= revealLine) + codemirror.scrollIntoView({ line: Math.max(0, revealLine - 1), ch: 0 }, 50); }, [codemirror, text, highlight, revealLine, focusOnChange, onChange]); return
; diff --git a/packages/web/src/components/tabbedPane.tsx b/packages/web/src/components/tabbedPane.tsx index a6acc36977..61474f0ee7 100644 --- a/packages/web/src/components/tabbedPane.tsx +++ b/packages/web/src/components/tabbedPane.tsx @@ -53,7 +53,7 @@ export const TabbedPane: React.FunctionComponent<{ if (tab.component) return
{tab.component}
; if (selectedTab === tab.id) - return
{tab.component || tab.render!()}
; + return
{tab.render!()}
; }) } diff --git a/packages/web/src/components/toolbar.css b/packages/web/src/components/toolbar.css index f8d4b97471..1aea35e2e7 100644 --- a/packages/web/src/components/toolbar.css +++ b/packages/web/src/components/toolbar.css @@ -15,19 +15,35 @@ */ .toolbar { + position: relative; display: flex; - box-shadow: var(--box-shadow); background-color: var(--vscode-sideBar-background); color: var(--vscode-sideBarTitle-foreground); min-height: 35px; align-items: center; flex: none; - z-index: 2; + padding-right: 2px; } -.toolbar-linewrap { +.toolbar:after { + content: ''; display: block; - flex: auto; + position: absolute; + pointer-events: none; + top: 0; + bottom: 0; + left: -2px; + right: -2px; + box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px; + z-index: 100; +} + +.toolbar.no-shadow:after { + box-shadow: none; +} + +.toolbar.no-min-height { + min-height: 0; } .toolbar input { diff --git a/packages/web/src/components/toolbar.tsx b/packages/web/src/components/toolbar.tsx index ddfede6176..5e11de75f7 100644 --- a/packages/web/src/components/toolbar.tsx +++ b/packages/web/src/components/toolbar.tsx @@ -17,11 +17,15 @@ import './toolbar.css'; import * as React from 'react'; -export interface ToolbarProps { -} +type ToolbarProps = { + noShadow?: boolean; + noMinHeight?: boolean; +}; export const Toolbar: React.FC> = ({ - children + noShadow, + children, + noMinHeight }) => { - return
{children}
; + return
{children}
; }; diff --git a/packages/web/src/components/toolbarButton.css b/packages/web/src/components/toolbarButton.css index 9686b2f22f..53c3abc672 100644 --- a/packages/web/src/components/toolbarButton.css +++ b/packages/web/src/components/toolbarButton.css @@ -21,7 +21,7 @@ color: var(--vscode-sideBarTitle-foreground); background: transparent; padding: 4px; - margin: 0 4px; + margin: 0 2px; cursor: pointer; display: inline-flex; align-items: center; @@ -43,3 +43,7 @@ .toolbar-button.toggled { color: var(--vscode-notificationLink-foreground); } + +.toolbar-button.toggled .codicon { + font-weight: bold; +} diff --git a/packages/web/src/components/toolbarButton.tsx b/packages/web/src/components/toolbarButton.tsx index 9242b734a9..7a0d6d5ca9 100644 --- a/packages/web/src/components/toolbarButton.tsx +++ b/packages/web/src/components/toolbarButton.tsx @@ -41,6 +41,7 @@ export const ToolbarButton: React.FC className={className} onMouseDown={preventDefault} onClick={onClick} + onDoubleClick={preventDefault} title={title} disabled={!!disabled}> {icon && } @@ -48,4 +49,7 @@ export const ToolbarButton: React.FC ; }; -const preventDefault = (e: any) => e.preventDefault(); +const preventDefault = (e: any) => { + e.stopPropagation(); + e.preventDefault(); +}; diff --git a/packages/web/src/components/xtermWrapper.tsx b/packages/web/src/components/xtermWrapper.tsx index edbbb90018..6c60e7a93b 100644 --- a/packages/web/src/components/xtermWrapper.tsx +++ b/packages/web/src/components/xtermWrapper.tsx @@ -17,8 +17,9 @@ import * as React from 'react'; import './xtermWrapper.css'; import type { ITheme, Terminal } from 'xterm'; +import type { FitAddon } from 'xterm-addon-fit'; import type { XtermModule } from './xtermModule'; -import { isDarkTheme } from '@web/theme'; +import { currentTheme, addThemeListener, removeThemeListener } from '@web/theme'; export type XtermDataSource = { pending: (string | Uint8Array)[]; @@ -28,12 +29,23 @@ export type XtermDataSource = { }; export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({ - source + source, }) => { const xtermElement = React.useRef(null); + const [theme, setTheme] = React.useState(currentTheme()); const [modulePromise] = React.useState>(import('./xtermModule').then(m => m.default)); - const terminal = React.useRef(null); + const terminal = React.useRef<{ terminal: Terminal, fitAddon: FitAddon }| null>(null); + React.useEffect(() => { + addThemeListener(setTheme); + return () => removeThemeListener(setTheme); + }, []); + + React.useEffect(() => { + const oldSourceWrite = source.write; + const oldSourceClear = source.clear; + let resizeObserver: ResizeObserver | undefined; + (async () => { // Always load the module first. const { Terminal, FitAddon } = await modulePromise; @@ -41,7 +53,7 @@ export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({ if (!element) return; - if (terminal.current) + if (terminal.current && terminal) return; const newTerminal = new Terminal({ @@ -49,7 +61,7 @@ export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({ fontSize: 13, scrollback: 10000, fontFamily: 'var(--vscode-editor-font-family)', - theme: isDarkTheme() ? darkTheme : lightTheme + theme: theme === 'dark-mode' ? darkTheme : lightTheme }); const fitAddon = new FitAddon(); @@ -66,16 +78,30 @@ export const XtermWrapper: React.FC<{ source: XtermDataSource }> = ({ }; newTerminal.open(element); fitAddon.fit(); - terminal.current = newTerminal; - const resizeObserver = new ResizeObserver(() => { - source.resize(newTerminal.cols, newTerminal.rows); - fitAddon.fit(); + terminal.current = { terminal: newTerminal, fitAddon }; + resizeObserver = new ResizeObserver(() => { + // Fit reads data from the terminal itself, which updates lazily, probably on some timer + // or mutation observer. Work around it. + setTimeout(() => { + fitAddon.fit(); + source.resize(newTerminal.cols, newTerminal.rows); + }, 100); }); resizeObserver.observe(element); })(); - }, [modulePromise, terminal, xtermElement, source]); - return
-
; + return () => { + source.clear = oldSourceClear; + source.write = oldSourceWrite; + resizeObserver?.disconnect(); + }; + }, [modulePromise, terminal, xtermElement, source, theme]); + + React.useEffect(() => { + if (terminal.current) + terminal.current.terminal.options.theme = theme === 'dark-mode' ? darkTheme : lightTheme; + }, [theme]); + + return
; }; const lightTheme: ITheme = { diff --git a/packages/web/src/theme.ts b/packages/web/src/theme.ts index cdafcb7bc6..44f663f9e6 100644 --- a/packages/web/src/theme.ts +++ b/packages/web/src/theme.ts @@ -34,9 +34,12 @@ export function applyTheme() { document.body.classList.add('dark-mode'); } +type Theme = 'dark-mode' | 'light-mode'; + +const listeners = new Set<(theme: Theme) => void>(); export function toggleTheme() { const oldTheme = settings.getString('theme', 'light-mode'); - let newTheme: string; + let newTheme: Theme; if (oldTheme === 'dark-mode') newTheme = 'light-mode'; else @@ -46,8 +49,18 @@ export function toggleTheme() { document.body.classList.remove(oldTheme); document.body.classList.add(newTheme); settings.setString('theme', newTheme); + for (const listener of listeners) + listener(newTheme); } -export function isDarkTheme() { - return document.body.classList.contains('dark-mode'); +export function addThemeListener(listener: (theme: 'light-mode' | 'dark-mode') => void) { + listeners.add(listener); +} + +export function removeThemeListener(listener: (theme: Theme) => void) { + listeners.delete(listener); +} + +export function currentTheme(): Theme { + return document.body.classList.contains('dark-mode') ? 'dark-mode' : 'light-mode'; } diff --git a/tests/playwright-test/ui-mode-test-run.spec.ts b/tests/playwright-test/ui-mode-test-run.spec.ts index a425fbf48c..aab0fddc38 100644 --- a/tests/playwright-test/ui-mode-test-run.spec.ts +++ b/tests/playwright-test/ui-mode-test-run.spec.ts @@ -59,6 +59,27 @@ test('should run visible', async ({ runUITest }) => { ✅ passes ⊘ skipped `); + + await expect(page.getByTestId('status-line')).toHaveText('4/8 passed (50%)'); +}); + +test('should show running progress', async ({ runUITest }) => { + const page = await runUITest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('test 1', async () => {}); + test('test 2', async () => new Promise(() => {})); + test('test 3', async () => {}); + test('test 4', async () => {}); + `, + }); + + await page.getByTitle('Run all').click(); + await expect(page.getByTestId('status-line')).toHaveText('Running 1/4 passed (25%)'); + await page.getByTitle('Stop').click(); + await expect(page.getByTestId('status-line')).toHaveText('1/4 passed (25%)'); + await page.getByTitle('Reload').click(); + await expect(page.getByTestId('status-line')).toBeHidden(); }); test('should run on hover', async ({ runUITest }) => { diff --git a/tests/playwright-test/ui-mode-test-source.spec.ts b/tests/playwright-test/ui-mode-test-source.spec.ts index 2772f7eb04..3ddf70371e 100644 --- a/tests/playwright-test/ui-mode-test-source.spec.ts +++ b/tests/playwright-test/ui-mode-test-source.spec.ts @@ -41,16 +41,25 @@ test('should show selected test in sources', async ({ runUITest }) => { `); await page.getByTestId('test-tree').getByText('first').click(); + await expect( + page.getByTestId('source-code').locator('.source-tab-file-name') + ).toHaveText('a.test.ts'); await expect( page.locator('.CodeMirror .source-line-running'), ).toHaveText(`3 test('first', () => {});`); await page.getByTestId('test-tree').getByText('second').click(); + await expect( + page.getByTestId('source-code').locator('.source-tab-file-name') + ).toHaveText('a.test.ts'); await expect( page.locator('.CodeMirror .source-line-running'), ).toHaveText(`4 test('second', () => {});`); await page.getByTestId('test-tree').getByText('third').click(); + await expect( + page.getByTestId('source-code').locator('.source-tab-file-name') + ).toHaveText('b.test.ts'); await expect( page.locator('.CodeMirror .source-line-running'), ).toHaveText(`3 test('third', () => {});`); diff --git a/tests/playwright-test/ui-mode-test-tree.spec.ts b/tests/playwright-test/ui-mode-test-tree.spec.ts index c85d5cbf9b..a57c8d035d 100644 --- a/tests/playwright-test/ui-mode-test-tree.spec.ts +++ b/tests/playwright-test/ui-mode-test-tree.spec.ts @@ -85,7 +85,7 @@ test('should traverse up/down', async ({ runUITest }) => { test('should expand / collapse groups', async ({ runUITest }) => { const page = await runUITest(basicTestTree); - await page.getByText('suite').click(); + await page.getByTestId('test-tree').getByText('suite').click(); await page.keyboard.press('ArrowRight'); await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(` ▼ ◯ a.test.ts @@ -104,7 +104,7 @@ test('should expand / collapse groups', async ({ runUITest }) => { ► ◯ suite <= `); - await page.getByText('passes').first().click(); + await page.getByTestId('test-tree').getByText('passes').first().click(); await page.keyboard.press('ArrowLeft'); await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(` ▼ ◯ a.test.ts <= diff --git a/tests/playwright-test/ui-mode-test-watch.spec.ts b/tests/playwright-test/ui-mode-test-watch.spec.ts index 4ea90196a6..8b5d48c782 100644 --- a/tests/playwright-test/ui-mode-test-watch.spec.ts +++ b/tests/playwright-test/ui-mode-test-watch.spec.ts @@ -29,6 +29,12 @@ test('should watch files', async ({ runUITest, writeFiles }) => { await page.getByText('fails').click(); await page.getByRole('listitem').filter({ hasText: 'fails' }).getByTitle('Watch').click(); + await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(` + ▼ ◯ a.test.ts + ◯ passes + ◯ fails 👁 <= + `); + await page.getByRole('listitem').filter({ hasText: 'fails' }).getByTitle('Run').click(); await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`