mirror of
https://github.com/microsoft/playwright.git
synced 2024-12-11 12:33:45 +03:00
chore(ui): ui polish / nits (#21781)
This commit is contained in:
parent
0728d0f7fb
commit
8a65cf9aac
@ -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 }));
|
||||
|
@ -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)
|
||||
|
@ -153,4 +153,5 @@ body.dark-mode .window-header {
|
||||
|
||||
.snapshot-tab .cm-wrapper {
|
||||
line-height: 23px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
@ -137,7 +137,6 @@ export const SnapshotTab: React.FunctionComponent<{
|
||||
setHighlightedLocator('');
|
||||
setIsInspecting(!pickerVisible);
|
||||
}}>Pick locator</ToolbarButton>
|
||||
<div style={{ width: 5 }}></div>
|
||||
{['action', 'before', 'after'].map(tab => {
|
||||
return <TabbedPaneTab
|
||||
id={tab}
|
||||
|
@ -21,3 +21,13 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.source-tab-file-name {
|
||||
height: 24px;
|
||||
margin-left: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--vscode-breadcrumb-background);
|
||||
box-shadow: var(--vscode-scrollbar-shadow) 0 6px 6px -6px;
|
||||
z-index: 200;
|
||||
}
|
||||
|
@ -29,8 +29,9 @@ export const SourceTab: React.FunctionComponent<{
|
||||
action: ActionTraceEvent | undefined,
|
||||
sources: Map<string, SourceModel>,
|
||||
hideStackFrames?: boolean,
|
||||
rootDir?: string,
|
||||
fallbackLocation?: StackFrame,
|
||||
}> = ({ action, sources, hideStackFrames, fallbackLocation }) => {
|
||||
}> = ({ action, sources, hideStackFrames, rootDir, fallbackLocation }) => {
|
||||
const [lastAction, setLastAction] = React.useState<ActionTraceEvent | undefined>();
|
||||
const [selectedFrame, setSelectedFrame] = React.useState<number>(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 = `<Unable to read "${location.file}">`;
|
||||
}
|
||||
}
|
||||
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 <SplitView sidebarSize={200} orientation='horizontal' sidebarHidden={hideStackFrames}>
|
||||
<CodeMirrorWrapper text={source.content || ''} language='javascript' highlight={highlight} revealLine={targetLine} readOnly={true} lineNumbers={true} />
|
||||
<div className='vbox' data-testid='source-code'>
|
||||
{fileName && <div className='source-tab-file-name'>{fileName}</div>}
|
||||
<CodeMirrorWrapper text={source.content || ''} language='javascript' highlight={highlight} revealLine={targetLine} readOnly={true} lineNumbers={true} />
|
||||
</div>
|
||||
<StackTraceView action={action} selectedFrame={selectedFrame} setSelectedFrame={setSelectedFrame} />
|
||||
</SplitView>;
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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<Map<string, boolean>>(new Map());
|
||||
const [testModel, setTestModel] = React.useState<TestModel>({ config: undefined, rootSuite: undefined });
|
||||
const [progress, setProgress] = React.useState<Progress>({ total: 0, passed: 0, failed: 0, skipped: 0 });
|
||||
const [selectedTest, setSelectedTest] = React.useState<TestCase | undefined>(undefined);
|
||||
const [progress, setProgress] = React.useState<Progress & { total: number } | undefined>();
|
||||
const [selectedItem, setSelectedItem] = React.useState<{ location?: Location, testCase?: TestCase }>({});
|
||||
const [visibleTestIds, setVisibleTestIds] = React.useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean }>();
|
||||
const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean } | undefined>();
|
||||
|
||||
const inputRef = React.useRef<HTMLInputElement>(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<string[] | undefined>(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 <div className='vbox watch-mode'>
|
||||
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
|
||||
<div className='vbox'>
|
||||
<div className={'vbox' + (isShowingOutput ? '' : ' hidden')}>
|
||||
<Toolbar>
|
||||
<div className='section-title' style={{ flex: 'none' }}>Output</div>
|
||||
<ToolbarButton icon='circle-slash' title='Clear output' onClick={() => xtermDataSource.clear()}></ToolbarButton>
|
||||
<div className='spacer'></div>
|
||||
<ToolbarButton icon='close' title='Close' onClick={() => setIsShowingOutput(false)}></ToolbarButton>
|
||||
</Toolbar>
|
||||
<XtermWrapper source={xtermDataSource}></XtermWrapper>;
|
||||
<XtermWrapper source={xtermDataSource}></XtermWrapper>
|
||||
</div>
|
||||
<div className={'vbox' + (isShowingOutput ? ' hidden' : '')}>
|
||||
<TraceView outputDir={outputDir} testCase={selectedTest} result={result} />
|
||||
<TraceView item={selectedItem} rootDir={testModel.config?.rootDir} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='vbox watch-mode-sidebar'>
|
||||
<Toolbar>
|
||||
<Toolbar noShadow={true}>
|
||||
<img src='icon-32x32.png' />
|
||||
<div className='section-title'>Playwright</div>
|
||||
<div className='spacer'></div>
|
||||
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()} />
|
||||
<ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton>
|
||||
<ToolbarButton icon='terminal' title='Toggle output' toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
|
||||
@ -169,9 +170,14 @@ export const WatchModeView: React.FC<{}> = ({
|
||||
setProjectFilters={setProjectFilters}
|
||||
testModel={testModel}
|
||||
runTests={() => runTests(visibleTestIds)} />
|
||||
<Toolbar>
|
||||
<div className='section-title'>Tests</div>
|
||||
<div className='spacer'></div>
|
||||
<Toolbar noMinHeight={true}>
|
||||
{!isRunningTest && !progress && <div className='section-title'>Tests</div>}
|
||||
{!isRunningTest && progress && <div data-testid='status-line' className='status-line'>
|
||||
<div>{progress.passed}/{progress.total} passed ({(progress.passed / progress.total) * 100 | 0}%)</div>
|
||||
</div>}
|
||||
{isRunningTest && progress && <div data-testid='status-line' className='status-line'>
|
||||
<div>Running {progress.passed}/{runningState.testIds.size} passed ({(progress.passed / runningState.testIds.size) * 100 | 0}%)</div>
|
||||
</div>}
|
||||
<ToolbarButton icon='play' title='Run all' onClick={() => runTests(visibleTestIds)} disabled={isRunningTest || isLoading}></ToolbarButton>
|
||||
<ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest || isLoading}></ToolbarButton>
|
||||
</Toolbar>
|
||||
@ -182,19 +188,10 @@ export const WatchModeView: React.FC<{}> = ({
|
||||
testModel={testModel}
|
||||
runningState={runningState}
|
||||
runTests={runTests}
|
||||
onTestSelected={setSelectedTest}
|
||||
onItemSelected={setSelectedItem}
|
||||
setVisibleTestIds={setVisibleTestIds} />
|
||||
</div>
|
||||
</SplitView>
|
||||
<div className='status-line'>
|
||||
<div>Total: {progress.total}</div>
|
||||
{isRunningTest && <div><span className='codicon codicon-loading'></span>{`Running ${visibleTestIds.length}\u2026`}</div>}
|
||||
{isLoading && <div><span className='codicon codicon-loading'></span> {'Loading\u2026'}</div>}
|
||||
{!isRunningTest && <div>Showing: {visibleTestIds.length}</div>}
|
||||
<div>{progress.passed} passed</div>
|
||||
<div>{progress.failed} failed</div>
|
||||
<div>{progress.skipped} skipped</div>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
@ -276,11 +273,11 @@ const TestList: React.FC<{
|
||||
runTests: (testIds: string[]) => void,
|
||||
runningState?: { testIds: Set<string>, 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<TreeState>({ expandedItems: new Map() });
|
||||
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
|
||||
const [watchedTreeIds] = React.useState<Set<string>>(new Set());
|
||||
const [watchedTreeIds, innerSetWatchedTreeIds] = React.useState<{ value: Set<string> }>({ 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<string>) => {
|
||||
const fileNames = new Set<string>();
|
||||
@ -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<{
|
||||
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton>
|
||||
<ToolbarButton icon='go-to-file' title='Open in VS Code' onClick={() => sendMessageNoReply('open', { location: locationToOpen(treeItem) })}></ToolbarButton>
|
||||
<ToolbarButton icon='eye' title='Watch' onClick={() => {
|
||||
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)}></ToolbarButton>
|
||||
watchedTreeIds.value.add(treeItem.id);
|
||||
setWatchedTreeIds(watchedTreeIds.value);
|
||||
}} toggled={watchedTreeIds.value.has(treeItem.id)}></ToolbarButton>
|
||||
</div>;
|
||||
}}
|
||||
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<MultiTraceModel | undefined>();
|
||||
const [counter, setCounter] = React.useState(0);
|
||||
const pollTimer = React.useRef<NodeJS.Timeout | null>(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 <Workbench key='workbench' model={model} hideTimelineBars={true} hideStackFrames={true} showSourcesFirst={true} defaultSourceLocation={testCase?.location} />;
|
||||
return <Workbench
|
||||
key='workbench'
|
||||
model={model}
|
||||
hideTimelineBars={true}
|
||||
hideStackFrames={true}
|
||||
showSourcesFirst={true}
|
||||
rootDir={rootDir}
|
||||
defaultSourceLocation={item.location} />;
|
||||
};
|
||||
|
||||
declare global {
|
||||
@ -478,7 +489,6 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
|
||||
|
||||
let rootSuite: Suite;
|
||||
const progress: Progress = {
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
@ -489,7 +499,6 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
|
||||
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;
|
||||
|
@ -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<ActionTraceEvent | undefined>(undefined);
|
||||
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>();
|
||||
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
|
||||
const [selectedPropertiesTab, setSelectedPropertiesTab] = React.useState<string>(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: () => <SourceTab action={activeAction} sources={model?.sources || new Map()} hideStackFrames={hideStackFrames} fallbackLocation={defaultSourceLocation}/>
|
||||
render: () => <SourceTab
|
||||
action={activeAction}
|
||||
sources={sources}
|
||||
hideStackFrames={hideStackFrames}
|
||||
rootDir={rootDir}
|
||||
fallbackLocation={defaultSourceLocation} />
|
||||
};
|
||||
const consoleTab: TabbedPaneTabModel = {
|
||||
id: 'console',
|
||||
|
@ -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 {
|
||||
|
@ -149,9 +149,9 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
|
||||
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 <div className='cm-wrapper' ref={codemirrorElement}></div>;
|
||||
|
@ -53,7 +53,7 @@ export const TabbedPane: React.FunctionComponent<{
|
||||
if (tab.component)
|
||||
return <div key={tab.id} className='tab-content' style={{ display: selectedTab === tab.id ? 'inherit' : 'none' }}>{tab.component}</div>;
|
||||
if (selectedTab === tab.id)
|
||||
return <div key={tab.id} className='tab-content'>{tab.component || tab.render!()}</div>;
|
||||
return <div key={tab.id} className='tab-content'>{tab.render!()}</div>;
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
@ -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 {
|
||||
|
@ -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<React.PropsWithChildren<ToolbarProps>> = ({
|
||||
children
|
||||
noShadow,
|
||||
children,
|
||||
noMinHeight
|
||||
}) => {
|
||||
return <div className='toolbar'>{children}</div>;
|
||||
return <div className={'toolbar' + (noShadow ? ' no-shadow' : '') + (noMinHeight ? ' no-min-height' : '')}>{children}</div>;
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -41,6 +41,7 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
|
||||
className={className}
|
||||
onMouseDown={preventDefault}
|
||||
onClick={onClick}
|
||||
onDoubleClick={preventDefault}
|
||||
title={title}
|
||||
disabled={!!disabled}>
|
||||
{icon && <span className={`codicon codicon-${icon}`} style={children ? { marginRight: 5 } : {}}></span>}
|
||||
@ -48,4 +49,7 @@ export const ToolbarButton: React.FC<React.PropsWithChildren<ToolbarButtonProps>
|
||||
</button>;
|
||||
};
|
||||
|
||||
const preventDefault = (e: any) => e.preventDefault();
|
||||
const preventDefault = (e: any) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
|
@ -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<HTMLDivElement>(null);
|
||||
const [theme, setTheme] = React.useState(currentTheme());
|
||||
const [modulePromise] = React.useState<Promise<XtermModule>>(import('./xtermModule').then(m => m.default));
|
||||
const terminal = React.useRef<Terminal | null>(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 <div className='xterm-wrapper' style={{ flex: 'auto' }} ref={xtermElement}>
|
||||
</div>;
|
||||
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 <div className='xterm-wrapper' style={{ flex: 'auto' }} ref={xtermElement}></div>;
|
||||
};
|
||||
|
||||
const lightTheme: ITheme = {
|
||||
|
@ -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';
|
||||
}
|
||||
|
@ -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 }) => {
|
||||
|
@ -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', () => {});`);
|
||||
|
@ -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 <=
|
||||
|
@ -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(`
|
||||
|
Loading…
Reference in New Issue
Block a user