From 6a8c4d8795c991cdaf542d5dcb691aae4e989d79 Mon Sep 17 00:00:00 2001 From: "Mark S. Miller" Date: Thu, 8 Feb 2024 14:01:30 -0800 Subject: [PATCH] feat(ses): permit Promise.any, AggregateError --- packages/common/NEWS.md | 18 ++ packages/common/throw-labeled.js | 16 +- packages/common/tsconfig.json | 4 + packages/errors/NEWS.md | 20 ++ packages/errors/index.js | 4 +- .../eslint-plugin/lib/configs/recommended.js | 2 + packages/marshal/NEWS.md | 26 ++ packages/marshal/src/marshal-justin.js | 18 +- packages/marshal/src/marshal.js | 64 +++-- packages/marshal/src/types.js | 4 +- packages/marshal/test/test-marshal-capdata.js | 39 ++- .../marshal/test/test-marshal-smallcaps.js | 43 +++- packages/pass-style/NEWS.md | 17 ++ packages/pass-style/index.js | 16 +- packages/pass-style/src/error.js | 223 +++++++++++++----- packages/pass-style/src/passStyleOf.js | 122 +++++++++- .../pass-style/test/test-extended-errors.js | 23 ++ packages/pass-style/test/test-passStyleOf.js | 16 +- packages/pass-style/tsconfig.json | 4 + packages/ses/NEWS.md | 20 ++ packages/ses/package.json | 3 +- packages/ses/src/commons.js | 1 + packages/ses/src/enablements.js | 6 + packages/ses/src/error/assert.js | 43 +++- packages/ses/src/error/console.js | 10 + packages/ses/src/error/internal-types.js | 7 +- packages/ses/src/error/types.js | 72 +++--- packages/ses/src/permits.js | 14 +- .../test-aggregate-error-console-demo.js | 20 ++ .../error/test-aggregate-error-console.js | 44 ++++ .../ses/test/error/test-aggregate-error.js | 46 ++++ .../test/error/test-error-cause-console.js | 80 +++++++ packages/ses/test/error/test-error-cause.js | 39 +++ .../ses/test/test-get-global-intrinsics.js | 2 + packages/ses/types.d.ts | 20 +- 35 files changed, 942 insertions(+), 164 deletions(-) create mode 100644 packages/errors/NEWS.md create mode 100644 packages/pass-style/test/test-extended-errors.js create mode 100644 packages/ses/test/error/test-aggregate-error-console-demo.js create mode 100644 packages/ses/test/error/test-aggregate-error-console.js create mode 100644 packages/ses/test/error/test-aggregate-error.js create mode 100644 packages/ses/test/error/test-error-cause-console.js create mode 100644 packages/ses/test/error/test-error-cause.js diff --git a/packages/common/NEWS.md b/packages/common/NEWS.md index e69de29bb2..59e2df12c3 100644 --- a/packages/common/NEWS.md +++ b/packages/common/NEWS.md @@ -0,0 +1,18 @@ +User-visible changes in `@endo/common`: + +# next release + +- Change to `throwLabeled` + - Like the assertion functions/methods that were parameterized by an error + constructor (`makeError`, `assert`, `assert.fail`, `assert.equal`), + `throwLabeled` now also accepts named options `cause` and `errors` in its + immediately succeeding `options` argument. + - Like those assertion functions, the error constructor argument to + `throwLabeled` can now be an `AggregateError`. + If `throwLabeled` makes an error instance, it encapsulates the + non-uniformity of the `AggregateError` construction arguments, allowing + all the error constructors to be used polymorphically + (generic / interchangeable). + - The error constructor argument is now typed `GenericErrorConstructor`, + effectively the common supertype of `ErrorConstructor` and + `AggregateErrorConstructor`. diff --git a/packages/common/throw-labeled.js b/packages/common/throw-labeled.js index b7457ecf4f..1c3709de63 100644 --- a/packages/common/throw-labeled.js +++ b/packages/common/throw-labeled.js @@ -7,14 +7,24 @@ import { X, makeError, annotateError } from '@endo/errors'; * * @param {Error} innerErr * @param {string|number} label - * @param {ErrorConstructor=} ErrorConstructor + * @param {import('ses').GenericErrorConstructor} [errConstructor] + * @param {import('ses').AssertMakeErrorOptions} [options] * @returns {never} */ -export const throwLabeled = (innerErr, label, ErrorConstructor = undefined) => { +export const throwLabeled = ( + innerErr, + label, + errConstructor = undefined, + options = undefined, +) => { if (typeof label === 'number') { label = `[${label}]`; } - const outerErr = makeError(`${label}: ${innerErr.message}`, ErrorConstructor); + const outerErr = makeError( + `${label}: ${innerErr.message}`, + errConstructor, + options, + ); annotateError(outerErr, X`Caused by ${innerErr}`); throw outerErr; }; diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json index f77b8008a1..20335e4343 100644 --- a/packages/common/tsconfig.json +++ b/packages/common/tsconfig.json @@ -1,5 +1,9 @@ { "extends": "../../tsconfig.eslint-base.json", + "compilerOptions": { + "checkJs": true, + "maxNodeModuleJsDepth": 1, + }, "include": [ "*.js", "*.ts", diff --git a/packages/errors/NEWS.md b/packages/errors/NEWS.md new file mode 100644 index 0000000000..721867a95e --- /dev/null +++ b/packages/errors/NEWS.md @@ -0,0 +1,20 @@ +User-visible changes in `@endo/errors`: + +# next release + +- `AggegateError` support + - Assertion functions/methods that were parameterized by an error constructor + (`makeError`, `assert`, `assert.fail`, `assert.equal`) now also accept named + options `cause` and `errors` in their immediately succeeding + `options` argument. + - For all those, the error constructor can now be an `AggregateError`. + If they do make an error instance, they encapsulate the + non-uniformity of the `AggregateError` construction arguments, allowing + all the error constructors to be used polymorphically + (generic / interchangeable). + - Adds a `GenericErrorConstructor` type to be effectively the common supertype + of `ErrorConstructor` and `AggregateErrorConstructor`, for typing these + error constructor parameters that handle the error constructor + polymorphically. + - The SES `console` now includes `error.cause` and `error.errors` in + its diagnostic output for errors. diff --git a/packages/errors/index.js b/packages/errors/index.js index 1a15cf0934..b3e3c27235 100644 --- a/packages/errors/index.js +++ b/packages/errors/index.js @@ -60,8 +60,8 @@ const { } = globalAssert; /** @type {import("ses").AssertionFunctions } */ // @ts-expect-error missing properties assigned next -const assert = (value, optDetails, optErrorContructor) => - globalAssert(value, optDetails, optErrorContructor); +const assert = (value, optDetails, errContructor, options) => + globalAssert(value, optDetails, errContructor, options); Object.assign(assert, assertions); // As of 2024-02, the Agoric chain's bootstrap vat runs with a version of SES diff --git a/packages/eslint-plugin/lib/configs/recommended.js b/packages/eslint-plugin/lib/configs/recommended.js index 6f314dacfc..8733b0ab3b 100644 --- a/packages/eslint-plugin/lib/configs/recommended.js +++ b/packages/eslint-plugin/lib/configs/recommended.js @@ -61,6 +61,8 @@ module.exports = { lockdown: 'readonly', harden: 'readonly', HandledPromise: 'readonly', + // https://github.com/endojs/endo/issues/550 + AggregateError: 'readonly', }, rules: { '@endo/assert-fail-as-throw': 'error', diff --git a/packages/marshal/NEWS.md b/packages/marshal/NEWS.md index 67140ad2bc..bcf9f35117 100644 --- a/packages/marshal/NEWS.md +++ b/packages/marshal/NEWS.md @@ -1,5 +1,31 @@ User-visible changes in `@endo/marshal`: +# next release + +- Sending and receiving extended errors. + - As of the previous release, `@endo/marshal` tolerates extra error + properties with `Passable` values. However, all those extra properties + were only recorded in annotations, since they are not recognized as + legitimate on `Passable` errors. + - This release will use these extra properties to construct an error object + with all those extra properties, and then call `toPassableError` to make + the locally `Passable` error that it returns. Thus, if the extra properties + received are not recognized as a legitimate part of a locally `Passable` + error, the error with those extra properties itself becomes the annotation + on the returned `Passable` error. + - An `error.cause` property whose value is a `Passable` error with therefore + show up on the returned `Passable` error. If it is any other `Passable` + value, it will show up on the internal error used to annotate the + returned error. + - An `error.errors` property whose value is a `CopyArray` of `Passable` + errors will likewise show up on the returned `Passable` error. Otherwise, + only on the internal error annotation of the returned error. + - Although this release does otherwise support the error properties + `error.cause` and `error.errors` on `Passable` errors, it still does not + send these properties because releases prior to the previous release + do not tolerate receiving them. Once we no longer need to support + releases prior to the previous release, then we can start sending these. + # v1.2.0 (2024-02-14) - Tolerates receiving extra error properties (https://github.com/endojs/endo/pull/2052). Once pervasive, this tolerance will eventually enable additional error properties to be sent. The motivating examples are the JavaScript standard properties `cause` and `errors`. This change also enables smoother interoperation with other languages with their own theories about diagnostic information to be included in errors. diff --git a/packages/marshal/src/marshal-justin.js b/packages/marshal/src/marshal-justin.js index 69bd2d7ed6..4cb8adcad6 100644 --- a/packages/marshal/src/marshal-justin.js +++ b/packages/marshal/src/marshal-justin.js @@ -217,8 +217,9 @@ const decodeToJustin = (encoding, shouldIndent = false, slots = []) => { } case 'error': { const { name, message } = rawTree; - typeof name === 'string' || - Fail`invalid error name typeof ${q(typeof name)}`; + if (typeof name !== 'string') { + throw Fail`invalid error name typeof ${q(typeof name)}`; + } getErrorConstructor(name) !== undefined || Fail`Must be the name of an Error constructor ${name}`; typeof message === 'string' || @@ -389,11 +390,18 @@ const decodeToJustin = (encoding, shouldIndent = false, slots = []) => { } case 'error': { - const { name, message } = rawTree; - // TODO cause, errors, AggregateError - // See https://github.com/endojs/endo/pull/2052 + const { + name, + message, + cause = undefined, + errors = undefined, + } = rawTree; + cause === undefined || + Fail`error cause not yet implemented in marshal-justin`; name !== `AggregateError` || Fail`AggregateError not yet implemented in marshal-justin`; + errors === undefined || + Fail`error errors not yet implemented in marshal-justin`; return out.next(`${name}(${quote(message)})`); } diff --git a/packages/marshal/src/marshal.js b/packages/marshal/src/marshal.js index edc8bb1d2c..d57e90c377 100644 --- a/packages/marshal/src/marshal.js +++ b/packages/marshal/src/marshal.js @@ -6,6 +6,7 @@ import { getInterfaceOf, getErrorConstructor, hasOwnPropertyOf, + toPassableError, } from '@endo/pass-style'; import { X, Fail, q, makeError, annotateError } from '@endo/errors'; @@ -30,6 +31,7 @@ import { /** @typedef {import('./types.js').Encoding} Encoding */ /** @typedef {import('@endo/pass-style').RemotableObject} Remotable */ +const { defineProperties } = Object; const { isArray } = Array; const { ownKeys } = Reflect; @@ -113,8 +115,9 @@ export const makeMarshal = ( assert.typeof(message, 'string'); const name = encodeRecur(`${err.name}`); assert.typeof(name, 'string'); - // Must encode `cause`, `errors`. - // nested non-passable errors must be ok from here. + // TODO Must encode `cause`, `errors`, but + // only once all possible counterparty decoders are tolerant of + // receiving them. if (errorTagging === 'on') { // We deliberately do not share the stack, but it would // be useful to log the stack locally so someone who has @@ -255,40 +258,65 @@ export const makeMarshal = ( }; /** - * @param {{errorId?: string, message: string, name: string}} errData + * @param {{ + * errorId?: string, + * message: string, + * name: string, + * cause: unknown, + * errors: unknown, + * }} errData * @param {(e: unknown) => Passable} decodeRecur * @returns {Error} */ const decodeErrorCommon = (errData, decodeRecur) => { - const { errorId = undefined, message, name, ...rest } = errData; - // TODO Must decode `cause` and `errors` properties. + const { + errorId = undefined, + message, + name, + cause = undefined, + errors = undefined, + ...rest + } = errData; // See https://github.com/endojs/endo/pull/2052 // capData does not transform strings. The immediately following calls // to `decodeRecur` are for reuse by other encodings that do, // such as smallcaps. const dName = decodeRecur(name); const dMessage = decodeRecur(message); + // errorId is a late addition so be tolerant of its absence. const dErrorId = errorId && decodeRecur(errorId); typeof dName === 'string' || Fail`invalid error name typeof ${q(typeof dName)}`; typeof dMessage === 'string' || Fail`invalid error message typeof ${q(typeof dMessage)}`; - const EC = getErrorConstructor(dName) || Error; - // errorId is a late addition so be tolerant of its absence. + const errConstructor = getErrorConstructor(dName) || Error; const errorName = dErrorId === undefined - ? `Remote${EC.name}` - : `Remote${EC.name}(${dErrorId})`; - const error = makeError(dMessage, EC, { errorName }); - if (ownKeys(rest).length >= 1) { - // Note that this does not decodeRecur rest's property names. - // This would be inconsistent with smallcaps' expected handling, - // but is fine here since it is only used for `annotateError`, - // which is for diagnostic info that is otherwise unobservable. - const extras = objectMap(rest, decodeRecur); - annotateError(error, X`extra marshalled properties ${extras}`); + ? `Remote${errConstructor.name}` + : `Remote${errConstructor.name}(${dErrorId})`; + const options = { + errorName, + }; + if (cause) { + options.cause = decodeRecur(cause); + } + if (errors) { + options.errors = decodeRecur(errors); } - return harden(error); + const rawError = makeError(dMessage, errConstructor, options); + // Note that this does not decodeRecur rest's property names. + // This would be inconsistent with smallcaps' expected handling, + // but is fine here since it is only used for `annotateError`, + // which is for diagnostic info that is otherwise unobservable. + const descs = objectMap(rest, data => ({ + value: decodeRecur(data), + writable: false, + enumerable: false, + configurable: false, + })); + defineProperties(rawError, descs); + harden(rawError); + return toPassableError(rawError); }; // The current encoding does not give the decoder enough into to distinguish diff --git a/packages/marshal/src/types.js b/packages/marshal/src/types.js index bc4899e1f5..d1d42ccfd9 100644 --- a/packages/marshal/src/types.js +++ b/packages/marshal/src/types.js @@ -31,7 +31,9 @@ export {}; * EncodingClass<'symbol'> & { name: string } | * EncodingClass<'error'> & { name: string, * message: string, - * errorId?: string + * errorId?: string, + * cause?: Encoding, + * errors?: Encoding[], * } | * EncodingClass<'slot'> & { index: number, * iface?: string diff --git a/packages/marshal/test/test-marshal-capdata.js b/packages/marshal/test/test-marshal-capdata.js index 503cb64749..e0251a97e8 100644 --- a/packages/marshal/test/test-marshal-capdata.js +++ b/packages/marshal/test/test-marshal-capdata.js @@ -172,10 +172,10 @@ test('unserialize extended errors', t => { const aggErr = uns( '{"@qclass":"error","message":"msg","name":"AggregateError","extraProp":"foo","cause":"bar","errors":["zip","zap"]}', ); - t.is(getPrototypeOf(aggErr), Error.prototype); // direct instance of + t.is(getPrototypeOf(aggErr), AggregateError.prototype); // direct instance of t.false('extraProp' in aggErr); t.false('cause' in aggErr); - t.false('errors' in aggErr); + t.is(aggErr.errors.length, 0); console.log('error with extra prop', aggErr); const unkErr = uns( @@ -188,6 +188,41 @@ test('unserialize extended errors', t => { console.log('error with extra prop', unkErr); }); +test('unserialize errors w recognized extensions', t => { + const { unserialize } = makeTestMarshal(); + const uns = body => unserialize({ body, slots: [] }); + + const errEnc = '{"@qclass":"error","message":"msg","name":"URIError"}'; + + const refErr = uns( + `{"@qclass":"error","message":"msg","name":"ReferenceError","extraProp":"foo","cause":${errEnc},"errors":[${errEnc}]}`, + ); + t.is(getPrototypeOf(refErr), ReferenceError.prototype); // direct instance of + t.false('extraProp' in refErr); + t.is(getPrototypeOf(refErr.cause), URIError.prototype); + t.is(getPrototypeOf(refErr.errors[0]), URIError.prototype); + console.log('error with extra prop', refErr); + + const aggErr = uns( + `{"@qclass":"error","message":"msg","name":"AggregateError","extraProp":"foo","cause":${errEnc},"errors":[${errEnc}]}`, + ); + t.is(getPrototypeOf(aggErr), AggregateError.prototype); // direct instance of + t.false('extraProp' in aggErr); + t.is(getPrototypeOf(aggErr.cause), URIError.prototype); + t.is(getPrototypeOf(aggErr.errors[0]), URIError.prototype); + console.log('error with extra prop', aggErr); + + const unkErr = uns( + `{"@qclass":"error","message":"msg","name":"UnknownError","extraProp":"foo","cause":${errEnc},"errors":[${errEnc}]}`, + ); + t.is(getPrototypeOf(unkErr), Error.prototype); // direct instance of + t.false('extraProp' in unkErr); + t.is(getPrototypeOf(unkErr.cause), URIError.prototype); + t.is(getPrototypeOf(unkErr.errors[0]), URIError.prototype); + + console.log('error with extra prop', unkErr); +}); + test('passStyleOf null is "null"', t => { t.assert(passStyleOf(null), 'null'); }); diff --git a/packages/marshal/test/test-marshal-smallcaps.js b/packages/marshal/test/test-marshal-smallcaps.js index 86fe5e75b2..9b285dbe02 100644 --- a/packages/marshal/test/test-marshal-smallcaps.js +++ b/packages/marshal/test/test-marshal-smallcaps.js @@ -163,9 +163,6 @@ test('smallcaps unserialize extended errors', t => { const { unserialize } = makeTestMarshal(); const uns = body => unserialize({ body, slots: [] }); - // TODO cause, errors, and AggregateError will eventually be recognized. - // See https://github.com/endojs/endo/pull/2042 - const refErr = uns( '#{"#error":"msg","name":"ReferenceError","extraProp":"foo","cause":"bar","errors":["zip","zap"]}', ); @@ -178,10 +175,10 @@ test('smallcaps unserialize extended errors', t => { const aggErr = uns( '#{"#error":"msg","name":"AggregateError","extraProp":"foo","cause":"bar","errors":["zip","zap"]}', ); - t.is(getPrototypeOf(aggErr), Error.prototype); // direct instance of + t.is(getPrototypeOf(aggErr), AggregateError.prototype); // direct instance of t.false('extraProp' in aggErr); t.false('cause' in aggErr); - t.false('errors' in aggErr); + t.is(aggErr.errors.length, 0); console.log('error with extra prop', aggErr); const unkErr = uns( @@ -194,6 +191,40 @@ test('smallcaps unserialize extended errors', t => { console.log('error with extra prop', unkErr); }); +test('smallcaps unserialize errors w recognized extensions', t => { + const { unserialize } = makeTestMarshal(); + const uns = body => unserialize({ body, slots: [] }); + + const errEnc = '{"#error":"msg","name":"URIError"}'; + + const refErr = uns( + `#{"#error":"msg","name":"ReferenceError","extraProp":"foo","cause":${errEnc},"errors":[${errEnc}]}`, + ); + t.is(getPrototypeOf(refErr), ReferenceError.prototype); // direct instance of + t.false('extraProp' in refErr); + t.is(getPrototypeOf(refErr.cause), URIError.prototype); + t.is(getPrototypeOf(refErr.errors[0]), URIError.prototype); + console.log('error with extra prop', refErr); + + const aggErr = uns( + `#{"#error":"msg","name":"AggregateError","extraProp":"foo","cause":${errEnc},"errors":[${errEnc}]}`, + ); + t.is(getPrototypeOf(aggErr), AggregateError.prototype); // direct instance of + t.false('extraProp' in aggErr); + t.is(getPrototypeOf(refErr.cause), URIError.prototype); + t.is(getPrototypeOf(refErr.errors[0]), URIError.prototype); + console.log('error with extra prop', aggErr); + + const unkErr = uns( + `#{"#error":"msg","name":"UnknownError","extraProp":"foo","cause":${errEnc},"errors":[${errEnc}]}`, + ); + t.is(getPrototypeOf(unkErr), Error.prototype); // direct instance of + t.false('extraProp' in unkErr); + t.is(getPrototypeOf(refErr.cause), URIError.prototype); + t.is(getPrototypeOf(refErr.errors[0]), URIError.prototype); + console.log('error with extra prop', unkErr); +}); + test('smallcaps mal-formed @qclass', t => { const { unserialize } = makeTestMarshal(); const uns = body => unserialize({ body, slots: [] }); @@ -396,7 +427,7 @@ test('smallcaps encoding examples', t => { harden(nonPassableErr); t.throws(() => passStyleOf(nonPassableErr), { message: - /Passed Error has extra unpassed properties {"extraProperty":{"configurable":.*,"enumerable":true,"value":"something bad","writable":.*}}/, + /Passable Error "extraProperty" own property must not be enumerable: \{"configurable":.*,"enumerable":true,"value":"something bad","writable":.*\}/, }); assertSer( nonPassableErr, diff --git a/packages/pass-style/NEWS.md b/packages/pass-style/NEWS.md index e69de29bb2..767cc5cc17 100644 --- a/packages/pass-style/NEWS.md +++ b/packages/pass-style/NEWS.md @@ -0,0 +1,17 @@ +User-visible changes in `@endo/pass-style`: + +# next release + +- Now supports `AggegateError`, `error.errors`, `error.cause`. + - A `Passable` error can now include an `error.cause` property whose + value is a `Passable` error. + - An `AggregateError` can be a `Passable` error. + - A `Passable` error can now include an `error.errors` property whose + value is a `CopyArray` of `Passable` errors. + - The previously internal `toPassableError` is more general and exported + for general use. If its error agument is already `Passable`, + `toPassableError` will return it. Otherwise, it will extract from it + info for making a `Passable` error, and use `annotateError` to attach + the original error to the returned `Passable` error as a note. This + node will show up on the SES `console` as additional diagnostic info + associated with the returned `Passable` error. diff --git a/packages/pass-style/index.js b/packages/pass-style/index.js index da985131a2..32e6d7562d 100644 --- a/packages/pass-style/index.js +++ b/packages/pass-style/index.js @@ -7,11 +7,8 @@ export { hasOwnPropertyOf, } from './src/passStyle-helpers.js'; -export { - getErrorConstructor, - toPassableError, - isErrorLike, -} from './src/error.js'; +export { getErrorConstructor, isErrorLike } from './src/error.js'; + export { getInterfaceOf } from './src/remotable.js'; export { @@ -21,7 +18,14 @@ export { passableSymbolForName, } from './src/symbol.js'; -export { passStyleOf, assertPassable } from './src/passStyleOf.js'; +export { + passStyleOf, + isPassable, + assertPassable, + isPassableError, + assertPassableError, + toPassableError, +} from './src/passStyleOf.js'; export { makeTagged } from './src/makeTagged.js'; export { diff --git a/packages/pass-style/src/error.js b/packages/pass-style/src/error.js index d015aaac9b..dc9d4afae5 100644 --- a/packages/pass-style/src/error.js +++ b/packages/pass-style/src/error.js @@ -1,27 +1,43 @@ /// -import { X, Fail, annotateError } from '@endo/errors'; +import { X, q } from '@endo/errors'; import { assertChecker } from './passStyle-helpers.js'; /** @typedef {import('./internal-types.js').PassStyleHelper} PassStyleHelper */ /** @typedef {import('./types.js').Checker} Checker */ -const { getPrototypeOf, getOwnPropertyDescriptors } = Object; -const { ownKeys } = Reflect; +const { getPrototypeOf, getOwnPropertyDescriptors, hasOwn, entries } = Object; // TODO: Maintenance hazard: Coordinate with the list of errors in the SES -// whilelist. Currently, both omit AggregateError, which is now standard. Both -// must eventually include it. -const errorConstructors = new Map([ - ['Error', Error], - ['EvalError', EvalError], - ['RangeError', RangeError], - ['ReferenceError', ReferenceError], - ['SyntaxError', SyntaxError], - ['TypeError', TypeError], - ['URIError', URIError], -]); +// whilelist. +const errorConstructors = new Map( + // Cast because otherwise TS is confused by AggregateError + // See https://github.com/endojs/endo/pull/2042#discussion_r1484933028 + /** @type {Array<[string, import('ses').GenericErrorConstructor]>} */ + ([ + ['Error', Error], + ['EvalError', EvalError], + ['RangeError', RangeError], + ['ReferenceError', ReferenceError], + ['SyntaxError', SyntaxError], + ['TypeError', TypeError], + ['URIError', URIError], + // https://github.com/endojs/endo/issues/550 + ['AggregateError', AggregateError], + ]), +); + +/** + * Because the error constructor returned by this function might be + * `AggregateError`, which has different construction parameters + * from the other error constructors, do not use it directly to try + * to make an error instance. Rather, use `makeError` which encapsulates + * this non-uniformity. + * + * @param {string} name + * @returns {import('ses').GenericErrorConstructor | undefined} + */ export const getErrorConstructor = name => errorConstructors.get(name); harden(getErrorConstructor); @@ -39,6 +55,7 @@ const checkErrorLike = (candidate, check = undefined) => { ); }; harden(checkErrorLike); +/// /** * Validating error objects are passable raises a tension between security @@ -62,61 +79,139 @@ export const isErrorLike = candidate => checkErrorLike(candidate); harden(isErrorLike); /** - * @type {PassStyleHelper} + * @param {string} propName + * @param {PropertyDescriptor} desc + * @param {import('./internal-types.js').PassStyleOf} passStyleOfRecur + * @param {Checker} [check] + * @returns {boolean} */ -export const ErrorHelper = harden({ - styleName: 'error', - - canBeValid: checkErrorLike, - - assertValid: candidate => { - ErrorHelper.canBeValid(candidate, assertChecker); - const proto = getPrototypeOf(candidate); - const { name } = proto; - const EC = getErrorConstructor(name); - (EC && EC.prototype === proto) || - Fail`Errors must inherit from an error class .prototype ${candidate}`; - - const { - // TODO Must allow `cause`, `errors` - message: mDesc, - stack: stackDesc, - ...restDescs - } = getOwnPropertyDescriptors(candidate); - ownKeys(restDescs).length < 1 || - Fail`Passed Error has extra unpassed properties ${restDescs}`; - if (mDesc) { - typeof mDesc.value === 'string' || - Fail`Passed Error "message" ${mDesc} must be a string-valued data property.`; - !mDesc.enumerable || - Fail`Passed Error "message" ${mDesc} must not be enumerable`; +export const checkRecursivelyPassableErrorPropertyDesc = ( + propName, + desc, + passStyleOfRecur, + check = undefined, +) => { + const reject = !!check && (details => check(false, details)); + if (desc.enumerable) { + return ( + reject && + reject( + X`Passable Error ${q( + propName, + )} own property must not be enumerable: ${desc}`, + ) + ); + } + if (!hasOwn(desc, 'value')) { + return ( + reject && + reject( + X`Passable Error ${q( + propName, + )} own property must be a data property: ${desc}`, + ) + ); + } + const { value } = desc; + switch (propName) { + case 'message': + case 'stack': { + return ( + typeof value === 'string' || + (reject && + reject( + X`Passable Error ${q( + propName, + )} own property must be a string: ${value}`, + )) + ); } - if (stackDesc) { - typeof stackDesc.value === 'string' || - Fail`Passed Error "stack" ${stackDesc} must be a string-valued data property.`; - !stackDesc.enumerable || - Fail`Passed Error "stack" ${stackDesc} must not be enumerable`; + case 'cause': { + // eslint-disable-next-line no-use-before-define + return checkRecursivelyPassableError(value, passStyleOfRecur, check); } - return true; - }, -}); + case 'errors': { + if (!Array.isArray(value) || passStyleOfRecur(value) !== 'copyArray') { + return ( + reject && + reject( + X`Passable Error ${q( + propName, + )} own property must be a copyArray: ${value}`, + ) + ); + } + return value.every(err => + // eslint-disable-next-line no-use-before-define + checkRecursivelyPassableError(err, passStyleOfRecur, check), + ); + } + default: { + break; + } + } + return ( + reject && + reject(X`Passable Error has extra unpassed property ${q(propName)}`) + ); +}; +harden(checkRecursivelyPassableErrorPropertyDesc); /** - * Return a new passable error that propagates the diagnostic info of the - * original, and is linked to the original as a note. - * - * @param {Error} err - * @returns {Error} + * @param {unknown} candidate + * @param {import('./internal-types.js').PassStyleOf} passStyleOfRecur + * @param {Checker} [check] + * @returns {boolean} */ -export const toPassableError = err => { - const { name, message } = err; +export const checkRecursivelyPassableError = ( + candidate, + passStyleOfRecur, + check = undefined, +) => { + const reject = !!check && (details => check(false, details)); + if (!checkErrorLike(candidate, check)) { + return false; + } + const proto = getPrototypeOf(candidate); + const { name } = proto; + const errConstructor = getErrorConstructor(name); + if (errConstructor === undefined || errConstructor.prototype !== proto) { + return ( + reject && + reject( + X`Passable Error must inherit from an error class .prototype: ${candidate}`, + ) + ); + } + const descs = getOwnPropertyDescriptors(candidate); + if (!('message' in descs)) { + return ( + reject && + reject( + X`Passable Error must have an own "message" string property: ${candidate}`, + ) + ); + } - const EC = getErrorConstructor(`${name}`) || Error; - const newError = harden(new EC(`${message}`)); - // Even the cleaned up error copy, if sent to the console, should - // cause hidden diagnostic information of the original error - // to be logged. - annotateError(newError, X`copied from error ${err}`); - return newError; + return entries(descs).every(([propName, desc]) => + checkRecursivelyPassableErrorPropertyDesc( + propName, + desc, + passStyleOfRecur, + check, + ), + ); }; -harden(toPassableError); +harden(checkRecursivelyPassableError); + +/** + * @type {PassStyleHelper} + */ +export const ErrorHelper = harden({ + styleName: 'error', + + canBeValid: checkErrorLike, + + assertValid: (candidate, passStyleOfRecur) => + checkRecursivelyPassableError(candidate, passStyleOfRecur, assertChecker), +}); diff --git a/packages/pass-style/src/passStyleOf.js b/packages/pass-style/src/passStyleOf.js index a335b2bca2..1f2ddb05a8 100644 --- a/packages/pass-style/src/passStyleOf.js +++ b/packages/pass-style/src/passStyleOf.js @@ -3,13 +3,23 @@ /// import { isPromise } from '@endo/promise-kit'; -import { X, Fail, q } from '@endo/errors'; -import { isObject, isTypedArray, PASS_STYLE } from './passStyle-helpers.js'; +import { X, Fail, q, annotateError, makeError } from '@endo/errors'; +import { + assertChecker, + isObject, + isTypedArray, + PASS_STYLE, +} from './passStyle-helpers.js'; import { CopyArrayHelper } from './copyArray.js'; import { CopyRecordHelper } from './copyRecord.js'; import { TaggedHelper } from './tagged.js'; -import { ErrorHelper } from './error.js'; +import { + ErrorHelper, + checkRecursivelyPassableErrorPropertyDesc, + checkRecursivelyPassableError, + getErrorConstructor, +} from './error.js'; import { RemotableHelper } from './remotable.js'; import { assertPassableSymbol } from './symbol.js'; @@ -24,7 +34,7 @@ import { assertSafePromise } from './safe-promise.js'; /** @typedef {Exclude} HelperPassStyle */ const { ownKeys } = Reflect; -const { isFrozen } = Object; +const { isFrozen, getOwnPropertyDescriptors } = Object; /** * @param {PassStyleHelper[]} passStyleHelpers @@ -221,3 +231,107 @@ export const assertPassable = val => { passStyleOf(val); // throws if val is not a passable }; harden(assertPassable); + +/** + * Is `specimen` Passable? This returns true iff `passStyleOf(specimen)` + * returns a string. This returns `false` iff `passStyleOf(specimen)` throws. + * Under no normal circumstance should `isPassable(specimen)` throw. + * + * TODO Deprecate and ultimately delete @agoric/base-zone's `isPassable' in + * favor of this one. + * + * TODO implement an isPassable that does not rely on try/catch. + * This implementation is just a standin until then + * + * @param {any} specimen + * @returns {specimen is Passable} + */ +export const isPassable = specimen => { + try { + // In fact, it never returns undefined. It either returns a + // string or throws. + return passStyleOf(specimen) !== undefined; + } catch (_) { + return false; + } +}; +harden(isPassable); + +/** + * @param {string} name + * @param {PropertyDescriptor} desc + * @returns {boolean} + */ +const isPassableErrorPropertyDesc = (name, desc) => + checkRecursivelyPassableErrorPropertyDesc(name, desc, passStyleOf); +harden(isPassableErrorPropertyDesc); + +/** + * @param {string} name + * @param {PropertyDescriptor} desc + */ +const assertPassableErrorPropertyDesc = (name, desc) => { + checkRecursivelyPassableErrorPropertyDesc( + name, + desc, + passStyleOf, + assertChecker, + ); +}; +harden(assertPassableErrorPropertyDesc); + +/** + * @param {unknown} err + * @returns {err is Error} + */ +export const isPassableError = err => + checkRecursivelyPassableError(err, passStyleOf); + +/** + * @param {unknown} err + * @returns {asserts err is Error} + */ +export const assertPassableError = err => { + checkRecursivelyPassableError(err, passStyleOf, assertChecker); +}; + +/** + * Return a new passable error that propagates the diagnostic info of the + * original, and is linked to the original as a note. + * + * @param {Error | AggregateError} err + * @returns {Error} + */ +export const toPassableError = err => { + if (isPassableError(err)) { + return err; + } + const { name, message } = err; + const { cause: causeDesc, errors: errorsDesc } = + getOwnPropertyDescriptors(err); + let cause; + let errors; + if (causeDesc && isPassableErrorPropertyDesc('cause', causeDesc)) { + // @ts-expect-error data descriptors have "value" property + cause = causeDesc.value; + } + if (errorsDesc && isPassableErrorPropertyDesc('errors', errorsDesc)) { + // @ts-expect-error data descriptors have "value" property + errors = errorsDesc.value; + } + + const errConstructor = getErrorConstructor(`${name}`) || Error; + const newError = makeError(`${message}`, errConstructor, { + // @ts-ignore Assuming cause is Error | undefined + cause, + errors, + }); + harden(newError); + // Even the cleaned up error copy, if sent to the console, should + // cause hidden diagnostic information of the original error + // to be logged. + annotateError(newError, X`copied from error ${err}`); + assertPassableError(newError); + return newError; +}; +harden(toPassableError); diff --git a/packages/pass-style/test/test-extended-errors.js b/packages/pass-style/test/test-extended-errors.js new file mode 100644 index 0000000000..0f653bf39a --- /dev/null +++ b/packages/pass-style/test/test-extended-errors.js @@ -0,0 +1,23 @@ +/* eslint-disable max-classes-per-file */ +import { test } from './prepare-test-env-ava.js'; + +// eslint-disable-next-line import/order +import { passStyleOf } from '../src/passStyleOf.js'; + +test('style of extended errors', t => { + const e1 = Error('e1'); + t.throws(() => passStyleOf(e1), { + message: 'Cannot pass non-frozen objects like "[Error: e1]". Use harden()', + }); + harden(e1); + t.is(passStyleOf(e1), 'error'); + + const e2 = harden(Error('e2', { cause: e1 })); + t.is(passStyleOf(e2), 'error'); + + const u3 = harden(URIError('u3', { cause: e1 })); + t.is(passStyleOf(u3), 'error'); + + const a4 = harden(AggregateError([e2, u3], 'a4', { cause: e1 })); + t.is(passStyleOf(a4), 'error'); +}); diff --git a/packages/pass-style/test/test-passStyleOf.js b/packages/pass-style/test/test-passStyleOf.js index 33c71a1bd9..0560223e55 100644 --- a/packages/pass-style/test/test-passStyleOf.js +++ b/packages/pass-style/test/test-passStyleOf.js @@ -30,15 +30,18 @@ test('passStyleOf basic success cases', t => { t.is(passStyleOf(harden({})), 'copyRecord', 'empty plain object'); t.is(passStyleOf(makeTagged('unknown', undefined)), 'tagged'); t.is(passStyleOf(harden(Error('ok'))), 'error'); +}); +test('some passStyleOf rejections', t => { const hairlessError = Error('hairless'); for (const k of ownKeys(hairlessError)) { delete hairlessError[k]; } - t.is(passStyleOf(harden(hairlessError)), 'error'); -}); + t.throws(() => passStyleOf(harden(hairlessError)), { + message: + 'Passable Error must have an own "message" string property: "[Error: ]"', + }); -test('some passStyleOf rejections', t => { t.throws(() => passStyleOf(Symbol('unique')), { message: /Only registered symbols or well-known symbols are passable: "\[Symbol\(unique\)\]"/, @@ -391,7 +394,7 @@ test('remotables - safety from the gibson042 attack', t => { // console.log(passStyleOf(input1)); // => "remotable" t.throws(() => passStyleOf(input1), { message: - 'Errors must inherit from an error class .prototype "[undefined: undefined]"', + 'Passable Error must inherit from an error class .prototype: "[undefined: undefined]"', }); // different because of changes in the prototype @@ -400,7 +403,7 @@ test('remotables - safety from the gibson042 attack', t => { // console.log(passStyleOf(input2)); // => Error (Errors must inherit from an error class .prototype) t.throws(() => passStyleOf(input2), { message: - 'Errors must inherit from an error class .prototype "[undefined: undefined]"', + 'Passable Error must inherit from an error class .prototype: "[undefined: undefined]"', }); }); @@ -417,8 +420,7 @@ test('Unexpected stack on errors', t => { Object.freeze(err); t.throws(() => passStyleOf(err), { - message: - 'Passed Error "stack" {"configurable":false,"enumerable":false,"value":{},"writable":false} must be a string-valued data property.', + message: 'Passable Error "stack" own property must be a string: {}', }); err.stack.foo = 42; }); diff --git a/packages/pass-style/tsconfig.json b/packages/pass-style/tsconfig.json index f77b8008a1..20335e4343 100644 --- a/packages/pass-style/tsconfig.json +++ b/packages/pass-style/tsconfig.json @@ -1,5 +1,9 @@ { "extends": "../../tsconfig.eslint-base.json", + "compilerOptions": { + "checkJs": true, + "maxNodeModuleJsDepth": 1, + }, "include": [ "*.js", "*.ts", diff --git a/packages/ses/NEWS.md b/packages/ses/NEWS.md index e9e5221c69..b35dca6a43 100644 --- a/packages/ses/NEWS.md +++ b/packages/ses/NEWS.md @@ -1,5 +1,25 @@ User-visible changes in SES: +# next release + +- Now supports `Promise.any`, `AggegateError`, `error.errors`, + and `error.cause`. + - Assertion functions/methods that were parameterized by an error constructor + (`makeError`, `assert`, `assert.fail`, `assert.equal`) now also accept named + options `cause` and `errors` in their immediately succeeding + `options` argument. + - For all those, the error constructor can now be an `AggregateError`. + If they do make an error instance, they encapsulate the + non-uniformity of the `AggregateError` construction arguments, allowing + all the error constructors to be used polymorphically + (generic / interchangeable). + - Adds a `GenericErrorConstructor` type to be effectively the common supertype + of `ErrorConstructor` and `AggregateErrorConstructor`, for typing these + error constructor parameters that handle the error constructor + polymorphically. + - The SES `console` now includes `error.cause` and `error.errors` in + its diagnostic output for errors. + # v1.2.0 (2024-02-14) - Exports `ses/lockdown-shim.js`, `ses/compartment-shim.js`, and diff --git a/packages/ses/package.json b/packages/ses/package.json index 669ac6f969..979100b364 100644 --- a/packages/ses/package.json +++ b/packages/ses/package.json @@ -161,7 +161,8 @@ "isNaN", "parseFloat", "parseInt", - "unescape" + "unescape", + "AggregateError" ], "@endo/no-polymorphic-call": "error" }, diff --git a/packages/ses/src/commons.js b/packages/ses/src/commons.js index 2d53725267..2ef01d7425 100644 --- a/packages/ses/src/commons.js +++ b/packages/ses/src/commons.js @@ -47,6 +47,7 @@ export const { ReferenceError, SyntaxError, TypeError, + AggregateError, } = globalThis; export const { diff --git a/packages/ses/src/enablements.js b/packages/ses/src/enablements.js index 440398ef55..9124238aa7 100644 --- a/packages/ses/src/enablements.js +++ b/packages/ses/src/enablements.js @@ -149,6 +149,12 @@ export const moderateEnablements = { name: true, // set by "node 14" }, + // https://github.com/endojs/endo/issues/550 + '%AggregateErrorPrototype%': { + message: true, // to match TypeErrorPrototype.message + name: true, // set by "node 14"? + }, + '%PromisePrototype%': { constructor: true, // set by "core-js" }, diff --git a/packages/ses/src/error/assert.js b/packages/ses/src/error/assert.js index ada53abe86..6dae9e3ae4 100644 --- a/packages/ses/src/error/assert.js +++ b/packages/ses/src/error/assert.js @@ -21,6 +21,7 @@ import { arrayPush, assign, freeze, + defineProperty, globalThis, is, isError, @@ -33,6 +34,7 @@ import { weakmapGet, weakmapHas, weakmapSet, + AggregateError, } from '../commons.js'; import { an, bestEffortStringify } from './stringify-utils.js'; import './types.js'; @@ -257,8 +259,8 @@ const tagError = (err, optErrorName = err.name) => { */ const makeError = ( optDetails = redactedDetails`Assert failed`, - ErrorConstructor = globalThis.Error, - { errorName = undefined } = {}, + errConstructor = globalThis.Error, + { errorName = undefined, cause = undefined, errors = undefined } = {}, ) => { if (typeof optDetails === 'string') { // If it is a string, use it as the literal part of the template so @@ -270,7 +272,26 @@ const makeError = ( throw TypeError(`unrecognized details ${quote(optDetails)}`); } const messageString = getMessageString(hiddenDetails); - const error = new ErrorConstructor(messageString); + const opts = cause && { cause }; + let error; + if (errConstructor === AggregateError) { + error = AggregateError(errors || [], messageString, opts); + } else { + error = /** @type {ErrorConstructor} */ (errConstructor)( + messageString, + opts, + ); + if (errors !== undefined) { + // Since we need to tolerate `errors` on an AggregateError, may as + // well tolerate it on all errors. + defineProperty(error, 'errors', { + value: errors, + writable: true, + enumerable: false, + configurable: true, + }); + } + } weakmapSet(hiddenMessageLogArgs, error, getLogArgs(hiddenDetails)); if (errorName !== undefined) { tagError(error, errorName); @@ -382,9 +403,10 @@ const makeAssert = (optRaise = undefined, unredacted = false) => { /** @type {AssertFail} */ const fail = ( optDetails = assertFailedDetails, - ErrorConstructor = globalThis.Error, + errConstructor = undefined, + options = undefined, ) => { - const reason = makeError(optDetails, ErrorConstructor); + const reason = makeError(optDetails, errConstructor, options); if (optRaise !== undefined) { optRaise(reason); } @@ -402,9 +424,10 @@ const makeAssert = (optRaise = undefined, unredacted = false) => { function baseAssert( flag, optDetails = undefined, - ErrorConstructor = undefined, + errConstructor = undefined, + options = undefined, ) { - flag || fail(optDetails, ErrorConstructor); + flag || fail(optDetails, errConstructor, options); } /** @type {AssertEqual} */ @@ -412,12 +435,14 @@ const makeAssert = (optRaise = undefined, unredacted = false) => { actual, expected, optDetails = undefined, - ErrorConstructor = undefined, + errConstructor = undefined, + options = undefined, ) => { is(actual, expected) || fail( optDetails || details`Expected ${actual} is same as ${expected}`, - ErrorConstructor || RangeError, + errConstructor || RangeError, + options, ); }; freeze(equal); diff --git a/packages/ses/src/error/console.js b/packages/ses/src/error/console.js index 0efd6fb15b..950bdd5c31 100644 --- a/packages/ses/src/error/console.js +++ b/packages/ses/src/error/console.js @@ -169,6 +169,8 @@ export { makeLoggingConsoleKit }; const ErrorInfo = { NOTE: 'ERROR_NOTE:', MESSAGE: 'ERROR_MESSAGE:', + CAUSE: 'cause:', + ERRORS: 'errors:', }; freeze(ErrorInfo); @@ -308,6 +310,14 @@ const makeCausalConsole = (baseConsole, loggedErrorHandler) => { // 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); } diff --git a/packages/ses/src/error/internal-types.js b/packages/ses/src/error/internal-types.js index caca8cfbfe..9cfe79413b 100644 --- a/packages/ses/src/error/internal-types.js +++ b/packages/ses/src/error/internal-types.js @@ -69,7 +69,12 @@ */ /** - * @typedef {{ NOTE: 'ERROR_NOTE:', MESSAGE: 'ERROR_MESSAGE:' }} ErrorInfo + * @typedef {{ + * NOTE: 'ERROR_NOTE:', + * MESSAGE: 'ERROR_MESSAGE:', + * CAUSE: 'cause:', + * ERRORS: 'errors:', + * }} ErrorInfo */ /** diff --git a/packages/ses/src/error/types.js b/packages/ses/src/error/types.js index 4038cc5168..d3ecdfaa52 100644 --- a/packages/ses/src/error/types.js +++ b/packages/ses/src/error/types.js @@ -1,19 +1,35 @@ // @ts-check +/** + * TypeScript does not treat `AggregateErrorConstructor` as a subtype of + * `ErrorConstructor`, which makes sense because their constructors + * have incompatible signatures. However, we want to parameterize some + * operations by any error constructor, including possible `AggregateError`. + * So we introduce `GenericErrorConstructor` as a common supertype. Any call + * to it to make an instance must therefore first case split on whether the + * constructor is an AggregateErrorConstructor or a normal ErrorConstructor. + * + * @typedef {ErrorConstructor | AggregateErrorConstructor} GenericErrorConstructor + */ + /** * @callback BaseAssert * The `assert` function itself. * * @param {any} flag The truthy/falsy value - * @param {Details=} optDetails The details to throw - * @param {ErrorConstructor=} ErrorConstructor An optional alternate error - * constructor to use. + * @param {Details} [optDetails] The details to throw + * @param {GenericErrorConstructor} [errConstructor] + * An optional alternate error constructor to use. + * @param {AssertMakeErrorOptions} [options] * @returns {asserts flag} */ /** * @typedef {object} AssertMakeErrorOptions - * @property {string=} errorName + * @property {string} [errorName] + * @property {Error} [cause] + * @property {Error[]} [errors] + * Normally only used when the ErrorConstuctor is `AggregateError` */ /** @@ -22,10 +38,10 @@ * The `assert.error` method, recording details for the console. * * The optional `optDetails` can be a string. - * @param {Details=} optDetails The details of what was asserted - * @param {ErrorConstructor=} ErrorConstructor An optional alternate error - * constructor to use. - * @param {AssertMakeErrorOptions=} options + * @param {Details} [optDetails] The details of what was asserted + * @param {GenericErrorConstructor} [errConstructor] + * An optional alternate error constructor to use. + * @param {AssertMakeErrorOptions} [options] * @returns {Error} */ @@ -40,9 +56,10 @@ * * The optional `optDetails` can be a string for backwards compatibility * with the nodejs assertion library. - * @param {Details=} optDetails The details of what was asserted - * @param {ErrorConstructor=} ErrorConstructor An optional alternate error - * constructor to use. + * @param {Details} [optDetails] The details of what was asserted + * @param {GenericErrorConstructor} [errConstructor] + * An optional alternate error constructor to use. + * @param {AssertMakeErrorOptions} [options] * @returns {never} */ @@ -53,9 +70,10 @@ * Assert that two values must be `Object.is`. * @param {any} actual The value we received * @param {any} expected What we wanted - * @param {Details=} optDetails The details to throw - * @param {ErrorConstructor=} ErrorConstructor An optional alternate error - * constructor to use. + * @param {Details} [optDetails] The details to throw + * @param {GenericErrorConstructor} [errConstructor] + * An optional alternate error constructor to use. + * @param {AssertMakeErrorOptions} [options] * @returns {void} */ @@ -66,7 +84,7 @@ * @callback AssertTypeofBigint * @param {any} specimen * @param {'bigint'} typename - * @param {Details=} optDetails + * @param {Details} [optDetails] * @returns {asserts specimen is bigint} */ @@ -74,7 +92,7 @@ * @callback AssertTypeofBoolean * @param {any} specimen * @param {'boolean'} typename - * @param {Details=} optDetails + * @param {Details} [optDetails] * @returns {asserts specimen is boolean} */ @@ -82,7 +100,7 @@ * @callback AssertTypeofFunction * @param {any} specimen * @param {'function'} typename - * @param {Details=} optDetails + * @param {Details} [optDetails] * @returns {asserts specimen is Function} */ @@ -90,7 +108,7 @@ * @callback AssertTypeofNumber * @param {any} specimen * @param {'number'} typename - * @param {Details=} optDetails + * @param {Details} [optDetails] * @returns {asserts specimen is number} */ @@ -98,7 +116,7 @@ * @callback AssertTypeofObject * @param {any} specimen * @param {'object'} typename - * @param {Details=} optDetails + * @param {Details} [optDetails] * @returns {asserts specimen is Record | null} */ @@ -106,7 +124,7 @@ * @callback AssertTypeofString * @param {any} specimen * @param {'string'} typename - * @param {Details=} optDetails + * @param {Details} [optDetails] * @returns {asserts specimen is string} */ @@ -114,7 +132,7 @@ * @callback AssertTypeofSymbol * @param {any} specimen * @param {'symbol'} typename - * @param {Details=} optDetails + * @param {Details} [optDetails] * @returns {asserts specimen is symbol} */ @@ -122,7 +140,7 @@ * @callback AssertTypeofUndefined * @param {any} specimen * @param {'undefined'} typename - * @param {Details=} optDetails + * @param {Details} [optDetails] * @returns {asserts specimen is undefined} */ @@ -141,7 +159,7 @@ * * Assert an expected typeof result. * @param {any} specimen The value to get the typeof - * @param {Details=} optDetails The details to throw + * @param {Details} [optDetails] The details to throw * @returns {asserts specimen is string} */ @@ -202,7 +220,7 @@ * * @callback AssertQuote * @param {any} payload What to declassify - * @param {(string|number)=} spaces + * @param {(string|number)} [spaces] * @returns {StringablePayload} The declassified payload */ @@ -235,8 +253,8 @@ * `optRaise` returns normally, which would be unusual, the throw following * `optRaise(reason)` would still happen. * - * @param {Raise=} optRaise - * @param {boolean=} unredacted + * @param {Raise} [optRaise] + * @param {boolean} [unredacted] * @returns {Assert} */ @@ -400,6 +418,6 @@ * @callback FilterConsole * @param {VirtualConsole} baseConsole * @param {ConsoleFilter} filter - * @param {string=} topic + * @param {string} [topic] * @returns {VirtualConsole} */ diff --git a/packages/ses/src/permits.js b/packages/ses/src/permits.js index d9a0df0046..bf3d33f4d2 100644 --- a/packages/ses/src/permits.js +++ b/packages/ses/src/permits.js @@ -82,6 +82,8 @@ export const universalPropertyNames = { Iterator: 'Iterator', // https://github.com/tc39/proposal-async-iterator-helpers AsyncIterator: 'AsyncIterator', + // https://github.com/endojs/endo/issues/550 + AggregateError: 'AggregateError', // *** Other Properties of the Global Object @@ -185,7 +187,6 @@ export const uniqueGlobalPropertyNames = { // All the "subclasses" of Error. These are collectively represented in the // ECMAScript spec by the meta variable NativeError. -// TODO Add AggregateError https://github.com/Agoric/SES-shim/issues/550 export const NativeErrors = [ EvalError, RangeError, @@ -193,6 +194,8 @@ export const NativeErrors = [ SyntaxError, TypeError, URIError, + // https://github.com/endojs/endo/issues/550 + AggregateError, ]; /** @@ -599,6 +602,8 @@ export const permitted = { SyntaxError: NativeError('%SyntaxErrorPrototype%'), TypeError: NativeError('%TypeErrorPrototype%'), URIError: NativeError('%URIErrorPrototype%'), + // https://github.com/endojs/endo/issues/550 + AggregateError: NativeError('%AggregateErrorPrototype%'), '%EvalErrorPrototype%': NativeErrorPrototype('EvalError'), '%RangeErrorPrototype%': NativeErrorPrototype('RangeError'), @@ -606,6 +611,8 @@ export const permitted = { '%SyntaxErrorPrototype%': NativeErrorPrototype('SyntaxError'), '%TypeErrorPrototype%': NativeErrorPrototype('TypeError'), '%URIErrorPrototype%': NativeErrorPrototype('URIError'), + // https://github.com/endojs/endo/issues/550 + '%AggregateErrorPrototype%': NativeErrorPrototype('AggregateError'), // *** Numbers and Dates @@ -1473,9 +1480,8 @@ export const permitted = { '[[Proto]]': '%FunctionPrototype%', all: fn, allSettled: fn, - // To transition from `false` to `fn` once we also have `AggregateError` - // TODO https://github.com/Agoric/SES-shim/issues/550 - any: false, // ES2021 + // https://github.com/Agoric/SES-shim/issues/550 + any: fn, prototype: '%PromisePrototype%', race: fn, reject: fn, diff --git a/packages/ses/test/error/test-aggregate-error-console-demo.js b/packages/ses/test/error/test-aggregate-error-console-demo.js new file mode 100644 index 0000000000..8472d7ce91 --- /dev/null +++ b/packages/ses/test/error/test-aggregate-error-console-demo.js @@ -0,0 +1,20 @@ +import test from 'ava'; +import '../../index.js'; + +// This is the demo version of test-aggregate-error-console.js that +// just outputs to the actual console, rather than using the logging console +// to test. Its purpose is to eyeball rather than automated testing. +// It also serves as a demo form of test-error-cause-console.js, since +// it also shows console output for those cases. + +lockdown(); + +test('aggregate error console demo', t => { + const e3 = Error('e3'); + const e2 = Error('e2', { cause: e3 }); + const u4 = URIError('u4', { cause: e2 }); + + const a1 = AggregateError([e3, u4], 'a1', { cause: e2 }); + console.log('log1', a1); + t.is(a1.cause, e2); +}); diff --git a/packages/ses/test/error/test-aggregate-error-console.js b/packages/ses/test/error/test-aggregate-error-console.js new file mode 100644 index 0000000000..417fcc85c8 --- /dev/null +++ b/packages/ses/test/error/test-aggregate-error-console.js @@ -0,0 +1,44 @@ +import test from 'ava'; +import '../../index.js'; +import { throwsAndLogs } from './throws-and-logs.js'; + +lockdown(); + +test('aggregate error console', t => { + const e3 = Error('e3'); + const e2 = Error('e2', { cause: e3 }); + const u4 = URIError('u4', { cause: e2 }); + + const a1 = AggregateError([e3, u4], 'a1', { cause: e2 }); + throwsAndLogs( + t, + () => { + console.log('log1', a1); + throw a1; + }, + /a1/, + [ + ['log', 'log1', '(AggregateError#1)'], + ['log', 'AggregateError#1:', 'a1'], + ['log', 'stack of AggregateError\n'], + ['log', 'AggregateError#1 cause:', '(Error#2)'], + ['log', 'AggregateError#1 errors:', '(Error#3)', '(URIError#4)'], + ['group', 'Nested 3 errors under AggregateError#1'], + ['log', 'Error#2:', 'e2'], + ['log', 'stack of Error\n'], + ['log', 'Error#2 cause:', '(Error#3)'], + ['group', 'Nested error under Error#2'], + ['log', 'Error#3:', 'e3'], + ['log', 'stack of Error\n'], + ['groupEnd'], + ['log', 'URIError#4:', 'u4'], + ['log', 'stack of URIError\n'], + ['log', 'URIError#4 cause:', '(Error#2)'], + ['group', 'Nested error under URIError#4'], + ['groupEnd'], + ['groupEnd'], + ['log', 'Caught', '(AggregateError#1)'], + ], + { wrapWithCausal: true }, + ); +}); diff --git a/packages/ses/test/error/test-aggregate-error.js b/packages/ses/test/error/test-aggregate-error.js new file mode 100644 index 0000000000..a95fbd114c --- /dev/null +++ b/packages/ses/test/error/test-aggregate-error.js @@ -0,0 +1,46 @@ +import test from 'ava'; +import '../../index.js'; + +const { getOwnPropertyDescriptor } = Object; + +lockdown(); + +test('aggregate error', t => { + const e1 = Error('e1'); + const e2 = Error('e2', { cause: e1 }); + const u3 = URIError('u3', { cause: e1 }); + + const a4 = AggregateError([e2, u3], 'a4', { cause: e1 }); + t.is(a4.message, 'a4'); + t.is(a4.cause, e1); + t.deepEqual(getOwnPropertyDescriptor(a4, 'cause'), { + value: e1, + writable: true, + enumerable: false, + configurable: true, + }); + t.deepEqual(getOwnPropertyDescriptor(a4, 'errors'), { + value: [e2, u3], + writable: true, + enumerable: false, + configurable: true, + }); +}); + +test('Promise.any aggregate error', async t => { + const e1 = Error('e1'); + const e2 = Error('e2', { cause: e1 }); + const u3 = URIError('u3', { cause: e1 }); + + try { + await Promise.any([Promise.reject(e2), Promise.reject(u3)]); + } catch (a4) { + t.false('cause' in a4); + t.deepEqual(getOwnPropertyDescriptor(a4, 'errors'), { + value: [e2, u3], + writable: true, + enumerable: false, + configurable: true, + }); + } +}); diff --git a/packages/ses/test/error/test-error-cause-console.js b/packages/ses/test/error/test-error-cause-console.js new file mode 100644 index 0000000000..323558d229 --- /dev/null +++ b/packages/ses/test/error/test-error-cause-console.js @@ -0,0 +1,80 @@ +import test from 'ava'; +import '../../index.js'; +import { throwsAndLogs } from './throws-and-logs.js'; + +lockdown(); + +test('error cause console control', t => { + const e1 = Error('e1'); + throwsAndLogs( + t, + () => { + console.log('log1', e1); + throw e1; + }, + /e1/, + [ + ['log', 'log1', '(Error#1)'], + ['log', 'Error#1:', 'e1'], + ['log', 'stack of Error\n'], + ['log', 'Caught', '(Error#1)'], + ], + { wrapWithCausal: true }, + ); +}); + +test('error cause console one level', t => { + const e2 = Error('e2'); + const e1 = Error('e1', { cause: e2 }); + throwsAndLogs( + t, + () => { + console.log('log1', e1); + throw e1; + }, + /e1/, + [ + ['log', 'log1', '(Error#1)'], + ['log', 'Error#1:', 'e1'], + ['log', 'stack of Error\n'], + ['log', 'Error#1 cause:', '(Error#2)'], + ['group', 'Nested error under Error#1'], + ['log', 'Error#2:', 'e2'], + ['log', 'stack of Error\n'], + ['groupEnd'], + ['log', 'Caught', '(Error#1)'], + ], + { wrapWithCausal: true }, + ); +}); + +test('error cause console nested', t => { + const e3 = Error('e3'); + const e2 = Error('e2', { cause: e3 }); + const u1 = URIError('u1', { cause: e2 }); + throwsAndLogs( + t, + () => { + console.log('log1', u1); + throw u1; + }, + /u1/, + [ + ['log', 'log1', '(URIError#1)'], + ['log', 'URIError#1:', 'u1'], + ['log', 'stack of URIError\n'], + ['log', 'URIError#1 cause:', '(Error#2)'], + ['group', 'Nested error under URIError#1'], + ['log', 'Error#2:', 'e2'], + ['log', 'stack of Error\n'], + ['log', 'Error#2 cause:', '(Error#3)'], + ['group', 'Nested error under Error#2'], + ['log', 'Error#3:', 'e3'], + ['log', 'stack of Error\n'], + ['groupEnd'], + ['groupEnd'], + ['log', 'Caught', '(URIError#1)'], + ], + { wrapWithCausal: true }, + ); +}); diff --git a/packages/ses/test/error/test-error-cause.js b/packages/ses/test/error/test-error-cause.js new file mode 100644 index 0000000000..0416285afd --- /dev/null +++ b/packages/ses/test/error/test-error-cause.js @@ -0,0 +1,39 @@ +import test from 'ava'; +import '../../index.js'; + +const { getOwnPropertyDescriptor } = Object; + +lockdown(); + +test('error cause', t => { + const e1 = Error('e1'); + t.is(e1.message, 'e1'); + t.false('cause' in e1); + const e2 = Error('e2', { cause: e1 }); + t.is(e2.message, 'e2'); + t.is(e2.cause, e1); + t.deepEqual(getOwnPropertyDescriptor(e2, 'cause'), { + value: e1, + writable: true, + enumerable: false, + configurable: true, + }); + const u3 = URIError('u3', { cause: e1 }); + t.is(u3.message, 'u3'); + t.is(u3.cause, e1); + t.deepEqual(getOwnPropertyDescriptor(u3, 'cause'), { + value: e1, + writable: true, + enumerable: false, + configurable: true, + }); + const a4 = AggregateError([e2, u3], 'a4', { cause: e1 }); + t.is(a4.message, 'a4'); + t.is(a4.cause, e1); + t.deepEqual(getOwnPropertyDescriptor(a4, 'cause'), { + value: e1, + writable: true, + enumerable: false, + configurable: true, + }); +}); diff --git a/packages/ses/test/test-get-global-intrinsics.js b/packages/ses/test/test-get-global-intrinsics.js index b17012e4b6..19b0531612 100644 --- a/packages/ses/test/test-get-global-intrinsics.js +++ b/packages/ses/test/test-get-global-intrinsics.js @@ -60,6 +60,8 @@ test.skip('getGlobalIntrinsics', () => { 'URIError', 'WeakMap', 'WeakSet', + // https://github.com/endojs/endo/issues/550 + 'AggregateError', // *** 18.4 Other Properties of the Global Object diff --git a/packages/ses/types.d.ts b/packages/ses/types.d.ts index 79fec88bcf..c198edd1ac 100644 --- a/packages/ses/types.d.ts +++ b/packages/ses/types.d.ts @@ -119,6 +119,8 @@ export type Details = string | DetailsToken; export interface AssertMakeErrorOptions { errorName?: string; + cause?: Error; + errors?: Error[]; } type AssertTypeofBigint = ( @@ -175,6 +177,10 @@ interface ToStringable { toString(): string; } +export type GenericErrorConstructor = + | ErrorConstructor + | AggregateErrorConstructor; + export type Raise = (reason: Error) => void; // Behold: recursion. // eslint-disable-next-line no-use-before-define @@ -184,23 +190,29 @@ export interface AssertionFunctions { ( value: any, details?: Details, - errorConstructor?: ErrorConstructor, + errConstructor?: GenericErrorConstructor, + options?: AssertMakeErrorOptions, ): asserts value; typeof: AssertTypeof; equal( left: any, right: any, details?: Details, - errorConstructor?: ErrorConstructor, + errConstructor?: GenericErrorConstructor, + options?: AssertMakeErrorOptions, ): void; string(specimen: any, details?: Details): asserts specimen is string; - fail(details?: Details, errorConstructor?: ErrorConstructor): never; + fail( + details?: Details, + errConstructor?: GenericErrorConstructor, + options?: AssertMakeErrorOptions, + ): never; } export interface AssertionUtilities { error( details?: Details, - errorConstructor?: ErrorConstructor, + errConstructor?: GenericErrorConstructor, options?: AssertMakeErrorOptions, ): Error; note(error: Error, details: Details): void;