mirror of
https://github.com/github/codeql-action.git
synced 2026-10-03 17:41:28 +00:00
Merge remote-tracking branch 'origin/main' into mbg/config/merge
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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`.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user