Compare commits

...

7 Commits

Author SHA1 Message Date
Michael B. Gale f56d0ec51b Add hasMessage to RecordingLogger 2026-02-24 20:45:41 +00:00
Michael B. Gale 33edb83959 Replace getRecordingLogger implementation with RecordingLogger 2026-02-24 20:40:41 +00:00
Michael B. Gale 33276bfbdb Add assertNotLogged test helper 2026-02-24 20:36:24 +00:00
Henry Mercer 16adc4e672 Merge pull request #3506 from github/henrymercer/result-better-inference
Improve type inference of `Result<T, E>`
2026-02-24 20:05:34 +00:00
Henry Mercer 2a607fea25 Update JSDoc
Co-authored-by: Michael B. Gale <mbg@github.com>
2026-02-24 19:28:27 +00:00
Henry Mercer e51b6a9a52 Update names in tests 2026-02-24 17:55:29 +00:00
Henry Mercer 160d27baf0 Improve type inference of Result<T, E> 2026-02-24 17:41:30 +00:00
5 changed files with 138 additions and 108 deletions
+23 -21
View File
@@ -103609,30 +103609,32 @@ function joinAtMost(array, separator, limit) {
}
return array.join(separator);
}
var Result = class _Result {
constructor(_ok, value) {
this._ok = _ok;
var Success = class {
constructor(value) {
this.value = value;
}
/** Creates a success result. */
static success(value) {
return new _Result(true, value);
}
/** Creates a failure result. */
static failure(value) {
return new _Result(false, value);
}
/** Whether this result represents a success. */
isSuccess() {
return this._ok;
return true;
}
/** Whether this result represents a failure. */
isFailure() {
return !this._ok;
return false;
}
orElse(_defaultValue) {
return this.value;
}
};
var Failure = class {
constructor(value) {
this.value = value;
}
isSuccess() {
return false;
}
isFailure() {
return true;
}
/** Get the value if this is a success, or return the default value if this is a failure. */
orElse(defaultValue) {
return this.isSuccess() ? this.value : defaultValue;
return defaultValue;
}
};
@@ -109782,23 +109784,23 @@ async function loadRepositoryProperties(repositoryNwo, gitHubVersion, features,
logger.debug(
"Skipping loading repository properties because the repository is owned by a user and therefore cannot have repository properties."
);
return Result.success({});
return new Success({});
}
if (!await features.getValue("use_repository_properties_v2" /* UseRepositoryProperties */)) {
logger.debug(
"Skipping loading repository properties because the UseRepositoryProperties feature flag is disabled."
);
return Result.success({});
return new Success({});
}
try {
return Result.success(
return new Success(
await loadPropertiesFromApi(gitHubVersion, logger, repositoryNwo)
);
} catch (error3) {
logger.warning(
`Failed to load repository properties: ${getErrorMessage(error3)}`
);
return Result.failure(error3);
return new Failure(error3);
}
}
function getTrapCachingEnabled() {
+6 -4
View File
@@ -96,6 +96,8 @@ import {
GitHubVersion,
Result,
getOptionalEnvVar,
Success,
Failure,
} from "./util";
import { checkWorkflow } from "./workflow";
@@ -834,25 +836,25 @@ async function loadRepositoryProperties(
"Skipping loading repository properties because the repository is owned by a user and " +
"therefore cannot have repository properties.",
);
return Result.success({});
return new Success({});
}
if (!(await features.getValue(Feature.UseRepositoryProperties))) {
logger.debug(
"Skipping loading repository properties because the UseRepositoryProperties feature flag is disabled.",
);
return Result.success({});
return new Success({});
}
try {
return Result.success(
return new Success(
await loadPropertiesFromApi(gitHubVersion, logger, repositoryNwo),
);
} catch (error) {
logger.warning(
`Failed to load repository properties: ${getErrorMessage(error)}`,
);
return Result.failure(error);
return new Failure(error);
}
}
+59 -42
View File
@@ -157,8 +157,8 @@ export interface LoggedMessage {
export class RecordingLogger implements Logger {
messages: LoggedMessage[] = [];
groups: string[] = [];
unfinishedGroups: Set<string> = new Set();
readonly groups: string[] = [];
readonly unfinishedGroups: Set<string> = new Set();
private currentGroup: string | undefined = undefined;
constructor(private readonly logToConsole: boolean = true) {}
@@ -172,6 +172,19 @@ export class RecordingLogger implements Logger {
}
}
/**
* Checks whether the logged messages contain `messageOrRegExp`.
*
* If `messageOrRegExp` is a string, this function returns true as long as
* `messageOrRegExp` appears as part of one of the `messages`.
*
* If `messageOrRegExp` is a regular expression, this function returns true as long as
* one of the `messages` matches `messageOrRegExp`.
*/
hasMessage(messageOrRegExp: string | RegExp): boolean {
return hasLoggedMessage(this.messages, messageOrRegExp);
}
isDebug() {
return true;
}
@@ -210,41 +223,37 @@ export function getRecordingLogger(
messages: LoggedMessage[],
{ logToConsole }: { logToConsole?: boolean } = { logToConsole: true },
): Logger {
return {
debug: (message: string) => {
messages.push({ type: "debug", message });
if (logToConsole) {
// eslint-disable-next-line no-console
console.debug(message);
}
},
info: (message: string) => {
messages.push({ type: "info", message });
if (logToConsole) {
// eslint-disable-next-line no-console
console.info(message);
}
},
warning: (message: string | Error) => {
messages.push({ type: "warning", message });
if (logToConsole) {
// eslint-disable-next-line no-console
console.warn(message);
}
},
error: (message: string | Error) => {
messages.push({ type: "error", message });
if (logToConsole) {
// eslint-disable-next-line no-console
console.error(message);
}
},
isDebug: () => true,
startGroup: () => undefined,
endGroup: () => undefined,
};
const logger = new RecordingLogger(logToConsole);
logger.messages = messages;
return logger;
}
/**
* Checks whether `messages` contains `messageOrRegExp`.
*
* If `messageOrRegExp` is a string, this function returns true as long as
* `messageOrRegExp` appears as part of one of the `messages`.
*
* If `messageOrRegExp` is a regular expression, this function returns true as long as
* one of the `messages` matches `messageOrRegExp`.
*/
function hasLoggedMessage(
messages: LoggedMessage[],
messageOrRegExp: string | RegExp,
): boolean {
const check = (val: string) =>
typeof messageOrRegExp === "string"
? val.includes(messageOrRegExp)
: messageOrRegExp.test(val);
return messages.some(
(msg) => typeof msg.message === "string" && check(msg.message),
);
}
/**
* Checks that `messages` contains all of `expectedMessages`.
*/
export function checkExpectedLogMessages(
t: ExecutionContext<any>,
messages: LoggedMessage[],
@@ -253,13 +262,7 @@ export function checkExpectedLogMessages(
const missingMessages: string[] = [];
for (const expectedMessage of expectedMessages) {
if (
!messages.some(
(msg) =>
typeof msg.message === "string" &&
msg.message.includes(expectedMessage),
)
) {
if (!hasLoggedMessage(messages, expectedMessage)) {
missingMessages.push(expectedMessage);
}
}
@@ -276,6 +279,20 @@ export function checkExpectedLogMessages(
}
}
/**
* Asserts that `message` should not have been logged to `logger`.
*/
export function assertNotLogged(
t: ExecutionContext<any>,
logger: RecordingLogger,
message: string | RegExp,
) {
t.false(
logger.hasMessage(message),
`'${message}' should not have been logged, but was.`,
);
}
/**
* Initialises a recording logger and calls `body` with it.
*
+8 -8
View File
@@ -564,27 +564,27 @@ test("joinAtMost - truncates list if array is > than limit", (t) => {
t.false(result.includes("test6"));
});
test("Result.success creates a success result", (t) => {
const result = util.Result.success("test value");
test("Success creates a success result", (t) => {
const result = new util.Success("test value");
t.true(result.isSuccess());
t.false(result.isFailure());
t.is(result.value, "test value");
});
test("Result.failure creates a failure result", (t) => {
test("Failure creates a failure result", (t) => {
const error = new Error("test error");
const result = util.Result.failure(error);
const result = new util.Failure(error);
t.false(result.isSuccess());
t.true(result.isFailure());
t.is(result.value, error);
});
test("Result.orElse returns the value for a success result", (t) => {
const result = util.Result.success("success value");
test("Success.orElse returns the value for a success result", (t) => {
const result = new util.Success("success value");
t.is(result.orElse("default value"), "success value");
});
test("Result.orElse returns the default value for a failure result", (t) => {
const result = util.Result.failure(new Error("test error"));
test("Failure.orElse returns the default value for a failure result", (t) => {
const result = new util.Failure(new Error("test error"));
t.is(result.orElse("default value"), "default value");
});
+42 -33
View File
@@ -1291,42 +1291,51 @@ export function joinAtMost(
return array.join(separator);
}
/** A success result. */
type Success<T> = Result<T, never>;
/** A failure result. */
type Failure<E> = Result<never, E>;
/**
* A simple result type representing either a success or a failure.
*/
export class Result<T, E> {
private constructor(
private readonly _ok: boolean,
public readonly value: T | E,
) {}
/** Creates a success result. */
static success<T>(value: T): Success<T> {
return new Result(true, value) as Success<T>;
}
/** Creates a failure result. */
static failure<E>(value: E): Failure<E> {
return new Result(false, value) as Failure<E>;
}
/** An interface representing something that is either a success or a failure. */
interface ResultLike<T, E> {
/** The value of the result, which can be either a success value or a failure value. */
value: T | E;
/** Whether this result represents a success. */
isSuccess(): this is Success<T> {
return this._ok;
}
isSuccess(): this is Success<T>;
/** Whether this result represents a failure. */
isFailure(): this is Failure<E> {
return !this._ok;
isFailure(): this is Failure<E>;
/** Get the value if this is a success, or return the default value if this is a failure. */
orElse<U>(defaultValue: U): T | U;
}
/** A simple result type representing either a success or a failure. */
export type Result<T, E> = Success<T> | Failure<E>;
/** A result representing a success. */
export class Success<T> implements ResultLike<T, never> {
constructor(public readonly value: T) {}
isSuccess(): this is Success<T> {
return true;
}
/** Get the value if this is a success, or return the default value if this is a failure. */
orElse<U>(defaultValue: U): T | U {
return this.isSuccess() ? this.value : defaultValue;
isFailure(): this is Failure<never> {
return false;
}
orElse<U>(_defaultValue: U): T {
return this.value;
}
}
/** A result representing a failure. */
export class Failure<E> implements ResultLike<never, E> {
constructor(public readonly value: E) {}
isSuccess(): this is Success<never> {
return false;
}
isFailure(): this is Failure<E> {
return true;
}
orElse<U>(defaultValue: U): U {
return defaultValue;
}
}