Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Fixed
- A rejected promise or exception in a `before` or `after` hook aborted the entire run instead of being reported as a failure. #TINYMCE-14759
- An error arriving after the test that caused it had finished was silently discarded, it is now reported against the most recent test. #TINYMCE-14759
- A test that timed out no longer hides the error from work that was still in flight, it is reported as `<test> (error after test)`. #TINYMCE-14759
- A promise rejected without a reason was treated as a pass. #TINYMCE-14759
- An `after` or `afterEach` hook failure replaced the error of the test that had already failed. #TINYMCE-14759

## 16.2.0 - 2026-03-25

### Added
Expand Down
19 changes: 12 additions & 7 deletions modules/runner/src/main/ts/errors/ErrorCatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type ErrorHandler = (error: Error) => void

export interface ErrorCatcher {
readonly bind: (onError: ErrorHandler) => { unbind: () => void };
readonly bindFallback: (onError: ErrorHandler) => { unbind: () => void };
readonly destroy: () => void;
}

Expand All @@ -20,12 +21,15 @@ const isError = (e: unknown): e is Error =>
export const ErrorCatcher = (): ErrorCatcher => {
const supportsGlobalEventListeners = Global.addEventListener !== undefined;
const onErrorHandlers: Set<ErrorHandler> = new Set();
const fallbackHandlers: Set<ErrorHandler> = new Set();
let bound = false;

const createHandler = <T extends Event>(extractError: (e: T) => Error) => (e: T) => {
if (onErrorHandlers.size > 0) {
// Unhandled errors that arrive either between tests after the test suite will have no handlers available, use the fallback
const handlers = onErrorHandlers.size > 0 ? onErrorHandlers : fallbackHandlers;
if (handlers.size > 0) {
const error = extractError(e);
onErrorHandlers.forEach((onError) => onError(error));
handlers.forEach((onError) => onError(error));
e.preventDefault();
return false;
} else {
Expand Down Expand Up @@ -73,17 +77,18 @@ export const ErrorCatcher = (): ErrorCatcher => {
}
};

const addHandler = (onError: ErrorHandler) => {
onErrorHandlers.add(onError);
const addHandler = (handlers: Set<ErrorHandler>) => (onError: ErrorHandler) => {
handlers.add(onError);
bind();

return {
unbind: () => onErrorHandlers.delete(onError)
unbind: () => handlers.delete(onError)
};
};

return {
bind: addHandler,
bind: addHandler(onErrorHandlers),
bindFallback: addHandler(fallbackHandlers),
destroy: unbind
};
};
};
69 changes: 47 additions & 22 deletions modules/runner/src/main/ts/reporter/Reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface TestReporter {
export interface Reporter {
readonly summary: () => { offset: number; passed: number; failed: number; skipped: number };
readonly test: (file: string, name: string, totalNumTests: number) => TestReporter;
readonly strayFailure: (file: string, name: string, e: LoggedError) => void;
readonly waitForResults: () => Promise<void>;
readonly retry: () => Promise<void>;
readonly done: (error?: LoggedError) => void;
Expand All @@ -28,6 +29,7 @@ export interface ReporterUi {
readonly skip: (testTime: string, currentCount: number) => void;
readonly fail: (e: LoggedError, testTime: string, currentCount: number) => void;
};
readonly error: (e: LoggedError) => void;
readonly done: (totalTime: string) => void;
}

Expand All @@ -53,6 +55,7 @@ export const Reporter = (params: UrlParams, callbacks: Callbacks, ui: ReporterUi
let passCount = 0;
let skipCount = 0;
let failCount = 0;
let finished = false;

// A list of test results we are going to send as a batch to the server
const testResults: TestReport[] = [];
Expand All @@ -76,6 +79,30 @@ export const Reporter = (params: UrlParams, callbacks: Callbacks, ui: ReporterUi
}
};

const reportFailure = (file: string, name: string, testTime: string, e: LoggedError, onReported: (err: LoggedError) => void): void => {
failCount++;

// `sourcemapped-stacktrace` is async, so we need to wait for it
requestsInFlight.push(mapError(e).then((err) => {
const errorData = ErrorReporter.data(err);
const error = {
data: errorData,
text: ErrorReporter.dataText(errorData)
};

reportResult({
file,
name,
passed: false,
time: testTime,
error,
skipped: null,
});

onReported(err);
}));
};

const summary = () => ({
offset: Math.max(0, currentCount - 1),
passed: passCount + (params.offset - params.failed - params.skipped),
Expand Down Expand Up @@ -154,29 +181,8 @@ export const Reporter = (params: UrlParams, callbacks: Callbacks, ui: ReporterUi
const fail = (e: LoggedError): void => {
if (!reported) {
reported = true;
failCount++;

const testTime = elapsed(starttime);

// `sourcemapped-stacktrace` is async, so we need to wait for it
requestsInFlight.push(mapError(e).then((err) => {
const errorData = ErrorReporter.data(err);
const error = {
data: errorData,
text: ErrorReporter.dataText(errorData)
};

reportResult({
file,
name,
passed: false,
time: testTime,
error,
skipped: null,
});

testUi.fail(err, testTime, currentCount);
}));
reportFailure(file, name, testTime, e, (err) => testUi.fail(err, testTime, currentCount));
}
};

Expand All @@ -189,6 +195,23 @@ export const Reporter = (params: UrlParams, callbacks: Callbacks, ui: ReporterUi
};
};

// An error that arrives outside of a test is attributed to whichever test ran most recently, which
// replaces that test's result on the server. It deliberately doesn't advance the test count - the
// run is still going, and moving the count on would make the next page load skip a test.
const strayFailure = (file: string, name: string, e: LoggedError): void => {
// once the run is over there's nothing left to attribute this to, and sending more results would
// reopen the session on the server
if (finished) {
ui.error(e);
} else {
reportFailure(file, name, elapsed(Date.now()), e, (err) => {
ui.error(err);
// nothing is waiting on this result and the page may be about to reload, so send it now
forceReportResults();
});
}
};

const waitForResults = async (): Promise<void> => {
forceReportResults();
if (requestsInFlight.length > 0) {
Expand All @@ -212,6 +235,7 @@ export const Reporter = (params: UrlParams, callbacks: Callbacks, ui: ReporterUi
};

const done = (error?: LoggedError): void => {
finished = true;
const setAsDone = (): void => {
const totalTime = elapsed(initial);
ui.done(totalTime);
Expand All @@ -228,6 +252,7 @@ export const Reporter = (params: UrlParams, callbacks: Callbacks, ui: ReporterUi
return {
summary,
test,
strayFailure,
retry,
waitForResults,
done
Expand Down
21 changes: 18 additions & 3 deletions modules/runner/src/main/ts/runner/Run.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import { Context, ExecuteFn, Failure, Runnable, RunnableState, TestThrowable } from '@ephox/bedrock-common';
import { Context, ExecuteFn, Failure, LoggedError, Runnable, RunnableState, TestThrowable, Type } from '@ephox/bedrock-common';
import { isInternalError, MultipleDone, SkipError } from '../errors/Errors';
import { ErrorCatcher } from '../errors/ErrorCatcher';
import { Timer } from './Timer';

type LoggedError = LoggedError.LoggedError;

const isPromiseLike = (value: unknown | undefined): value is PromiseLike<any> =>
value !== undefined && (value as PromiseLike<any>).then !== undefined;

// Promises can be rejected, and values thrown, with anything at all - including nothing - so
// substitute an error for the values that can't be reported on
const normalizeThrowable = (e: unknown): TestThrowable =>
Type.isObject(e) || Type.isString(e) || typeof e === 'function'
? e as TestThrowable
: new Error(`Failed with no or falsy error: ${e}`);

const errorCatcher = ErrorCatcher();

// Errors that arrive while no runnable is executing are otherwise lost, as there's nothing left to
// fail. They still need reporting, so hand them to the runner to attribute to whatever ran last.
export const catchStrayErrors = (onError: (e: LoggedError) => void): void => {
errorCatcher.bindFallback((e) => onError(Failure.prepFailure(e)));
};

export const runWithErrorCatcher = <T>(runnable: Runnable, fn: () => Promise<T>): Promise<T> => {
return new Promise((resolve, reject) => {
let errorOccurred = false;
Expand Down Expand Up @@ -53,13 +68,13 @@ const runExecFn = (fn: ExecuteFn, context: Context): Promise<void> => {
const retValue: any = fn.call(context, done);
if (fn.length === 0) {
if (isPromiseLike(retValue)) {
retValue.then(() => done(), done);
retValue.then(() => done(), (e) => done(normalizeThrowable(e)));
} else {
resolve();
}
}
} catch (e) {
const err = isInternalError(e) ? e : Failure.prepFailure(e);
const err = isInternalError(e) ? e : Failure.prepFailure(normalizeThrowable(e));
reject(err);
}
});
Expand Down
6 changes: 5 additions & 1 deletion modules/runner/src/main/ts/runner/Runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { Callbacks } from '../reporter/Callbacks';
import { Reporter } from '../reporter/Reporter';
import { Actions } from '../ui/Actions';
import { Ui } from '../ui/Ui';
import { RunActions, RunState, runSuite } from './TestRun';
import { catchStrayErrors } from './Run';
import { reportStrayError, RunActions, RunState, runSuite } from './TestRun';
import { countTests, filterOnly } from './Utils';

export interface Runner {
Expand Down Expand Up @@ -111,6 +112,9 @@ export const Runner = (rootSuite: Suite, params: UrlParams, callbacks: Callbacks
runNextChunk
};

// Don't let an error that lands between tests take down the run
catchStrayErrors(reportStrayError(runState, reporter));

ui.setStopOnFailure(stopOnFailure);
return runSuite(rootSuite, runState, runActions, reporter)
.then(() => {
Expand Down
54 changes: 49 additions & 5 deletions modules/runner/src/main/ts/runner/TestRun.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { Failure, LoggedError, RunnableState, Suite, Test } from '@ephox/bedrock-common';
import { Failure, HookType, LoggedError, RunnableState, Suite, Test } from '@ephox/bedrock-common';
import * as Context from '../core/Context';
import { InternalError, isInternalError, SkipError } from '../errors/Errors';
import { Reporter, TestReporter } from '../reporter/Reporter';
import * as Hooks from './Hooks';
import { runWithErrorCatcher, runWithTimeout } from './Run';
import { countTests, loop } from './Utils';
import { countTests, getTests, loop } from './Utils';

type LoggedError = LoggedError.LoggedError;
type RunReporter = Pick<Reporter, 'test'>;
type RunReporter = Pick<Reporter, 'test' | 'strayFailure'>;

export interface RunState {
readonly totalTests: number;
readonly offset: number;
readonly chunk: number;
readonly timeout: number;
testCount: number;
// the test that is running, or the last one to have run, so stray errors have something to blame
currentTest?: Test;
readonly checkSiblings: () => string[];
readonly auto: boolean;
}
Expand All @@ -31,8 +33,12 @@ const runTestWithRetry = (test: Test, state: RunState, testReport: TestReporter,
if (test.isSkipped()) {
return Promise.reject(new SkipError());
} else {
// Keep the original error if we already have one, so the failure that caused it isn't hidden
const runAfterHooks = <T>(error: boolean) => (result: T): Promise<T> =>
Hooks.runAfterEach(test).then(() => error ? Promise.reject(result) : Promise.resolve(result));
Hooks.runAfterEach(test).then(
() => error ? Promise.reject(result) : Promise.resolve(result),
(e) => Promise.reject(error ? result : e)
);

return Hooks.runBeforeEach(test)
.then(() => runWithErrorCatcher(test, () => runWithTimeout(test, Context.createContext(test, test), state.timeout)))
Expand Down Expand Up @@ -86,6 +92,7 @@ export const runTest = (test: Test, state: RunState, actions: RunActions, report
} else {
const testReport = reporter.test(test.file || 'Unknown', test.fullTitle(), state.totalTests);

state.currentTest = test;
actions.onStart(test);
if (!state.auto) {
console.log(`Starting test ${state.testCount} of ${state.totalTests}: ${test.fullTitle()} (${test.file})`);
Expand Down Expand Up @@ -135,16 +142,53 @@ const checkSiblings = (suite: Suite, state: RunState, actions: RunActions, repor
return Promise.reject();
};

// An error that lands while nothing is running belongs to whatever ran last. A test that hasn't failed
// yet is failed by it, but one that has already been reported keeps its own error and the stray one is
// reported alongside - a timeout says far less about what went wrong than the rejection that follows it.
export const reportStrayError = (state: RunState, reporter: RunReporter) => (e: LoggedError): void => {
const test = state.currentTest;
console.error(e);
if (test === undefined) {
reporter.strayFailure('Unknown', 'Unknown', e);
} else if (test.isFailed()) {
reporter.strayFailure(test.file || 'Unknown', `${test.fullTitle()} (error after test)`, e);
} else {
test.setResult(RunnableState.Failed, e);
reporter.strayFailure(test.file || 'Unknown', test.fullTitle(), e);
}
};

// A suite hook isn't a test, but it's reported as one so the failure goes through the same retry and
// reporting machinery instead of aborting the entire run as an unexpected bedrock error
const failHook = (suite: Suite, type: HookType, state: RunState, actions: RunActions, reporter: RunReporter) => (e: LoggedError | InternalError): Promise<never> => {
const file = getTests(suite)[0]?.file || 'Unknown';
const hookReport = reporter.test(file, `${suite.fullTitle()} "${type}" hook`, state.totalTests);
const error = Failure.prepFailure(e);

hookReport.start();
console.error(error);
hookReport.fail(error);
actions.onFailure();
// Test failures must be an empty reject, otherwise the error management thinks it's a bedrock error
return Promise.reject();
};

export const runSuite = (suite: Suite, state: RunState, actions: RunActions, reporter: RunReporter): Promise<void> => {
const numTests = countTests(suite);
if (state.testCount + numTests <= state.offset) {
state.testCount += numTests;
return Promise.resolve();
} else {
const onAfterFailure = failHook(suite, HookType.After, state, actions, reporter);
// Keep the original error if we already have one, as that failure has already been reported
const runAfterHooks = <T>(error: boolean) => (result: T): Promise<T> =>
Hooks.runAfter(suite).then(() => error ? Promise.reject(result) : Promise.resolve(result));
Hooks.runAfter(suite).then(
() => error ? Promise.reject(result) : Promise.resolve(result),
(e) => error ? Promise.reject(result) : onAfterFailure(e)
);

return Hooks.runBefore(suite)
.catch(failHook(suite, HookType.Before, state, actions, reporter))
.then(() => runTests(suite.tests, state, actions, reporter))
.then(() => runSuites(suite.suites, state, actions, reporter))
.then(checkSiblings(suite, state, actions, reporter))
Expand Down
26 changes: 26 additions & 0 deletions modules/runner/src/test/ts/errors/ErrorCatcherTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,32 @@ describe('ErrorCatcher', () => {
assert.isFalse(prevented, 'Event should not be prevented from running the default action');
});

it('should notify the fallback handler when nothing else is bound', () => {
let prevented = false;
const fallbackErrors: Error[] = [];
const fallback = errorCatcher.bindFallback((e) => fallbackErrors.push(e));
const unhandledPromiseRejection = {
reason: new Error('message'),
preventDefault: () => {
prevented = true;
}
} as PromiseRejectionEvent;

// while a runnable is bound the fallback should be left alone
fireEvent('unhandledrejection', unhandledPromiseRejection);
assert.lengthOf(errors, 1, 'The bound handler should be notified');
assert.lengthOf(fallbackErrors, 0, 'The fallback should not be notified');

unbind();
fireEvent('unhandledrejection', unhandledPromiseRejection);
assert.lengthOf(errors, 1, 'The unbound handler should not be notified again');
assert.lengthOf(fallbackErrors, 1, 'The fallback should be notified');
assert.equal(fallbackErrors[0].message, 'Unhandled promise rejection: message');
assert.isTrue(prevented, 'Event should be prevented from running the default action');

fallback.unbind();
});

it('should handle cross frame errors', () => {
let prevented = false;
// Note: As this doesn't run in the browser we can't do a proper cross frame check so simulate an Error
Expand Down
Loading
Loading