mirror of
https://github.com/microsoft/playwright.git
synced 2024-10-27 13:50:25 +03:00
chore: render parse errors in the UI mode (#22888)
Fixes: https://github.com/microsoft/playwright/issues/22863
This commit is contained in:
parent
5fb426e7db
commit
b10cc03314
@ -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)
|
||||||
|
@ -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`);
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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 };
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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',
|
||||||
|
@ -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);
|
|
||||||
}
|
}
|
||||||
|
@ -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.'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user