Merge remote-tracking branch 'origin/main' into mbg/config/merge

This commit is contained in:
Michael B. Gale
2026-07-13 09:25:36 +01:00
14 changed files with 369 additions and 173 deletions

View File

@@ -13,7 +13,7 @@ test("getConfigFileInput returns undefined by default", async (t) => {
await callee(getConfigFileInput)
.withArgs({})
.withFeatures([Feature.ConfigFileRepositoryProperty])
.passes(async (fn) => t.is(await fn(), undefined));
.passes(t.is, undefined);
});
const repositoryProperties = {
@@ -22,47 +22,29 @@ const repositoryProperties = {
test("getConfigFileInput returns input value", async (t) => {
const testInput = "/some/path";
const target = callee(getConfigFileInput).withFeatures([
Feature.ConfigFileRepositoryProperty,
]);
const actionsEnv = target.getState().actions;
sinon
.stub(actionsEnv, "getOptionalInput")
.withArgs("config-file")
.returns(testInput);
// Even though both an input and repository property are configured,
// we prefer the direct input to the Action.
const targetWithArgs = target
.withActions(actionsEnv)
.withArgs(repositoryProperties);
await targetWithArgs.passes(async (fn) => t.is(await fn(), testInput));
// Check for the expected log message.
t.true(
targetWithArgs
.getLogger()
.hasMessage("Using configuration file input from workflow"),
);
await callee(getConfigFileInput)
.withFeatures([Feature.ConfigFileRepositoryProperty])
.withActions((actionsEnv) => {
sinon
.stub(actionsEnv, "getOptionalInput")
.withArgs("config-file")
.returns(testInput);
})
.withArgs(repositoryProperties)
.logs(t, "Using configuration file input from workflow")
.passes(t.is, testInput);
});
test("getConfigFileInput returns repository property value", async (t) => {
// Since there is no direct input, we should use the repository property.
const target = callee(getConfigFileInput)
await callee(getConfigFileInput)
.withFeatures([Feature.ConfigFileRepositoryProperty])
.withArgs(repositoryProperties);
await target.passes(async (fn) =>
t.is(await fn(), repositoryProperties[RepositoryPropertyName.CONFIG_FILE]),
);
// Check for the expected log message.
t.true(
target
.getLogger()
.hasMessage("Using configuration file input from repository property"),
);
.withArgs(repositoryProperties)
.logs(t, "Using configuration file input from repository property")
.passes(t.is, repositoryProperties[RepositoryPropertyName.CONFIG_FILE]);
});
test("getConfigFileInput ignores empty repository property value", async (t) => {
@@ -70,27 +52,18 @@ test("getConfigFileInput ignores empty repository property value", async (t) =>
await callee(getConfigFileInput)
.withFeatures([Feature.ConfigFileRepositoryProperty])
.withArgs({ [RepositoryPropertyName.CONFIG_FILE]: " " })
.passes(async (fn) => t.is(await fn(), undefined));
.passes(t.is, undefined);
});
test("getConfigFileInput ignores repository property value when FF is off", async (t) => {
// Since the FF is off, we should ignore the repository property value.
const target = callee(getConfigFileInput)
await callee(getConfigFileInput)
.withFeatures([])
.withArgs(repositoryProperties);
await target.passes(async (fn) => t.is(await fn(), undefined));
t.false(
target
.getLogger()
.hasMessage("Using configuration file input from repository property"),
);
t.true(
target
.getLogger()
.hasMessage(
"Ignoring configuration file input from repository property, because the corresponding feature flag is disabled.",
),
);
.withArgs(repositoryProperties)
.notLogs(t, "Using configuration file input from repository property")
.logs(
t,
"Ignoring configuration file input from repository property, because the corresponding feature flag is disabled.",
)
.passes(t.is, undefined);
});

View File

@@ -4,7 +4,7 @@ import sinon from "sinon";
import { ActionsEnvVars } from "../environment";
import * as errors from "../error-messages";
import { Feature } from "../feature-flags";
import { callee, getTestEnv } from "../testing-utils";
import { callee } from "../testing-utils";
import { ConfigurationError } from "../util";
import {
@@ -50,7 +50,7 @@ test("parseRemoteFileAddress accepts full remote addresses", async (t) => {
for (const oldFormatInput of oldFormatInputs) {
await target
.withArgs(oldFormatInput.input)
.passes(async (fn) => t.deepEqual(await fn(), oldFormatInput.expected));
.passes(t.deepEqual, oldFormatInput.expected);
}
// New format.
@@ -78,28 +78,23 @@ test("parseRemoteFileAddress accepts full remote addresses", async (t) => {
// Should fail when the FF is not enabled.
await targetWithArgs
.withFeatures([])
.passes(async (fn) =>
t.throwsAsync(fn, { instanceOf: ConfigurationError }),
);
.throws(t, { instanceOf: ConfigurationError });
// And pass when the FF is enabled.
await targetWithArgs
.withFeatures([Feature.NewRemoteFileAddresses])
.passes(async (fn) => t.deepEqual(await fn(), newFormatInput.expected));
.passes(t.deepEqual, newFormatInput.expected);
}
});
test("parseRemoteFileAddress accepts remote address without an owner", async (t) => {
const target = callee(parseRemoteFileAddress);
const env = target.getState().env;
const owner = "test-owner";
const getRequired = sinon.stub(env, "getRequired");
getRequired
.withArgs(ActionsEnvVars.GITHUB_REPOSITORY)
.returns(`${owner}/current-repo`);
const targetWithEnv = target.withEnv(env);
const target = callee(parseRemoteFileAddress).withEnv((env) => {
const getRequired = sinon.stub(env, "getRequired");
getRequired
.withArgs(ActionsEnvVars.GITHUB_REPOSITORY)
.returns(`${owner}/current-repo`);
});
const testCases: ParseRemoteFileAddressTest[] = [
{
@@ -141,33 +136,33 @@ test("parseRemoteFileAddress accepts remote address without an owner", async (t)
];
for (const testCase of testCases) {
const targetWithArgs = targetWithEnv.withArgs(testCase.input);
const targetWithArgs = target.withArgs(testCase.input);
// Should fail when the FF is not enabled.
await targetWithArgs
.withFeatures([])
.passes(async (fn) =>
t.throwsAsync(fn, { instanceOf: ConfigurationError }),
);
.throws(t, { instanceOf: ConfigurationError });
// And pass when the FF is enabled.
await targetWithArgs
.withFeatures([Feature.NewRemoteFileAddresses])
.passes(async (fn) => t.deepEqual(await fn(), testCase.expected));
.passes(t.deepEqual, testCase.expected);
}
});
test("parseRemoteFileAddress throws for invalid `GITHUB_REPOSITORY`", async (t) => {
const target = callee(parseRemoteFileAddress).withArgs("repo@ref");
const env = target.getState().env;
const getRequired = sinon.stub(env, "getRequired");
const getRequired: sinon.SinonStub = sinon.stub();
getRequired.withArgs(ActionsEnvVars.GITHUB_REPOSITORY).returns(`not-valid`);
const target = callee(parseRemoteFileAddress)
.withArgs("repo@ref")
.withEnv((env) => {
sinon.define(env, "getRequired", getRequired);
});
await target
.withEnv(env)
.withFeatures([Feature.NewRemoteFileAddresses])
.passes(async (fn) => t.throwsAsync(fn, { instanceOf: Error }));
.throws(t, { instanceOf: Error });
t.assert(getRequired.calledOnceWith(ActionsEnvVars.GITHUB_REPOSITORY));
});
@@ -202,14 +197,12 @@ test("parseRemoteFileAddress accepts remote address without a path", async (t) =
// Should fail when the FF is not enabled.
await targetWithArgs
.withFeatures([])
.passes(async (fn) =>
t.throwsAsync(fn, { instanceOf: ConfigurationError }),
);
.throws(t, { instanceOf: ConfigurationError });
// And pass when the FF is enabled.
await targetWithArgs
.withFeatures([Feature.NewRemoteFileAddresses])
.passes(async (fn) => t.deepEqual(await fn(), testCase.expected));
.passes(t.deepEqual, testCase.expected);
}
});
@@ -217,28 +210,25 @@ test("parseRemoteFileAddress accepts remote address without a ref", async (t) =>
const target = callee(parseRemoteFileAddress).withArgs("owner/repo:path");
// Should only accept the input if the FF is enabled.
await target.withFeatures([]).passes(t.throwsAsync);
await target.withFeatures([]).throws(t);
await target
.withFeatures([Feature.NewRemoteFileAddresses])
.passes(async (fn) =>
t.deepEqual(await fn(), {
owner: "owner",
repo: "repo",
path: "path",
ref: DEFAULT_CONFIG_FILE_REF,
} satisfies RemoteFileAddress),
);
.passes(t.deepEqual, {
owner: "owner",
repo: "repo",
path: "path",
ref: DEFAULT_CONFIG_FILE_REF,
} satisfies RemoteFileAddress);
});
test("parseRemoteFileAddress rejects invalid values", async (t) => {
const env = getTestEnv();
const owner = "owner";
const getRequired = sinon.stub(env, "getRequired");
getRequired
.withArgs(ActionsEnvVars.GITHUB_REPOSITORY)
.returns(`${owner}/current-repo`);
const target = callee(parseRemoteFileAddress).withEnv(env);
const target = callee(parseRemoteFileAddress).withEnv((env) => {
const getRequired = sinon.stub(env, "getRequired");
getRequired
.withArgs(ActionsEnvVars.GITHUB_REPOSITORY)
.returns(`${owner}/current-repo`);
});
const testInputs = [
" ",
@@ -262,21 +252,17 @@ test("parseRemoteFileAddress rejects invalid values", async (t) => {
const targetWithArgs = target.withArgs(testInput);
// Should throw both when the new format is and isn't accepted.
await targetWithArgs.withFeatures([]).passes(async (fn) =>
t.throwsAsync(fn, {
instanceOf: ConfigurationError,
message: errors.getConfigFileRepoOldFormatInvalidMessage(testInput),
}),
);
await targetWithArgs.withFeatures([]).throws(t, {
instanceOf: ConfigurationError,
message: errors.getConfigFileRepoOldFormatInvalidMessage(testInput),
});
await targetWithArgs
.withFeatures([Feature.NewRemoteFileAddresses])
.passes(async (fn) =>
t.throwsAsync(fn, {
// When the new format is accepted, there are some more specific
// errors in some cases. It is sufficient for us to check that
// an exception is thrown.
instanceOf: ConfigurationError,
}),
);
.throws(t, {
// When the new format is accepted, there are some more specific
// errors in some cases. It is sufficient for us to check that
// an exception is thrown.
instanceOf: ConfigurationError,
});
}
});

View File

@@ -193,10 +193,12 @@ export enum ActionsEnvVars {
GITHUB_SERVER_URL = "GITHUB_SERVER_URL",
GITHUB_SHA = "GITHUB_SHA",
GITHUB_WORKFLOW = "GITHUB_WORKFLOW",
GITHUB_WORKSPACE = "GITHUB_WORKSPACE",
RUNNER_ENVIRONMENT = "RUNNER_ENVIRONMENT",
RUNNER_NAME = "RUNNER_NAME",
RUNNER_OS = "RUNNER_OS",
RUNNER_TEMP = "RUNNER_TEMP",
RUNNER_TOOL_CACHE = "RUNNER_TOOL_CACHE",
}
/** A type representing all known environment variables. */

View File

@@ -120,9 +120,14 @@ const mixedCredentials = [
{ type: "maven_repository", host: "maven.pkg.github.com", token: "def" },
{ type: "nuget_feed", host: "nuget.pkg.github.com", token: "ghi" },
{ type: "goproxy_server", host: "goproxy.example.com", token: "jkl" },
{ type: "git_source", host: "github.com/github", token: "mno" },
];
const gitSourceCredential = {
type: "git_source",
host: "github.com/github",
token: "mno",
};
test("getCredentials prefers registriesCredentials over registrySecrets", async (t) => {
const registryCredentials = Buffer.from(
JSON.stringify([
@@ -241,7 +246,7 @@ test("getCredentials returns all for a language when specified", async (t) => {
const credentials = startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
toEncodedJSON(mixedCredentials),
toEncodedJSON([...mixedCredentials, gitSourceCredential]),
BuiltInLanguage.go,
);
t.is(credentials.length, 2);
@@ -284,7 +289,7 @@ test("getCredentials returns all maven_repositories for Java when specified", as
host: "maven2.pkg.github.com",
token: "token2",
},
{ type: "git_source", host: "github.com/github", token: "mno" },
{ type: "goproxy_server", host: "github.com/github", token: "mno" },
];
const credentials = startProxyExports.getCredentials(
@@ -624,8 +629,11 @@ test("getCredentials validates 'replaces-base' correctly", async (t) => {
);
});
test("getCredentials returns no credentials for Actions", async (t) => {
const credentialsInput = toEncodedJSON(mixedCredentials);
test("getCredentials returns only ALWAYS_ENABLED_REGISTRY_TYPE credentials for Actions", async (t) => {
const credentialsInput = toEncodedJSON([
...mixedCredentials,
gitSourceCredential,
]);
const credentials = startProxyExports.getCredentials(
getRunnerLogger(true),
@@ -633,7 +641,41 @@ test("getCredentials returns no credentials for Actions", async (t) => {
credentialsInput,
BuiltInLanguage.actions,
);
t.deepEqual(credentials, []);
for (const credential of credentials) {
t.true(
startProxyExports.ALWAYS_ENABLED_REGISTRY_TYPE.some(
(ty) => ty === credential.type,
),
);
}
});
test("getCredentials always returns ALWAYS_ENABLED_REGISTRY_TYPE credentials for all languages", async (t) => {
const alwaysEnabledCredentials: startProxyExports.Credential[] = [];
for (const alwaysEnabled of startProxyExports.ALWAYS_ENABLED_REGISTRY_TYPE) {
alwaysEnabledCredentials.push({
type: alwaysEnabled,
host: `host-${alwaysEnabled}`,
token: `bar-${alwaysEnabled}`,
url: `url-${alwaysEnabled}`,
});
}
const credentialsInput = toEncodedJSON(alwaysEnabledCredentials);
// Test all languages.
for (const language of Object.values(BuiltInLanguage)) {
const credentials = startProxyExports.getCredentials(
getRunnerLogger(true),
undefined,
credentialsInput,
language,
);
t.deepEqual(credentials, alwaysEnabledCredentials);
}
});
function mockGetApiClient(endpoints: any) {

View File

@@ -187,9 +187,16 @@ function isPAT(value: string) {
]);
}
/**
* A list of always-enabled registry types. The registry types in this list are always
* enabled, because generic CodeQL workflow components may use them rather than just
* language-specific components.
*/
export const ALWAYS_ENABLED_REGISTRY_TYPE = ["git_source"] as const;
type RegistryMapping = Partial<Record<BuiltInLanguage, string[]>>;
const LANGUAGE_TO_REGISTRY_TYPE: Required<RegistryMapping> = {
export const LANGUAGE_TO_REGISTRY_TYPE: Required<RegistryMapping> = {
actions: [],
cpp: [],
java: ["maven_repository"],
@@ -233,9 +240,11 @@ function getRegistryAddress(
}
}
// getCredentials returns registry credentials from action inputs.
// It prefers `registries_credentials` over `registry_secrets`.
// If neither is set, it returns an empty array.
/**
* Returns registry credentials from action inputs.
* It prefers `registriesCredentials` over `registrySecrets`.
* If neither is set, it returns an empty array.
*/
export function getCredentials(
logger: Logger,
registrySecrets: string | undefined,
@@ -291,8 +300,11 @@ export function getCredentials(
const address = getRegistryAddress(e);
// Filter credentials based on language if specified. `type` is the registry type.
// E.g., "maven_feed" for Java/Kotlin, "nuget_repository" for C#.
// E.g., "maven_repository" for Java/Kotlin, "nuget_feed" for C#.
// We always allow types in `ALWAYS_ENABLED_REGISTRY_TYPE` since they can be used by
// other parts of the workflow.
if (
!ALWAYS_ENABLED_REGISTRY_TYPE.some((t) => t === e.type) &&
registryTypeForLanguage &&
!registryTypeForLanguage.some((t) => t === e.type)
) {

View File

@@ -3,6 +3,8 @@ import path from "path";
import * as github from "@actions/github";
import test, {
type ThrownError,
type ThrowsExpectation,
type ExecutionContext,
type MacroDeclarationOptions,
type TestFn,
@@ -208,35 +210,50 @@ export function initAllState(
};
}
type DelayedCheck<
Args extends readonly any[],
R,
Fs extends ReadonlyArray<AllState[number]>,
> = (env: Readonly<BaseEnvBuilder<Args, R, Fs>>) => Promise<any>;
export type ValueOrMutation<T> = T | ((val: T) => void);
/**
* Wraps a function that accepts an `ActionState` for testing in different environments.
*/
export class TestEnv<
abstract class BaseEnvBuilder<
Args extends readonly any[],
R,
Fs extends ReadonlyArray<AllState[number]>,
> {
private readonly fn: (state: ActionState<Fs>, ...args: Args) => R;
private args?: Args;
protected readonly fn: (state: ActionState<Fs>, ...args: Args) => R;
private logger: RecordingLogger;
private state: ActionState<AllState>;
protected state: ActionState<AllState>;
protected checks: Array<DelayedCheck<Args, R, Fs>>;
constructor(
fn: (state: ActionState<Fs>, ...args: Args) => R,
cloneFrom?: TestEnv<Args, R, Fs>,
cloneFrom?: BaseEnvBuilder<Args, R, Fs>,
) {
this.fn = fn;
this.args = cloneFrom?.args;
this.logger = new RecordingLogger();
this.state =
cloneFrom !== undefined
? { ...cloneFrom.state, logger: this.logger }
? ({
...cloneFrom.state,
env: Object.create(cloneFrom.state.env),
actions: Object.create(cloneFrom.state.actions),
logger: this.logger,
} satisfies ActionState<AllState>)
: initAllState({ logger: this.logger });
this.checks = [...(cloneFrom?.checks ?? [])];
}
private clone(): TestEnv<Args, R, Fs> {
return new TestEnv(this.fn, this);
}
/**
* Creates a clone of this object. Used internally.
* Must be overridden by subclasses.
*/
protected abstract clone(): this;
public getLogger(): RecordingLogger {
return this.logger;
@@ -246,48 +263,181 @@ export class TestEnv<
return this.state;
}
public getArgs(): Args | undefined {
return this.args;
}
public withArgs(...args: Args) {
const result = this.clone();
result.args = args;
public withArgs(...args: Args): CallableEnvBuilder<Args, R, Fs> {
const result = new CallableEnvBuilder(this.fn, args, this.clone());
return result;
}
public withFeatures(enabled: Feature[]): TestEnv<Args, R, Fs> {
public withFeatures(enabled: Feature[]): this {
const result = this.clone();
result.state.features = createFeatures(enabled);
return result;
}
public withEnv(env: Env): TestEnv<Args, R, Fs> {
/**
* Sets environment variables that are always available to GitHub Actions,
* excluding some that are expected to be set to paths.
*
* @param overrides Overrides for the defaults.
*/
public withDefaultActionsEnv(overrides?: ActionVarOverrides): this {
const result = this.clone();
result.state.env = env;
setupBaseActionsVars(overrides, result.state.env);
return result;
}
public withActions(actions: ActionsEnv): TestEnv<Args, R, Fs> {
/**
* Sets environment variables that are always available to GitHub Actions.
* @param tempDir A value for `RUNNER_TEMP` and `GITHUB_WORKSPACE`.
* @param toolsDir A value for `RUNNER_TOOL_CACHE`.
* @param overrides Overrides for the defaults.
*/
public withActionsEnv(
tempDir: string,
toolsDir: string,
overrides?: ActionVarOverrides,
): this {
const result = this.clone();
result.state.actions = actions;
setupActionsVars(tempDir, toolsDir, overrides, result.state.env);
return result;
}
public withEnv(arg: ValueOrMutation<Env>): this {
const result = this.clone();
if (typeof arg === "function") {
arg(result.state.env);
} else {
result.state.env = arg;
}
return result;
}
public withActions(arg: ValueOrMutation<ActionsEnv>): this {
const result = this.clone();
if (typeof arg === "function") {
arg(result.state.actions);
} else {
result.state.actions = arg;
}
return result;
}
/**
* Adds a delayed check that `messages` are logged. The check will be
* performed after the main assertion passes.
*/
public logs(t: ExecutionContext<unknown>, ...messages: string[]): this {
const result = this.clone();
result.checks.push(async (env) => {
checkExpectedLogMessages(t, env.getLogger().messages, messages);
});
return result;
}
/**
* Adds a delayed check that `messages` are not logged. The check will be
* performed after the main assertion passes.
*/
public notLogs(t: ExecutionContext<unknown>, ...messages: string[]): this {
const result = this.clone();
result.checks.push(async (env) => {
checkUnexpectedLogMessages(t, env.getLogger().messages, messages);
});
return result;
}
}
class EnvBuilder<
Args extends readonly any[],
R,
Fs extends ReadonlyArray<AllState[number]>,
> extends BaseEnvBuilder<Args, R, Fs> {
protected clone(): this {
return new EnvBuilder(this.fn, this) as this;
}
}
class CallableEnvBuilder<
Args extends readonly any[],
R,
Fs extends ReadonlyArray<AllState[number]>,
> extends BaseEnvBuilder<Args, R, Fs> {
private args: Args;
constructor(
fn: (state: ActionState<Fs>, ...args: Args) => R,
args: Args,
cloneFrom?: BaseEnvBuilder<Args, R, Fs>,
) {
super(fn, cloneFrom);
this.args = args;
}
protected clone(): this {
return new CallableEnvBuilder(this.fn, this.args, this) as this;
}
public getArgs(): Args {
return this.args;
}
call(): R {
if (!this.args) {
throw new Error("Trying to call function in TestEnv without arguments.");
}
return this.fn(this.state as unknown as ActionState<Fs>, ...this.args);
}
public passes<T>(
assertion: (makeCall: () => R) => T | Promise<T>,
): T | Promise<T> {
return assertion(() => {
const result = this.call();
return result;
});
/**
* Calls the underlying function in the configured environment and passes
* the result to `assertion` along with extra `assertionArgs`.
*
* @param assertion The assertion to apply to the result.
* @param assertionArgs Extra arguments for the assertion.
* @returns The result of the assertion.
*/
public async passes<AArgs extends readonly any[], AResult>(
assertion: (val: Awaited<R>, ...assertionArgs: AArgs) => AResult,
...assertionArgs: AArgs
): Promise<AResult> {
// this.call() may or may not return a promise,
// `Promise.resolve` turns the result into one if it isn't already,
// and we then await it. That ensures that `result` is an `Awaited<R>`.
const result = await Promise.resolve(this.call());
// Run the main assertion on the `result`.
const assertionResult = await assertion(result, ...assertionArgs);
// Run other delayed checks.
for (const delayedCheck of this.checks) {
await delayedCheck(this);
}
// Return the result of the main assertion.
return assertionResult;
}
/**
* Asserts that calling the underlying function should throw an exception.
*
* @param t The execution context for the assertion.
* @param expectations Expectations for the error.
* @returns The error that was thrown.
*/
public async throws<ErrorType extends ErrorConstructor | Error>(
t: ExecutionContext<unknown>,
expectations?: ThrowsExpectation<ErrorType>,
): Promise<ThrownError<ErrorType>> {
// Run the main assertion.
const error = await t.throwsAsync(
async () => Promise.resolve(this.call()),
expectations,
);
// Run other delayed checks.
for (const delayedCheck of this.checks) {
await delayedCheck(this);
}
// Return the error.
return error;
}
}
@@ -296,8 +446,8 @@ export function callee<
Args extends readonly any[],
R,
Fs extends readonly StateFeature[],
>(fn: (state: ActionState<Fs>, ...args: Args) => R): TestEnv<Args, R, Fs> {
return new TestEnv(fn);
>(fn: (state: ActionState<Fs>, ...args: Args) => R): EnvBuilder<Args, R, Fs> {
return new EnvBuilder(fn);
}
/**
@@ -349,16 +499,18 @@ export function setupBaseActionsVars(
* @param tempDir A value for `RUNNER_TEMP` and `GITHUB_WORKSPACE`.
* @param toolsDir A value for `RUNNER_TOOL_CACHE`.
* @param overrides Overrides for the defaults.
* @param env The environment to set the variables for.
*/
export function setupActionsVars(
tempDir: string,
toolsDir: string,
overrides?: ActionVarOverrides,
env: Env = getEnv(),
) {
setupBaseActionsVars(overrides);
process.env["RUNNER_TEMP"] = tempDir;
process.env["RUNNER_TOOL_CACHE"] = toolsDir;
process.env["GITHUB_WORKSPACE"] = tempDir;
setupBaseActionsVars(overrides, env);
env.set(ActionsEnvVars.RUNNER_TEMP, tempDir);
env.set(ActionsEnvVars.RUNNER_TOOL_CACHE, toolsDir);
env.set(ActionsEnvVars.GITHUB_WORKSPACE, tempDir);
}
type LogLevel = "debug" | "info" | "warning" | "error";
@@ -492,6 +644,34 @@ export function checkExpectedLogMessages(
}
}
/**
* Checks that `messages` contains none of `unexpectedMessages`.
*/
export function checkUnexpectedLogMessages(
t: ExecutionContext<any>,
messages: LoggedMessage[],
unexpectedMessages: string[],
) {
const presentMessages: string[] = [];
for (const unexpectedMessage of unexpectedMessages) {
if (hasLoggedMessage(messages, unexpectedMessage)) {
presentMessages.push(unexpectedMessage);
}
}
if (presentMessages.length > 0) {
const listify = (lines: string[]) =>
lines.map((m) => ` - '${m}'`).join("\n");
t.fail(
`Did not expect\n\n${listify(presentMessages)}\n\nin the logger output, but found them in:\n\n${messages.map((m) => ` - '${m.message}'`).join("\n")}`,
);
} else {
t.pass();
}
}
/**
* Asserts that `message` should not have been logged to `logger`.
*/