chore: render parse errors in the UI mode (#22888)

Fixes: https://github.com/microsoft/playwright/issues/22863
This commit is contained in:
Pavel Feldman 2023-05-08 18:51:27 -07:00 committed by GitHub
parent 5fb426e7db
commit b10cc03314
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 134 additions and 68 deletions

View File

@ -22,7 +22,7 @@ import { serializeError } from '../util';
import type { InternalReporter } from '../reporters/internalReporter'; import type { InternalReporter } from '../reporters/internalReporter';
type TaskTeardown = () => Promise<any> | undefined; type TaskTeardown = () => Promise<any> | undefined;
export type Task<Context> = (context: Context, errors: TestError[]) => Promise<TaskTeardown | void> | undefined; export type Task<Context> = (context: Context, errors: TestError[], softErrors: TestError[]) => Promise<TaskTeardown | void> | undefined;
export class TaskRunner<Context> { export class TaskRunner<Context> {
private _tasks: { name: string, task: Task<Context> }[] = []; private _tasks: { name: string, task: Task<Context> }[] = [];
@ -62,15 +62,16 @@ export class TaskRunner<Context> {
break; break;
debug('pw:test:task')(`"${name}" started`); debug('pw:test:task')(`"${name}" started`);
const errors: TestError[] = []; const errors: TestError[] = [];
const softErrors: TestError[] = [];
try { try {
const teardown = await task(context, errors); const teardown = await task(context, errors, softErrors);
if (teardown) if (teardown)
teardownRunner._tasks.unshift({ name: `teardown for ${name}`, task: teardown }); teardownRunner._tasks.unshift({ name: `teardown for ${name}`, task: teardown });
} catch (e) { } catch (e) {
debug('pw:test:task')(`error in "${name}": `, e); debug('pw:test:task')(`error in "${name}": `, e);
errors.push(serializeError(e)); errors.push(serializeError(e));
} finally { } finally {
for (const error of errors) for (const error of [...softErrors, ...errors])
this._reporter.onError?.(error); this._reporter.onError?.(error);
if (errors.length) { if (errors.length) {
if (!this._isTearDown) if (!this._isTearDown)

View File

@ -63,7 +63,7 @@ export class TestRun {
export function createTaskRunner(config: FullConfigInternal, reporter: InternalReporter): TaskRunner<TestRun> { export function createTaskRunner(config: FullConfigInternal, reporter: InternalReporter): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, config.config.globalTimeout); const taskRunner = new TaskRunner<TestRun>(reporter, config.config.globalTimeout);
addGlobalSetupTasks(taskRunner, config); addGlobalSetupTasks(taskRunner, config);
taskRunner.addTask('load tests', createLoadTask('in-process', true)); taskRunner.addTask('load tests', createLoadTask('in-process', { filterOnly: true, failOnLoadErrors: true }));
addRunTasks(taskRunner, config); addRunTasks(taskRunner, config);
return taskRunner; return taskRunner;
} }
@ -76,7 +76,7 @@ export function createTaskRunnerForWatchSetup(config: FullConfigInternal, report
export function createTaskRunnerForWatch(config: FullConfigInternal, reporter: InternalReporter, additionalFileMatcher?: Matcher): TaskRunner<TestRun> { export function createTaskRunnerForWatch(config: FullConfigInternal, reporter: InternalReporter, additionalFileMatcher?: Matcher): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, 0); const taskRunner = new TaskRunner<TestRun>(reporter, 0);
taskRunner.addTask('load tests', createLoadTask('out-of-process', true, additionalFileMatcher)); taskRunner.addTask('load tests', createLoadTask('out-of-process', { filterOnly: true, failOnLoadErrors: false, additionalFileMatcher }));
addRunTasks(taskRunner, config); addRunTasks(taskRunner, config);
return taskRunner; return taskRunner;
} }
@ -104,7 +104,7 @@ function addRunTasks(taskRunner: TaskRunner<TestRun>, config: FullConfigInternal
export function createTaskRunnerForList(config: FullConfigInternal, reporter: InternalReporter, mode: 'in-process' | 'out-of-process'): TaskRunner<TestRun> { export function createTaskRunnerForList(config: FullConfigInternal, reporter: InternalReporter, mode: 'in-process' | 'out-of-process'): TaskRunner<TestRun> {
const taskRunner = new TaskRunner<TestRun>(reporter, config.config.globalTimeout); const taskRunner = new TaskRunner<TestRun>(reporter, config.config.globalTimeout);
taskRunner.addTask('load tests', createLoadTask(mode, false)); taskRunner.addTask('load tests', createLoadTask(mode, { filterOnly: false, failOnLoadErrors: false }));
taskRunner.addTask('report begin', async ({ reporter, rootSuite }) => { taskRunner.addTask('report begin', async ({ reporter, rootSuite }) => {
reporter.onBegin(config.config, rootSuite!); reporter.onBegin(config.config, rootSuite!);
return () => reporter.onEnd(); return () => reporter.onEnd();
@ -166,11 +166,11 @@ function createRemoveOutputDirsTask(): Task<TestRun> {
}; };
} }
function createLoadTask(mode: 'out-of-process' | 'in-process', shouldFilterOnly: boolean, additionalFileMatcher?: Matcher): Task<TestRun> { function createLoadTask(mode: 'out-of-process' | 'in-process', options: { filterOnly: boolean, failOnLoadErrors: boolean, additionalFileMatcher?: Matcher }): Task<TestRun> {
return async (testRun, errors) => { return async (testRun, errors, softErrors) => {
await collectProjectsAndTestFiles(testRun, additionalFileMatcher); await collectProjectsAndTestFiles(testRun, options.additionalFileMatcher);
await loadFileSuites(testRun, mode, errors); await loadFileSuites(testRun, mode, options.failOnLoadErrors ? errors : softErrors);
testRun.rootSuite = await createRootSuite(testRun, errors, shouldFilterOnly); testRun.rootSuite = await createRootSuite(testRun, options.failOnLoadErrors ? errors : softErrors, !!options.filterOnly);
// Fail when no tests. // Fail when no tests.
if (!testRun.rootSuite.allTests().length && !testRun.config.cliPassWithNoTests && !testRun.config.config.shard) if (!testRun.rootSuite.allTests().length && !testRun.config.cliPassWithNoTests && !testRun.config.config.shard)
throw new Error(`No tests found`); throw new Error(`No tests found`);

View File

@ -19,7 +19,6 @@ import type { ResourceSnapshot } from '@trace/snapshot';
import type * as trace from '@trace/trace'; import type * as trace from '@trace/trace';
import type { ActionTraceEvent, EventTraceEvent } from '@trace/trace'; import type { ActionTraceEvent, EventTraceEvent } from '@trace/trace';
import type { ContextEntry, PageEntry } from '../entries'; import type { ContextEntry, PageEntry } from '../entries';
import type { SerializedError, StackFrame } from '@protocol/channels';
const contextSymbol = Symbol('context'); const contextSymbol = Symbol('context');
const nextInContextSymbol = Symbol('next'); const nextInContextSymbol = Symbol('next');
@ -27,8 +26,14 @@ const prevInListSymbol = Symbol('prev');
const eventsSymbol = Symbol('events'); const eventsSymbol = Symbol('events');
const resourcesSymbol = Symbol('resources'); const resourcesSymbol = Symbol('resources');
export type SourceLocation = {
file: string;
line: number;
source: SourceModel;
};
export type SourceModel = { export type SourceModel = {
errors: { error: SerializedError['error'], location: StackFrame }[]; errors: { line: number, message: string }[];
content: string | undefined; content: string | undefined;
}; };
@ -203,7 +208,7 @@ function collectSources(actions: trace.ActionTraceEvent[]): Map<string, SourceMo
} }
} }
if (action.error && action.stack?.[0]) if (action.error && action.stack?.[0])
result.get(action.stack[0].file)!.errors.push({ error: action.error, location: action.stack?.[0] }); result.get(action.stack[0].file)!.errors.push({ line: action.stack?.[0].line || 0, message: action.error.message });
} }
return result; return result;
} }

View File

@ -22,15 +22,14 @@ import './sourceTab.css';
import { StackTraceView } from './stackTrace'; import { StackTraceView } from './stackTrace';
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import type { SourceHighlight } from '@web/components/codeMirrorWrapper'; import type { SourceHighlight } from '@web/components/codeMirrorWrapper';
import type { SourceModel } from './modelUtil'; import type { SourceLocation, SourceModel } from './modelUtil';
import type { StackFrame } from '@protocol/channels';
export const SourceTab: React.FunctionComponent<{ export const SourceTab: React.FunctionComponent<{
action: ActionTraceEvent | undefined, action: ActionTraceEvent | undefined,
sources: Map<string, SourceModel>, sources: Map<string, SourceModel>,
hideStackFrames?: boolean, hideStackFrames?: boolean,
rootDir?: string, rootDir?: string,
fallbackLocation?: StackFrame, fallbackLocation?: SourceLocation,
}> = ({ action, sources, hideStackFrames, rootDir, fallbackLocation }) => { }> = ({ action, sources, hideStackFrames, rootDir, fallbackLocation }) => {
const [lastAction, setLastAction] = React.useState<ActionTraceEvent | undefined>(); const [lastAction, setLastAction] = React.useState<ActionTraceEvent | undefined>();
const [selectedFrame, setSelectedFrame] = React.useState<number>(0); const [selectedFrame, setSelectedFrame] = React.useState<number>(0);
@ -43,31 +42,34 @@ export const SourceTab: React.FunctionComponent<{
}, [action, lastAction, setLastAction, setSelectedFrame]); }, [action, lastAction, setLastAction, setSelectedFrame]);
const { source, highlight, targetLine, fileName } = useAsyncMemo<{ source: SourceModel, targetLine?: number, fileName?: string, highlight: SourceHighlight[] }>(async () => { const { source, highlight, targetLine, fileName } = useAsyncMemo<{ source: SourceModel, targetLine?: number, fileName?: string, highlight: SourceHighlight[] }>(async () => {
const location = action?.stack?.[selectedFrame] || fallbackLocation; const actionLocation = action?.stack?.[selectedFrame];
if (!location?.file) const shouldUseFallback = !actionLocation?.file;
return { source: { errors: [], content: undefined }, targetLine: 0, highlight: [] }; if (shouldUseFallback && !shouldUseFallback)
return { source: { file: '', errors: [], content: undefined }, targetLine: 0, highlight: [] };
let source = sources.get(location.file); const file = shouldUseFallback ? fallbackLocation!.file : actionLocation.file;
let source = sources.get(file);
// Fallback location can fall outside the sources model. // Fallback location can fall outside the sources model.
if (!source) { if (!source) {
source = { errors: [], content: undefined }; source = { errors: fallbackLocation?.source?.errors || [], content: undefined };
sources.set(location.file, source); sources.set(file, source);
} }
const targetLine = location.line || 0; const targetLine = shouldUseFallback ? fallbackLocation?.line || source.errors[0]?.line || 0 : actionLocation.line;
const fileName = rootDir && location.file.startsWith(rootDir) ? location.file.substring(rootDir.length + 1) : location.file; const fileName = rootDir && file.startsWith(rootDir) ? file.substring(rootDir.length + 1) : file;
const highlight: SourceHighlight[] = source.errors.map(e => ({ type: 'error', line: e.location.line, message: e.error!.message })); const highlight: SourceHighlight[] = source.errors.map(e => ({ type: 'error', line: e.line, message: e.message }));
highlight.push({ line: targetLine, type: 'running' }); highlight.push({ line: targetLine, type: 'running' });
if (source.content === undefined || fallbackLocation) { // After the source update, but before the test run, don't trust the cache.
const sha1 = await calculateSha1(location.file); if (source.content === undefined || shouldUseFallback) {
const sha1 = await calculateSha1(file);
try { try {
let response = await fetch(`sha1/src@${sha1}.txt`); let response = await fetch(`sha1/src@${sha1}.txt`);
if (response.status === 404) if (response.status === 404)
response = await fetch(`file?path=${location.file}`); response = await fetch(`file?path=${file}`);
source.content = await response.text(); source.content = await response.text();
} catch { } catch {
source.content = `<Unable to read "${location.file}">`; source.content = `<Unable to read "${file}">`;
} }
} }
return { source, highlight, targetLine, fileName }; return { source, highlight, targetLine, fileName };

View File

@ -25,6 +25,7 @@ import type { TeleTestCase } from '@testIsomorphic/teleReceiver';
import type { FullConfig, Suite, TestCase, Location, TestError } from '@playwright/test/types/testReporter'; import type { FullConfig, Suite, TestCase, Location, TestError } from '@playwright/test/types/testReporter';
import { SplitView } from '@web/components/splitView'; import { SplitView } from '@web/components/splitView';
import { idForAction, MultiTraceModel } from './modelUtil'; import { idForAction, MultiTraceModel } from './modelUtil';
import type { SourceLocation } from './modelUtil';
import './uiModeView.css'; import './uiModeView.css';
import { ToolbarButton } from '@web/components/toolbarButton'; import { ToolbarButton } from '@web/components/toolbarButton';
import { Toolbar } from '@web/components/toolbar'; import { Toolbar } from '@web/components/toolbar';
@ -37,7 +38,7 @@ import { artifactsFolderName } from '@testIsomorphic/folders';
import { msToString, settings, useSetting } from '@web/uiUtils'; import { msToString, settings, useSetting } from '@web/uiUtils';
import type { ActionTraceEvent } from '@trace/trace'; import type { ActionTraceEvent } from '@trace/trace';
let updateRootSuite: (config: FullConfig, rootSuite: Suite, progress: Progress | undefined) => void = () => {}; let updateRootSuite: (config: FullConfig, rootSuite: Suite, loadErrors: TestError[], progress: Progress | undefined) => void = () => {};
let runWatchedTests = (fileNames: string[]) => {}; let runWatchedTests = (fileNames: string[]) => {};
let xtermSize = { cols: 80, rows: 24 }; let xtermSize = { cols: 80, rows: 24 };
@ -54,6 +55,7 @@ const xtermDataSource: XtermDataSource = {
type TestModel = { type TestModel = {
config: FullConfig | undefined; config: FullConfig | undefined;
rootSuite: Suite | undefined; rootSuite: Suite | undefined;
loadErrors: TestError[];
}; };
export const UIModeView: React.FC<{}> = ({ export const UIModeView: React.FC<{}> = ({
@ -67,9 +69,9 @@ export const UIModeView: React.FC<{}> = ({
['skipped', false], ['skipped', false],
])); ]));
const [projectFilters, setProjectFilters] = React.useState<Map<string, boolean>>(new Map()); const [projectFilters, setProjectFilters] = React.useState<Map<string, boolean>>(new Map());
const [testModel, setTestModel] = React.useState<TestModel>({ config: undefined, rootSuite: undefined }); const [testModel, setTestModel] = React.useState<TestModel>({ config: undefined, rootSuite: undefined, loadErrors: [] });
const [progress, setProgress] = React.useState<Progress & { total: number } | undefined>(); const [progress, setProgress] = React.useState<Progress & { total: number } | undefined>();
const [selectedItem, setSelectedItem] = React.useState<{ location?: Location, testCase?: TestCase }>({}); const [selectedItem, setSelectedItem] = React.useState<{ testFile?: SourceLocation, testCase?: TestCase }>({});
const [visibleTestIds, setVisibleTestIds] = React.useState<Set<string>>(new Set()); const [visibleTestIds, setVisibleTestIds] = React.useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = React.useState<boolean>(false); const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean } | undefined>(); const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean } | undefined>();
@ -81,21 +83,21 @@ export const UIModeView: React.FC<{}> = ({
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
const reloadTests = () => { const reloadTests = React.useCallback(() => {
setIsLoading(true); setIsLoading(true);
setWatchedTreeIds({ value: new Set() }); setWatchedTreeIds({ value: new Set() });
updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), undefined); updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), [], undefined);
refreshRootSuite(true).then(() => { refreshRootSuite(true).then(() => {
setIsLoading(false); setIsLoading(false);
}); });
}; }, []);
React.useEffect(() => { React.useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus();
reloadTests(); reloadTests();
}, []); }, [reloadTests]);
updateRootSuite = (config: FullConfig, rootSuite: Suite, newProgress: Progress | undefined) => { updateRootSuite = React.useCallback((config: FullConfig, rootSuite: Suite, loadErrors: TestError[], newProgress: Progress | undefined) => {
const selectedProjects = config.configFile ? settings.getObject<string[] | undefined>(config.configFile + ':projects', undefined) : undefined; const selectedProjects = config.configFile ? settings.getObject<string[] | undefined>(config.configFile + ':projects', undefined) : undefined;
for (const projectName of projectFilters.keys()) { for (const projectName of projectFilters.keys()) {
if (!rootSuite.suites.find(s => s.title === projectName)) if (!rootSuite.suites.find(s => s.title === projectName))
@ -108,13 +110,13 @@ export const UIModeView: React.FC<{}> = ({
if (!selectedProjects && projectFilters.size && ![...projectFilters.values()].includes(true)) if (!selectedProjects && projectFilters.size && ![...projectFilters.values()].includes(true))
projectFilters.set(projectFilters.entries().next().value[0], true); projectFilters.set(projectFilters.entries().next().value[0], true);
setTestModel({ config, rootSuite }); setTestModel({ config, rootSuite, loadErrors });
setProjectFilters(new Map(projectFilters)); setProjectFilters(new Map(projectFilters));
if (runningState && newProgress) if (runningState && newProgress)
setProgress({ ...newProgress, total: runningState.testIds.size }); setProgress({ ...newProgress, total: runningState.testIds.size });
else if (!newProgress) else if (!newProgress)
setProgress(undefined); setProgress(undefined);
}; }, [projectFilters, runningState]);
const runTests = React.useCallback((mode: 'queue-if-busy' | 'bounce-if-busy', testIds: Set<string>) => { const runTests = React.useCallback((mode: 'queue-if-busy' | 'bounce-if-busy', testIds: Set<string>) => {
if (mode === 'bounce-if-busy' && runningState) if (mode === 'bounce-if-busy' && runningState)
@ -300,7 +302,7 @@ const TestList: React.FC<{
statusFilters: Map<string, boolean>, statusFilters: Map<string, boolean>,
projectFilters: Map<string, boolean>, projectFilters: Map<string, boolean>,
filterText: string, filterText: string,
testModel: { rootSuite: Suite | undefined, config: FullConfig | undefined }, testModel: TestModel,
runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set<string>) => void, runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set<string>) => void,
runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean }, runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean },
watchAll: boolean, watchAll: boolean,
@ -308,7 +310,7 @@ const TestList: React.FC<{
setWatchedTreeIds: (ids: { value: Set<string> }) => void, setWatchedTreeIds: (ids: { value: Set<string> }) => void,
isLoading?: boolean, isLoading?: boolean,
setVisibleTestIds: (testIds: Set<string>) => void, setVisibleTestIds: (testIds: Set<string>) => void,
onItemSelected: (item: { testCase?: TestCase, location?: Location }) => void, onItemSelected: (item: { testCase?: TestCase, testFile?: SourceLocation }) => void,
requestedCollapseAllCount: number, requestedCollapseAllCount: number,
}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, setVisibleTestIds, requestedCollapseAllCount }) => { }> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, setVisibleTestIds, requestedCollapseAllCount }) => {
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() }); const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
@ -317,7 +319,7 @@ const TestList: React.FC<{
// Build the test tree. // Build the test tree.
const { rootItem, treeItemMap, fileNames } = React.useMemo(() => { const { rootItem, treeItemMap, fileNames } = React.useMemo(() => {
let rootItem = createTree(testModel.rootSuite, projectFilters); let rootItem = createTree(testModel.rootSuite, testModel.loadErrors, projectFilters);
filterTree(rootItem, filterText, statusFilters, runningState?.testIds); filterTree(rootItem, filterText, statusFilters, runningState?.testIds);
sortAndPropagateStatus(rootItem); sortAndPropagateStatus(rootItem);
rootItem = shortenRoot(rootItem); rootItem = shortenRoot(rootItem);
@ -375,15 +377,25 @@ const TestList: React.FC<{
// Compute selected item. // Compute selected item.
const { selectedTreeItem } = React.useMemo(() => { const { selectedTreeItem } = React.useMemo(() => {
const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined; const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined;
const location = selectedTreeItem?.location; let testFile: SourceLocation | undefined;
if (selectedTreeItem) {
testFile = {
file: selectedTreeItem.location.file,
line: selectedTreeItem.location.line,
source: {
errors: testModel.loadErrors.filter(e => e.location?.file === selectedTreeItem.location.file).map(e => ({ line: e.location!.line, message: e.message! })),
content: undefined,
}
};
}
let selectedTest: TestCase | undefined; let selectedTest: TestCase | undefined;
if (selectedTreeItem?.kind === 'test') if (selectedTreeItem?.kind === 'test')
selectedTest = selectedTreeItem.test; selectedTest = selectedTreeItem.test;
else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1) else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1)
selectedTest = selectedTreeItem.tests[0]; selectedTest = selectedTreeItem.tests[0];
onItemSelected({ testCase: selectedTest, location }); onItemSelected({ testCase: selectedTest, testFile });
return { selectedTreeItem }; return { selectedTreeItem };
}, [onItemSelected, selectedTreeItemId, treeItemMap]); }, [onItemSelected, selectedTreeItemId, testModel, treeItemMap]);
// Update watch all. // Update watch all.
React.useEffect(() => { React.useEffect(() => {
@ -471,12 +483,13 @@ const TestList: React.FC<{
runningState.itemSelectedByUser = true; runningState.itemSelectedByUser = true;
setSelectedTreeItemId(treeItem.id); setSelectedTreeItemId(treeItem.id);
}} }}
isError={treeItem => treeItem.kind === 'group' ? treeItem.hasLoadErrors : false}
autoExpandDepth={filterText ? 5 : 1} autoExpandDepth={filterText ? 5 : 1}
noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />; noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />;
}; };
const TraceView: React.FC<{ const TraceView: React.FC<{
item: { location?: Location, testCase?: TestCase }, item: { testFile?: SourceLocation, testCase?: TestCase },
rootDir?: string, rootDir?: string,
}> = ({ item, rootDir }) => { }> = ({ item, rootDir }) => {
const [model, setModel] = React.useState<MultiTraceModel | undefined>(); const [model, setModel] = React.useState<MultiTraceModel | undefined>();
@ -543,7 +556,7 @@ const TraceView: React.FC<{
rootDir={rootDir} rootDir={rootDir}
initialSelection={initialSelection} initialSelection={initialSelection}
onSelectionChanged={onSelectionChanged} onSelectionChanged={onSelectionChanged}
defaultSourceLocation={item.location} />; fallbackLocation={item.testFile} />;
}; };
declare global { declare global {
@ -555,15 +568,15 @@ declare global {
let receiver: TeleReporterReceiver | undefined; let receiver: TeleReporterReceiver | undefined;
let throttleTimer: NodeJS.Timeout | undefined; let throttleTimer: NodeJS.Timeout | undefined;
let throttleData: { config: FullConfig, rootSuite: Suite, progress: Progress } | undefined; let throttleData: { config: FullConfig, rootSuite: Suite, loadErrors: TestError[], progress: Progress } | undefined;
const throttledAction = () => { const throttledAction = () => {
clearTimeout(throttleTimer); clearTimeout(throttleTimer);
throttleTimer = undefined; throttleTimer = undefined;
updateRootSuite(throttleData!.config, throttleData!.rootSuite, throttleData!.progress); updateRootSuite(throttleData!.config, throttleData!.rootSuite, throttleData!.loadErrors, throttleData!.progress);
}; };
const throttleUpdateRootSuite = (config: FullConfig, rootSuite: Suite, progress: Progress, immediate = false) => { const throttleUpdateRootSuite = (config: FullConfig, rootSuite: Suite, loadErrors: TestError[], progress: Progress, immediate = false) => {
throttleData = { config, rootSuite, progress }; throttleData = { config, rootSuite, loadErrors, progress };
if (immediate) if (immediate)
throttledAction(); throttledAction();
else if (!throttleTimer) else if (!throttleTimer)
@ -575,6 +588,7 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
return sendMessage('list', {}); return sendMessage('list', {});
let rootSuite: Suite; let rootSuite: Suite;
let loadErrors: TestError[];
const progress: Progress = { const progress: Progress = {
passed: 0, passed: 0,
failed: 0, failed: 0,
@ -583,21 +597,23 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
let config: FullConfig; let config: FullConfig;
receiver = new TeleReporterReceiver(pathSeparator, { receiver = new TeleReporterReceiver(pathSeparator, {
onBegin: (c: FullConfig, suite: Suite) => { onBegin: (c: FullConfig, suite: Suite) => {
if (!rootSuite) if (!rootSuite) {
rootSuite = suite; rootSuite = suite;
loadErrors = [];
}
config = c; config = c;
progress.passed = 0; progress.passed = 0;
progress.failed = 0; progress.failed = 0;
progress.skipped = 0; progress.skipped = 0;
throttleUpdateRootSuite(config, rootSuite, progress, true); throttleUpdateRootSuite(config, rootSuite, loadErrors, progress, true);
}, },
onEnd: () => { onEnd: () => {
throttleUpdateRootSuite(config, rootSuite, progress, true); throttleUpdateRootSuite(config, rootSuite, loadErrors, progress, true);
}, },
onTestBegin: () => { onTestBegin: () => {
throttleUpdateRootSuite(config, rootSuite, progress); throttleUpdateRootSuite(config, rootSuite, loadErrors, progress);
}, },
onTestEnd: (test: TestCase) => { onTestEnd: (test: TestCase) => {
@ -607,11 +623,13 @@ const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
++progress.failed; ++progress.failed;
else else
++progress.passed; ++progress.passed;
throttleUpdateRootSuite(config, rootSuite, progress); throttleUpdateRootSuite(config, rootSuite, loadErrors, progress);
}, },
onError: (error: TestError) => { onError: (error: TestError) => {
xtermDataSource.write((error.stack || error.value || '') + '\n'); xtermDataSource.write((error.stack || error.value || '') + '\n');
loadErrors.push(error);
throttleUpdateRootSuite(config, rootSuite, loadErrors, progress);
}, },
}); });
receiver._setClearPreviousResultsWhenTestBegins(); receiver._setClearPreviousResultsWhenTestBegins();
@ -708,6 +726,7 @@ type TreeItemBase = {
type GroupItem = TreeItemBase & { type GroupItem = TreeItemBase & {
kind: 'group'; kind: 'group';
subKind: 'folder' | 'file' | 'describe'; subKind: 'folder' | 'file' | 'describe';
hasLoadErrors: boolean;
children: (TestCaseItem | GroupItem)[]; children: (TestCaseItem | GroupItem)[];
}; };
@ -743,13 +762,14 @@ function getFileItem(rootItem: GroupItem, filePath: string[], isFile: boolean, f
parent: parentFileItem, parent: parentFileItem,
children: [], children: [],
status: 'none', status: 'none',
hasLoadErrors: false,
}; };
parentFileItem.children.push(fileItem); parentFileItem.children.push(fileItem);
fileItems.set(fileName, fileItem); fileItems.set(fileName, fileItem);
return fileItem; return fileItem;
} }
function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, boolean>): GroupItem { function createTree(rootSuite: Suite | undefined, loadErrors: TestError[], projectFilters: Map<string, boolean>): GroupItem {
const filterProjects = [...projectFilters.values()].some(Boolean); const filterProjects = [...projectFilters.values()].some(Boolean);
const rootItem: GroupItem = { const rootItem: GroupItem = {
kind: 'group', kind: 'group',
@ -761,6 +781,7 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
parent: undefined, parent: undefined,
children: [], children: [],
status: 'none', status: 'none',
hasLoadErrors: false,
}; };
const visitSuite = (projectName: string, parentSuite: Suite, parentGroup: GroupItem) => { const visitSuite = (projectName: string, parentSuite: Suite, parentGroup: GroupItem) => {
@ -778,6 +799,7 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
parent: parentGroup, parent: parentGroup,
children: [], children: [],
status: 'none', status: 'none',
hasLoadErrors: false,
}; };
parentGroup.children.push(group); parentGroup.children.push(group);
} }
@ -842,6 +864,12 @@ function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, bo
const fileItem = getFileItem(rootItem, fileSuite.location!.file.split(pathSeparator), true, fileMap); const fileItem = getFileItem(rootItem, fileSuite.location!.file.split(pathSeparator), true, fileMap);
visitSuite(projectSuite.title, fileSuite, fileItem); visitSuite(projectSuite.title, fileSuite, fileItem);
} }
for (const loadError of loadErrors) {
if (!loadError.location)
continue;
const fileItem = getFileItem(rootItem, loadError.location.file.split(pathSeparator), true, fileMap);
fileItem.hasLoadErrors = true;
}
} }
return rootItem; return rootItem;
} }
@ -869,7 +897,7 @@ function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map<
newChildren.push(child); newChildren.push(child);
} else { } else {
visit(child); visit(child);
if (child.children.length) if (child.children.length || child.hasLoadErrors)
newChildren.push(child); newChildren.push(child);
} }
} }

View File

@ -30,7 +30,6 @@ import type { TabbedPaneTabModel } from '@web/components/tabbedPane';
import { Timeline } from './timeline'; import { Timeline } from './timeline';
import './workbench.css'; import './workbench.css';
import { MetadataView } from './metadataView'; import { MetadataView } from './metadataView';
import type { Location } from '../../../playwright-test/types/testReporter';
export const Workbench: React.FunctionComponent<{ export const Workbench: React.FunctionComponent<{
model?: MultiTraceModel, model?: MultiTraceModel,
@ -38,10 +37,10 @@ export const Workbench: React.FunctionComponent<{
hideStackFrames?: boolean, hideStackFrames?: boolean,
showSourcesFirst?: boolean, showSourcesFirst?: boolean,
rootDir?: string, rootDir?: string,
defaultSourceLocation?: Location, fallbackLocation?: modelUtil.SourceLocation,
initialSelection?: ActionTraceEvent, initialSelection?: ActionTraceEvent,
onSelectionChanged?: (action: ActionTraceEvent) => void, onSelectionChanged?: (action: ActionTraceEvent) => void,
}> = ({ model, hideTimelineBars, hideStackFrames, showSourcesFirst, rootDir, defaultSourceLocation, initialSelection, onSelectionChanged }) => { }> = ({ model, hideTimelineBars, hideStackFrames, showSourcesFirst, rootDir, fallbackLocation, initialSelection, onSelectionChanged }) => {
const [selectedAction, setSelectedAction] = React.useState<ActionTraceEvent | undefined>(undefined); const [selectedAction, setSelectedAction] = React.useState<ActionTraceEvent | undefined>(undefined);
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>(); const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>();
const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions'); const [selectedNavigatorTab, setSelectedNavigatorTab] = React.useState<string>('actions');
@ -85,7 +84,7 @@ export const Workbench: React.FunctionComponent<{
sources={sources} sources={sources}
hideStackFrames={hideStackFrames} hideStackFrames={hideStackFrames}
rootDir={rootDir} rootDir={rootDir}
fallbackLocation={defaultSourceLocation} /> fallbackLocation={fallbackLocation} />
}; };
const consoleTab: TabbedPaneTabModel = { const consoleTab: TabbedPaneTabModel = {
id: 'console', id: 'console',

View File

@ -61,10 +61,6 @@
background-color: transparent !important; background-color: transparent !important;
} }
.list-view-content:focus .list-view-entry.error.selected {
outline: 1px solid var(--vscode-inputValidation-errorBorder);
}
.list-view-content:focus .list-view-entry.selected .codicon { .list-view-content:focus .list-view-entry.selected .codicon {
color: var(--vscode-list-activeSelectionForeground) !important; color: var(--vscode-list-activeSelectionForeground) !important;
} }
@ -78,5 +74,4 @@
.list-view-entry.error { .list-view-entry.error {
color: var(--vscode-list-errorForeground); color: var(--vscode-list-errorForeground);
background-color: var(--vscode-inputValidation-errorBackground);
} }

View File

@ -64,3 +64,39 @@ test('should show selected test in sources', async ({ runUITest }) => {
page.locator('.CodeMirror .source-line-running'), page.locator('.CodeMirror .source-line-running'),
).toHaveText(`3 test('third', () => {});`); ).toHaveText(`3 test('third', () => {});`);
}); });
test('should show syntax errors in file', async ({ runUITest }) => {
const { page } = await runUITest({
'a.test.ts': `
import { test } from '@playwright/test';
const a = 1;
a = 2;
test('first', () => {});
test('second', () => {});
`,
'b.test.ts': `
import { test } from '@playwright/test';
test('third', () => {});
`,
});
await expect.poll(dumpTestTree(page)).toBe(`
a.test.ts
b.test.ts
third
`);
await page.getByTestId('test-tree').getByText('a.test.ts').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 a = 2;`);
await expect(
page.locator('.CodeMirror-linewidget')
).toHaveText([
'            ',
'Assignment to constant variable.'
]);
});