ses
Version:
Hardened JavaScript for Fearless Cooperation
542 lines (500 loc) • 17.8 kB
JavaScript
// @ts-check
// To ensure that this module operates without special privilege, it should
// not reference the free variable `console` except for its own internal
// debugging purposes in the declaration of `internalDebugConsole`, which is
// normally commented out.
import {
WeakSet,
arrayFilter,
arrayFlatMap,
arrayMap,
arrayPop,
arrayPush,
defineProperty,
freeze,
fromEntries,
isError,
stringEndsWith,
stringIncludes,
stringSplit,
weaksetAdd,
weaksetHas,
} from '../commons.js';
/**
* @import {FilterConsole, LogSeverity, VirtualConsole} from './types.js'
* @import {ErrorInfo, ErrorInfoKind, LogRecord, NoteCallback, LoggedErrorHandler, MakeCausalConsole, MakeLoggingConsoleKit} from "./internal-types.js";
*/
/**
* Explicitly set a function's name, supporting use of arrow functions for which
* source text doesn't include a name and no initial name is set by
* NamedEvaluation
* https://tc39.es/ecma262/multipage/syntax-directed-operations.html#sec-runtime-semantics-namedevaluation
* Instead, we hope that tooling uses only the explicit `name` property.
*
* @template {Function} F
* @param {string} name
* @param {F} fn
* @returns {F}
*/
const defineName = (name, fn) => defineProperty(fn, 'name', { value: name });
// For our internal debugging purposes, uncomment
// const internalDebugConsole = console;
// The permitted console methods, from:
// Whatwg "living standard" https://console.spec.whatwg.org/
// Node https://nodejs.org/dist/latest-v14.x/docs/api/console.html
// MDN https://developer.mozilla.org/en-US/docs/Web/API/Console_API
// TypeScript https://openstapps.gitlab.io/projectmanagement/interfaces/_node_modules__types_node_globals_d_.console.html
// Chrome https://developers.google.com/web/tools/chrome-devtools/console/api
// All console level methods have parameters (fmt?, ...args)
// where the argument sequence `fmt?, ...args` formats args according to
// fmt if fmt is a format string. Otherwise, it just renders them all as values
// separated by spaces.
// https://console.spec.whatwg.org/#formatter
// https://nodejs.org/docs/latest/api/util.html#util_util_format_format_args
// For the causal console, all occurrences of `fmt, ...args` or `...args` by
// itself must check for the presence of an error to ask the
// `loggedErrorHandler` to handle.
// In theory we should do a deep inspection to detect for example an array
// containing an error. We currently do not detect these and may never.
/** @typedef {keyof VirtualConsole | 'profile' | 'profileEnd'} ConsoleProps */
/**
* Those console methods whose actual parameters are `(fmt?, ...args)`
* even if their TypeScript types claim otherwise.
*
* Each is paired with what we consider to be their log severity level.
* This is the same as the log severity of these on other
* platform console implementations when they all agree.
*
* @type {readonly [ConsoleProps, LogSeverity | undefined][]}
*/
export const consoleLevelMethods = freeze([
['debug', 'debug'], // (fmt?, ...args) verbose level on Chrome
['log', 'log'], // (fmt?, ...args) info level on Chrome
['info', 'info'], // (fmt?, ...args)
['warn', 'warn'], // (fmt?, ...args)
['error', 'error'], // (fmt?, ...args)
['trace', 'log'], // (fmt?, ...args)
['dirxml', 'log'], // (fmt?, ...args) but TS typed (...data)
['group', 'log'], // (fmt?, ...args) but TS typed (...label)
['groupCollapsed', 'log'], // (fmt?, ...args) but TS typed (...label)
]);
/**
* Those console methods other than those already enumerated by
* `consoleLevelMethods`.
*
* Each is paired with what we consider to be their log severity level.
* This is the same as the log severity of these on other
* platform console implementations when they all agree.
*
* @type {readonly [ConsoleProps, LogSeverity | undefined][]}
*/
export const consoleOtherMethods = freeze([
['assert', 'error'], // (value, fmt?, ...args)
['timeLog', 'log'], // (label?, ...args) no fmt string
// Insensitive to whether any argument is an error. All arguments can pass
// thru to baseConsole as is.
['clear', undefined], // ()
['count', 'info'], // (label?)
['countReset', undefined], // (label?)
['dir', 'log'], // (item, options?)
['groupEnd', 'log'], // ()
// In theory tabular data may be or contain an error. However, we currently
// do not detect these and may never.
['table', 'log'], // (tabularData, properties?)
['time', 'info'], // (label?)
['timeEnd', 'info'], // (label?)
// Node Inspector only, MDN, and TypeScript, but not whatwg
['profile', undefined], // (label?)
['profileEnd', undefined], // (label?)
['timeStamp', undefined], // (label?)
]);
/** @type {readonly [ConsoleProps, LogSeverity | undefined][]} */
const consoleMethodPermits = freeze([
...consoleLevelMethods,
...consoleOtherMethods,
]);
/**
* consoleOmittedProperties is currently unused. I record and maintain it here
* with the intention that it be treated like the `false` entries in the main
* SES permits: that seeing these on the original console is expected, but
* seeing anything else that's outside the permits is surprising and should
* provide a diagnostic.
*
* const consoleOmittedProperties = freeze([
* 'memory', // Chrome
* 'exception', // FF, MDN
* '_ignoreErrors', // Node
* '_stderr', // Node
* '_stderrErrorHandler', // Node
* '_stdout', // Node
* '_stdoutErrorHandler', // Node
* '_times', // Node
* 'context', // Chrome, Node
* 'record', // Safari
* 'recordEnd', // Safari
*
* 'screenshot', // Safari
* // Symbols
* '@@toStringTag', // Chrome: "Object", Safari: "Console"
* // A variety of other symbols also seen on Node
* ]);
*/
// //////////////////////////// Logging Console ////////////////////////////////
/** @type {MakeLoggingConsoleKit} */
export const makeLoggingConsoleKit = (
loggedErrorHandler,
{ shouldResetForDebugging = false } = {},
) => {
if (shouldResetForDebugging) {
// eslint-disable-next-line @endo/no-polymorphic-call
loggedErrorHandler.resetErrorTagNum();
}
// Not frozen!
let logArray = [];
const loggingConsole = fromEntries(
arrayMap(consoleMethodPermits, ([name, _]) => {
/**
* @param {...any} args
*/
const method = defineName(name, (...args) => {
arrayPush(logArray, [name, ...args]);
});
return [name, freeze(method)];
}),
);
freeze(loggingConsole);
const takeLog = () => {
const result = freeze(logArray);
logArray = [];
return result;
};
freeze(takeLog);
const typedLoggingConsole = /** @type {VirtualConsole} */ (loggingConsole);
return freeze({ loggingConsole: typedLoggingConsole, takeLog });
};
freeze(makeLoggingConsoleKit);
/**
* Makes the same calls on a `baseConsole` that were made on a
* `loggingConsole` to produce this `log`. In this way, a logging console
* can be used as a buffer to delay the application of these calls to a
* `baseConsole`.
*
* @param {LogRecord[]} log
* @param {VirtualConsole} baseConsole
*/
export const pumpLogToConsole = (log, baseConsole) => {
for (const [name, ...args] of log) {
// eslint-disable-next-line @endo/no-polymorphic-call
baseConsole[name](...args);
}
};
// //////////////////////////// Causal Console /////////////////////////////////
/** @type {ErrorInfo} */
const ErrorInfo = {
NOTE: 'ERROR_NOTE:',
MESSAGE: 'ERROR_MESSAGE:',
CAUSE: 'cause:',
ERRORS: 'errors:',
};
freeze(ErrorInfo);
/** @type {MakeCausalConsole} */
export const makeCausalConsole = (baseConsole, loggedErrorHandler) => {
if (!baseConsole) {
return undefined;
}
const { getStackString, tagError, takeMessageLogArgs, takeNoteLogArgsArray } =
loggedErrorHandler;
/**
* @param {ReadonlyArray<any>} logArgs
* @param {Array<any>} subErrorsSink
* @returns {any}
*/
const extractErrorArgs = (logArgs, subErrorsSink) => {
const argTags = arrayMap(logArgs, arg => {
if (isError(arg)) {
arrayPush(subErrorsSink, arg);
return `(${tagError(arg)})`;
}
return arg;
});
return argTags;
};
/**
* @param {LogSeverity} severity
* @param {Error} error
* @param {ErrorInfoKind} kind
* @param {readonly any[]} logArgs
* @param {Array<Error>} subErrorsSink
*/
const logErrorInfo = (severity, error, kind, logArgs, subErrorsSink) => {
const errorTag = tagError(error);
const errorName =
kind === ErrorInfo.MESSAGE ? `${errorTag}:` : `${errorTag} ${kind}`;
const argTags = extractErrorArgs(logArgs, subErrorsSink);
// eslint-disable-next-line @endo/no-polymorphic-call
baseConsole[severity](errorName, ...argTags);
};
/**
* Logs the `subErrors` within a group name mentioning `optTag`.
*
* @param {LogSeverity} severity
* @param {Error[]} subErrors
* @param {string | undefined} optTag
* @returns {void}
*/
const logSubErrors = (severity, subErrors, optTag = undefined) => {
if (subErrors.length === 0) {
return;
}
if (subErrors.length === 1 && optTag === undefined) {
// eslint-disable-next-line no-use-before-define
logError(severity, subErrors[0]);
return;
}
let label;
if (subErrors.length === 1) {
label = `Nested error`;
} else {
label = `Nested ${subErrors.length} errors`;
}
if (optTag !== undefined) {
label = `${label} under ${optTag}`;
}
// eslint-disable-next-line @endo/no-polymorphic-call
baseConsole.group(label);
try {
for (const subError of subErrors) {
// eslint-disable-next-line no-use-before-define
logError(severity, subError);
}
} finally {
if (baseConsole.groupEnd) {
// eslint-disable-next-line @endo/no-polymorphic-call
baseConsole.groupEnd();
}
}
};
const errorsLogged = new WeakSet();
/** @type {(severity: LogSeverity) => NoteCallback} */
const makeNoteCallback = severity => (error, noteLogArgs) => {
const subErrors = [];
// Annotation arrived after the error has already been logged,
// so just log the annotation immediately, rather than remembering it.
logErrorInfo(severity, error, ErrorInfo.NOTE, noteLogArgs, subErrors);
logSubErrors(severity, subErrors, tagError(error));
};
/**
* @param {LogSeverity} severity
* @param {Error} error
*/
const logError = (severity, error) => {
if (weaksetHas(errorsLogged, error)) {
return;
}
const errorTag = tagError(error);
weaksetAdd(errorsLogged, error);
const subErrors = [];
const messageLogArgs = takeMessageLogArgs(error);
const noteLogArgsArray = takeNoteLogArgsArray(
error,
makeNoteCallback(severity),
);
// Show the error's most informative error message
if (messageLogArgs === undefined) {
// If there is no message log args, then just show the message that
// the error itself carries.
// eslint-disable-next-line @endo/no-polymorphic-call
baseConsole[severity](`${errorTag}:`, error.message);
} else {
// If there is one, we take it to be strictly more informative than the
// message string carried by the error, so show it *instead*.
logErrorInfo(
severity,
error,
ErrorInfo.MESSAGE,
messageLogArgs,
subErrors,
);
}
// After the message but before any other annotations, show the stack.
let stackString = getStackString(error);
if (
typeof stackString === 'string' &&
stackString.length >= 1 &&
!stringEndsWith(stackString, '\n')
) {
stackString += '\n';
}
// eslint-disable-next-line @endo/no-polymorphic-call
baseConsole[severity](stackString);
// Show the other annotations on error
if (error.cause) {
logErrorInfo(severity, error, ErrorInfo.CAUSE, [error.cause], subErrors);
}
// @ts-expect-error AggregateError has an `errors` property.
if (error.errors) {
// @ts-expect-error AggregateError has an `errors` property.
logErrorInfo(severity, error, ErrorInfo.ERRORS, error.errors, subErrors);
}
for (const noteLogArgs of noteLogArgsArray) {
logErrorInfo(severity, error, ErrorInfo.NOTE, noteLogArgs, subErrors);
}
// explain all the errors seen in the messages already emitted.
logSubErrors(severity, subErrors, errorTag);
};
const levelMethods = arrayMap(consoleLevelMethods, ([level, _]) => {
/**
* @param {...any} logArgs
*/
const levelMethod = defineName(level, (...logArgs) => {
const subErrors = [];
const argTags = extractErrorArgs(logArgs, subErrors);
if (baseConsole[level]) {
// eslint-disable-next-line @endo/no-polymorphic-call
baseConsole[level](...argTags);
}
// @ts-expect-error ConsoleProp vs LogSeverity mismatch
logSubErrors(level, subErrors);
});
return [level, freeze(levelMethod)];
});
const otherMethodNames = arrayFilter(
consoleOtherMethods,
([name, _]) => name in baseConsole,
);
const otherMethods = arrayMap(otherMethodNames, ([name, _]) => {
/**
* @param {...any} args
*/
const otherMethod = defineName(name, (...args) => {
// @ts-ignore
// eslint-disable-next-line @endo/no-polymorphic-call
baseConsole[name](...args);
return undefined;
});
return [name, freeze(otherMethod)];
});
const causalConsole = fromEntries([...levelMethods, ...otherMethods]);
return /** @type {VirtualConsole} */ (freeze(causalConsole));
};
freeze(makeCausalConsole);
/**
* @typedef {(...args: unknown[]) => void} Logger
*/
/**
* This is a rather horrible kludge to indent the output to a logger in
* the case where some arguments are strings containing newlines. Part of
* the problem is that console-like loggers, including the one in ava,
* join the string arguments of the log message with a space.
* Because of this, there's an extra space at the beginning of each of
* the split lines. So this kludge compensated by putting an extra empty
* string at the beginning, so that the logger will add the same extra
* joiner.
* TODO: Fix this horrible kludge, and indent in a sane manner.
*
* @param {string} str
* @param {string} sep
* @param {string[]} indents
* @returns {string[]}
*/
const indentAfterAllSeps = (str, sep, indents) => {
const [firstLine, ...restLines] = stringSplit(str, sep);
const indentedRest = arrayFlatMap(restLines, line => [sep, ...indents, line]);
return ['', firstLine, ...indentedRest];
};
/**
* @param {LoggedErrorHandler} loggedErrorHandler
*/
export const defineCausalConsoleFromLogger = loggedErrorHandler => {
/**
* Implement the `VirtualConsole` API badly by turning all calls into
* calls on `tlogger`. We need to do this to have `console` logging
* turn into calls to Ava's `t.log`, so these console log messages
* are output in the right place.
*
* @param {Logger} tlogger
* @returns {VirtualConsole}
*/
const makeCausalConsoleFromLogger = tlogger => {
const indents = [];
const logWithIndent = (...args) => {
if (indents.length > 0) {
args = arrayFlatMap(args, arg =>
typeof arg === 'string' && stringIncludes(arg, '\n')
? indentAfterAllSeps(arg, '\n', indents)
: [arg],
);
args = [...indents, ...args];
}
return tlogger(...args);
};
const baseConsole = fromEntries([
...arrayMap(consoleLevelMethods, ([name]) => [
name,
defineName(name, (...args) => logWithIndent(...args)),
]),
...arrayMap(consoleOtherMethods, ([name]) => [
name,
defineName(name, (...args) => logWithIndent(name, ...args)),
]),
]);
// https://console.spec.whatwg.org/#grouping
for (const name of ['group', 'groupCollapsed']) {
if (baseConsole[name]) {
baseConsole[name] = defineName(name, (...args) => {
if (args.length >= 1) {
// Prefix the logged data with "group" or "groupCollapsed".
logWithIndent(...args);
}
// A single space is enough;
// the host console will separate them with additional spaces.
arrayPush(indents, ' ');
});
} else {
baseConsole[name] = defineName(name, () => {});
}
}
baseConsole.groupEnd = defineName(
'groupEnd',
baseConsole.groupEnd
? (...args) => {
arrayPop(indents);
}
: () => {},
);
harden(baseConsole);
const causalConsole = makeCausalConsole(
/** @type {VirtualConsole} */ (baseConsole),
loggedErrorHandler,
);
return /** @type {VirtualConsole} */ (causalConsole);
};
return freeze(makeCausalConsoleFromLogger);
};
freeze(defineCausalConsoleFromLogger);
// ///////////////////////// Filter Console ////////////////////////////////////
/** @type {FilterConsole} */
export const filterConsole = (baseConsole, filter, _topic = undefined) => {
// TODO do something with optional topic string
const methodPermits = arrayFilter(
consoleMethodPermits,
([name, _]) => name in baseConsole,
);
const methods = arrayMap(methodPermits, ([name, severity]) => {
/**
* @param {...any} args
*/
const method = defineName(name, (...args) => {
// eslint-disable-next-line @endo/no-polymorphic-call
if (severity === undefined || filter.canLog(severity)) {
// @ts-ignore
// eslint-disable-next-line @endo/no-polymorphic-call
baseConsole[name](...args);
}
});
return [name, freeze(method)];
});
const filteringConsole = fromEntries(methods);
return /** @type {VirtualConsole} */ (freeze(filteringConsole));
};
freeze(filterConsole);