Files
codeql-action/src/testing-utils.ts
2026-09-28 19:18:37 +01:00

1007 lines
28 KiB
TypeScript

import { TextDecoder } from "node:util";
import path from "path";
import * as github from "@actions/github";
import test, {
type ThrownError,
type ThrowsExpectation,
type ExecutionContext,
type MacroDeclarationOptions,
type TestFn,
} from "ava";
import nock from "nock";
import * as sinon from "sinon";
import { ActionState, StateFeature } from "./action-common";
import { ActionsEnv, getActionVersion } from "./actions-util";
import { AnalysisKind } from "./analyses";
import * as apiClient from "./api-client";
import { GitHubApiDetails } from "./api-client";
import { CachingKind } from "./caching-utils";
import { resetCachedCodeQlVersion } from "./cli/output-cache";
import type { VersionInfo } from "./cli/types";
import * as codeql from "./codeql";
import { Config } from "./config-utils";
import * as defaults from "./defaults.json";
import { Env, ActionsEnvVars } from "./environment";
import {
CodeQLDefaultVersionInfo,
Feature,
featureConfig,
FeatureEnablement,
} from "./feature-flags";
import { Logger } from "./logging";
import { OverlayDatabaseMode } from "./overlay/overlay-database-mode";
import { getBundlePlatform } from "./platform";
import { ActionName } from "./status-report";
import {
DEFAULT_DEBUG_ARTIFACT_NAME,
DEFAULT_DEBUG_DATABASE_NAME,
Failure,
getEnv,
GitHubVariant,
GitHubVersion,
HTTPError,
Result,
Success,
} from "./util";
export const SAMPLE_DOTCOM_API_DETAILS = {
auth: "token",
url: "https://github.com",
apiURL: "https://api.github.com",
};
export const LINKED_CLI_VERSION = {
cliVersion: defaults.cliVersion,
tagName: defaults.bundleVersion,
};
export const SAMPLE_DEFAULT_CLI_VERSION: CodeQLDefaultVersionInfo = {
enabledVersions: [
{
cliVersion: "2.20.0",
tagName: "codeql-bundle-v2.20.0",
},
],
};
type TestContext = {
stdoutWrite: any;
stderrWrite: any;
testOutput: string;
env: NodeJS.ProcessEnv;
};
function wrapOutput(context: TestContext) {
// Function signature taken from Socket.write.
// Note there are two overloads:
// write(buffer: Uint8Array | string, cb?: (err?: Error) => void): boolean;
// write(str: Uint8Array | string, encoding?: string, cb?: (err?: Error) => void): boolean;
return (
chunk: Uint8Array | string,
encoding?: string,
cb?: (err?: Error) => void,
): boolean => {
// Work out which method overload we are in
if (cb === undefined && typeof encoding === "function") {
cb = encoding;
encoding = undefined;
}
// Record the output
if (typeof chunk === "string") {
context.testOutput += chunk;
} else {
context.testOutput += new TextDecoder(encoding || "utf-8").decode(chunk);
}
// Satisfy contract by calling callback when done
if (cb !== undefined && typeof cb === "function") {
cb();
}
return true;
};
}
export function setupTests(testFn: TestFn<any>) {
const typedTest = testFn as TestFn<TestContext>;
typedTest.beforeEach((t) => {
// Set an empty CodeQL object so that all method calls will fail
// unless the test explicitly sets one up.
codeql.setCodeQL({});
// Reset the in-process CodeQL version cache so that it doesn't leak between
// tests, which each represent a separate Actions step in production.
resetCachedCodeQlVersion();
// Replace stdout and stderr so we can record output during tests
t.context.testOutput = "";
const processStdoutWrite = process.stdout.write.bind(process.stdout);
t.context.stdoutWrite = processStdoutWrite;
process.stdout.write = wrapOutput(t.context) as any;
const processStderrWrite = process.stderr.write.bind(process.stderr);
t.context.stderrWrite = processStderrWrite;
process.stderr.write = wrapOutput(t.context) as any;
// Workaround an issue in tests where the case insensitivity of the `$PATH`
// environment variable on Windows isn't preserved, i.e. `process.env.PATH`
// is not the same as `process.env.Path`.
const pathKeys = Object.keys(process.env).filter(
(k) => k.toLowerCase() === "path",
);
if (pathKeys.length > 0) {
process.env.PATH = process.env[pathKeys[0]];
}
// Many tests modify environment variables. Take a copy now so that
// we reset them after the test to keep tests independent of each other.
// process.env only has strings fields, so a shallow copy is fine.
t.context.env = {};
Object.assign(t.context.env, process.env);
});
typedTest.afterEach.always((t) => {
// Restore stdout and stderr
// The captured output is only replayed if the test failed
process.stdout.write = t.context.stdoutWrite;
process.stderr.write = t.context.stderrWrite;
if (!t.passed) {
process.stdout.write(t.context.testOutput);
}
// Undo any modifications made by nock
nock.cleanAll();
// Undo any modifications made by sinon
sinon.restore();
// Undo any modifications to the env
process.env = t.context.env;
});
}
/**
* Declare a reusable test implementation, with better type safety than `test.macro`.
*/
export function makeMacro<Args extends unknown[]>(
decl: MacroDeclarationOptions<Args, unknown>,
) {
const m = test.macro<Args>(decl);
const wrapper = (name: string, ...args: Args) => test(name, m, ...args);
wrapper.test = (...args: Args) => test(m, ...args);
wrapper.serial = (name: string, ...args: Args) =>
test.serial(name, m, ...args);
// Make the implementation available as `fn`. We don't call it `exec` so
// that results from this function are not valid arguments to `test`
// or `test.serial`.
wrapper.fn = decl.exec;
return wrapper;
}
export function getTestEnv(testEnv: NodeJS.ProcessEnv = {}): Env {
return getEnv(testEnv);
}
/** An implementation of `ActionsEnv` for use in tests. */
class TestActionsEnv implements ActionsEnv {
constructor(private readonly env: Env) {}
public clone(env: Env): this {
return Object.create(this, { env: { value: env } }) as this;
}
public getRequiredInput(name: string): string {
throw new Error(`Input required and not supplied: ${name}`);
}
public getOptionalInput(_name: string): string | undefined {
return undefined;
}
public exportVariable(name: string, value: string): void {
this.env.set(name, value);
}
}
/**
* Gets an `ActionsEnv` instance for use in tests.
*/
export function getTestActionsEnv(env: Env): TestActionsEnv {
return new TestActionsEnv(env);
}
/** For testing purposes, we make all available state features accessible in `TestEnv`. */
type AllState = [
"Base",
"Logger",
"Env",
"ReadOnlyEnv",
"Actions",
"Api",
"FeatureFlags",
];
/** Initialise a fresh `ActionState<AllState>` value. */
export function initAllState(
overrides?: Partial<ActionState<AllState>>,
): ActionState<AllState> {
const env = getTestEnv();
return {
name: ActionName.Init,
startedAt: new Date(),
platform: process.platform,
arch: process.arch,
logger: new RecordingLogger(),
env,
actions: getTestActionsEnv(env),
apiClient: github.getOctokit("123"),
features: createFeatures([]),
...overrides,
};
}
type DelayedCheck<
Args extends readonly any[],
R,
Fs extends ReadonlyArray<AllState[number]>,
> = (
env: Readonly<BaseEnvBuilder<Args, R, Fs>>,
result: Result<Awaited<R>, ThrownError<ErrorConstructor | Error>>,
) => Promise<any>;
export type Mutation<T> = (val: T) => void;
export type ValueOrMutation<T> = T | Mutation<T>;
/**
* Wraps a function that accepts an `ActionState` for testing in different environments.
*/
abstract class BaseEnvBuilder<
Args extends readonly any[],
R,
Fs extends ReadonlyArray<AllState[number]>,
> {
protected readonly fn: (state: ActionState<Fs>, ...args: Args) => R;
private logger: RecordingLogger;
private actions: TestActionsEnv;
protected state: ActionState<AllState>;
protected checks: Array<DelayedCheck<Args, R, Fs>>;
constructor(
fn: (state: ActionState<Fs>, ...args: Args) => R,
cloneFrom?: BaseEnvBuilder<Args, R, Fs>,
) {
this.fn = fn;
this.logger = new RecordingLogger();
if (cloneFrom !== undefined) {
const env = cloneFrom.state.env.clone();
this.actions = cloneFrom.actions.clone(env);
this.state = {
...cloneFrom.state,
env,
actions: this.actions,
logger: this.logger,
} satisfies ActionState<AllState>;
} else {
const env = getTestEnv();
this.actions = getTestActionsEnv(env);
this.state = initAllState({
logger: this.logger,
env,
actions: this.actions,
});
}
this.checks = [...(cloneFrom?.checks ?? [])];
}
/**
* Creates a clone of this object. Used internally.
* Must be overridden by subclasses.
*/
protected abstract clone(): this;
public getLogger(): RecordingLogger {
return this.logger;
}
public getState(): ActionState<AllState> {
return this.state;
}
public withArgs(...args: Args): CallableEnvBuilder<Args, R, Fs> {
const result = new CallableEnvBuilder(this.fn, args, this.clone());
return result;
}
public withFeatures(enabled: Feature[]): this {
const result = this.clone();
result.state.features = createFeatures(enabled);
return result;
}
/**
* 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();
setupBaseActionsVars(overrides, result.state.env);
return result;
}
/**
* 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();
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;
}
/** Applies `fn` to the `ActionsEnv`. */
public withActions(fn: Mutation<ActionsEnv>): this {
const result = this.clone();
fn(result.state.actions);
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 the environment variables returned by `fn`
* are present in the environment after the main assertion passes.
*/
public hasEnv(
t: ExecutionContext<unknown>,
fn: (
value: Awaited<R> | undefined,
error: ThrownError<ErrorConstructor | Error> | undefined,
) => Record<string, string | undefined>,
): this {
const result = this.clone();
result.checks.push(async (env, r) => {
const value = r.orElse(undefined);
const error = r.isFailure() ? r.value : undefined;
const expected = fn(value, error);
t.like(env.getState().env.get(), expected);
});
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;
}
}
export interface PassedAssertion<R, T> {
result: Awaited<R>;
assertionResult: T;
}
/**
* A more minimal, exported interface for `CallableEnvBuilder`. This makes it easier to
* define helper functions in tests which expect a value of a compatible type.
*/
export interface AssertableTarget<R> {
passes<AArgs extends readonly any[], AResult>(
assertion: (val: Awaited<R>, ...assertionArgs: AArgs) => AResult,
...assertionArgs: AArgs
): Promise<PassedAssertion<R, AResult>>;
throws<ErrorType extends ErrorConstructor | Error>(
t: ExecutionContext<unknown>,
expectations?: ThrowsExpectation<ErrorType>,
): Promise<ThrownError<ErrorType>>;
}
class CallableEnvBuilder<
Args extends readonly any[],
R,
Fs extends ReadonlyArray<AllState[number]>,
>
extends BaseEnvBuilder<Args, R, Fs>
implements AssertableTarget<R>
{
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 {
return this.fn(this.state as unknown as ActionState<Fs>, ...this.args);
}
/**
* 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<PassedAssertion<R, 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, new Success(result));
}
// Return the results of the function call and the main assertion.
return { result, 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, new Failure(error));
}
// Return the error.
return error;
}
}
/** Utility function to construct a `TestEnv`. */
export function callee<
Args extends readonly any[],
R,
Fs extends readonly StateFeature[],
>(fn: (state: ActionState<Fs>, ...args: Args) => R): EnvBuilder<Args, R, Fs> {
return new EnvBuilder(fn);
}
/**
* Default values for environment variables typically set in an Actions
* environment. Tests can override individual variables by passing them in the
* `overrides` parameter.
*/
export const DEFAULT_ACTIONS_VARS = {
GITHUB_ACTION_REPOSITORY: "github/codeql-action",
GITHUB_API_URL: "https://api.github.com",
GITHUB_EVENT_NAME: "push",
GITHUB_JOB: "test-job",
GITHUB_REF: "refs/heads/main",
GITHUB_REPOSITORY: "github/codeql-action-testing",
GITHUB_RUN_ATTEMPT: "1",
GITHUB_RUN_ID: "1",
GITHUB_SERVER_URL: "https://github.com",
GITHUB_SHA: "0".repeat(40),
GITHUB_WORKFLOW: "test-workflow",
RUNNER_NAME: "my-runner",
RUNNER_OS: "Linux",
} as const satisfies Partial<Record<ActionsEnvVars, string>>;
/** Partial mappings from GitHub Actions environment variables to values. */
export type ActionVarOverrides = Partial<
Record<keyof typeof DEFAULT_ACTIONS_VARS, string>
>;
/**
* Sets environment variables that are always available on GitHub Actions,
* excluding some that are expected to be set to paths. See `setupActionsVars`.
*
* @param overrides Overrides for the defaults.
* @param env The environment to set the variables for.
*/
export function setupBaseActionsVars(
overrides?: ActionVarOverrides,
env: Env = getEnv(),
) {
const vars = { ...DEFAULT_ACTIONS_VARS, ...overrides };
for (const [key, value] of Object.entries(vars)) {
env.set(key, value);
}
}
/**
* Sets environment variables that are always available on 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.
* @param env The environment to set the variables for.
*/
export function setupActionsVars(
tempDir: string,
toolsDir: string,
overrides?: ActionVarOverrides,
env: Env = getEnv(),
) {
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";
export interface LoggedMessage {
type: LogLevel;
message: string | Error;
}
export class RecordingLogger implements Logger {
messages: LoggedMessage[] = [];
readonly groups: string[] = [];
readonly unfinishedGroups: Set<string> = new Set();
private currentGroup: string | undefined = undefined;
constructor(private readonly logToConsole: boolean = true) {}
private addMessage(level: LogLevel, message: string | Error): void {
this.messages.push({ type: level, message });
if (this.logToConsole) {
// eslint-disable-next-line no-console
console.debug(message);
}
}
/**
* 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;
}
debug(message: string) {
this.addMessage("debug", message);
}
info(message: string) {
this.addMessage("info", message);
}
warning(message: string | Error) {
this.addMessage("warning", message);
}
error(message: string | Error) {
this.addMessage("error", message);
}
startGroup(name: string) {
this.groups.push(name);
this.currentGroup = name;
this.unfinishedGroups.add(name);
}
endGroup() {
if (this.currentGroup !== undefined) {
this.unfinishedGroups.delete(this.currentGroup);
}
this.currentGroup = undefined;
}
}
export function getRecordingLogger(
messages: LoggedMessage[],
{ logToConsole }: { logToConsole?: boolean } = { logToConsole: true },
): Logger {
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[],
expectedMessages: string[],
) {
const missingMessages: string[] = [];
for (const expectedMessage of expectedMessages) {
if (!hasLoggedMessage(messages, expectedMessage)) {
missingMessages.push(expectedMessage);
}
}
if (missingMessages.length > 0) {
const listify = (lines: string[]) =>
lines.map((m) => ` - '${m}'`).join("\n");
t.fail(
`Expected\n\n${listify(missingMessages)}\n\nin the logger output, but didn't find it in:\n\n${messages.map((m) => ` - '${m.message}'`).join("\n")}`,
);
} else {
t.pass();
}
}
/**
* 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`.
*/
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.
*
* @param body The test that requires a recording logger.
* @returns The logged messages.
*/
export async function withRecordingLoggerAsync(
body: (logger: Logger) => Promise<void>,
): Promise<LoggedMessage[]> {
const messages = [];
const logger = getRecordingLogger(messages);
await body(logger);
return messages;
}
/** Mock the HTTP request to the feature flags enablement API endpoint. */
export function mockFeatureFlagApiEndpoint(
responseStatusCode: number,
response: { [flagName: string]: boolean },
) {
stubFeatureFlagApiEndpoint(() => ({
status: responseStatusCode,
messageIfError: "some error message",
data: response,
}));
}
/** Stub the HTTP request to the feature flags enablement API endpoint. */
export function stubFeatureFlagApiEndpoint(
responseFunction: (params: any) => {
status: number;
messageIfError?: string;
data: { [flagName: string]: boolean };
},
) {
// Passing an auth token is required, so we just use a dummy value
const client = github.getOctokit("123");
const requestSpy = sinon.stub(client, "request");
const optInSpy = requestSpy.withArgs(
"GET /repos/:owner/:repo/code-scanning/codeql-action/features",
);
optInSpy.callsFake((_route, params) => {
const response = responseFunction(params);
if (response.status < 300) {
return Promise.resolve({
status: response.status,
data: response.data,
headers: {},
url: "GET /repos/:owner/:repo/code-scanning/codeql-action/features",
});
} else {
throw new HTTPError(
response.messageIfError || "default stub error message",
response.status,
);
}
});
sinon.stub(apiClient, "getApiClient").value(() => client);
}
export function mockLanguagesInRepo(languages: string[]) {
const mockClient = sinon.stub(apiClient, "getApiClient");
const listLanguages = sinon.stub().resolves({
status: 200,
data: languages.reduce((acc, lang) => {
acc[lang] = 1;
return acc;
}, {}),
headers: {},
url: "GET /repos/:owner/:repo/languages",
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
mockClient.returns({
rest: {
repos: {
listLanguages,
},
},
} as any);
return listLanguages;
}
/**
* Constructs a `VersionInfo` object for testing purposes only.
*/
export const makeVersionInfo = (
version: string,
features?: { [name: string]: boolean },
overlayVersion?: number,
): VersionInfo => ({
version,
features,
overlayVersion,
});
export function mockCodeQLVersion(
version: string,
features?: { [name: string]: boolean },
overlayVersion?: number,
) {
return codeql.createStubCodeQL({
async getVersion() {
return makeVersionInfo(version, features, overlayVersion);
},
});
}
/**
* Create a feature enablement instance with the specified set of enabled features.
*
* This should be only used within tests.
*/
export function createFeatures(enabledFeatures: Feature[]): FeatureEnablement {
return {
getEnabledDefaultCliVersions: async () => {
throw new Error("not implemented");
},
getValue: async (feature) => {
return enabledFeatures.includes(feature as Feature);
},
};
}
export function initializeFeatures(initialValue: boolean) {
return Object.keys(featureConfig).reduce((features, key) => {
features[key] = initialValue;
return features;
}, {});
}
/**
* Mocks the API for downloading the bundle tagged `tagName`.
*
* @returns the download URL for the bundle. This can be passed to the tools parameter of
* `codeql.setupCodeQL`.
*/
export function mockBundleDownloadApi({
apiDetails = SAMPLE_DOTCOM_API_DETAILS,
isPinned,
repo = "github/codeql-action",
platformSpecific = true,
tagName,
}: {
apiDetails?: GitHubApiDetails;
isPinned?: boolean;
repo?: string;
platformSpecific?: boolean;
tagName: string;
}): string {
const platform = platformSpecific ? getBundlePlatform() : undefined;
const baseUrl = apiDetails?.url ?? "https://example.com";
const bundleUrls = ["tar.gz", "tar.zst"].map((extension) => {
const relativeUrl = apiDetails
? `/${repo}/releases/download/${tagName}/codeql-bundle${
platform !== undefined ? `-${platform}` : ""
}.${extension}`
: `/download/${tagName}/codeql-bundle.${extension}`;
nock(baseUrl)
.get(relativeUrl)
.replyWithFile(
200,
path.join(
__dirname,
`/../src/testdata/codeql-bundle${
isPinned ? "-pinned" : ""
}.${extension}`,
),
);
return `${baseUrl}${relativeUrl}`;
});
// Choose an arbitrary URL to return
return bundleUrls[0];
}
export function createTestConfig(overrides: Partial<Config>): Config {
return Object.assign(
{},
{
version: getActionVersion(),
analysisKinds: [AnalysisKind.CodeScanning],
languages: [],
buildMode: undefined,
originalUserInput: {},
computedConfig: {},
tempDir: "",
codeQLCmd: "",
gitHubVersion: {
type: GitHubVariant.DOTCOM,
} as GitHubVersion,
dbLocation: "",
debugMode: false,
debugArtifactName: DEFAULT_DEBUG_ARTIFACT_NAME,
debugDatabaseName: DEFAULT_DEBUG_DATABASE_NAME,
trapCaches: {},
trapCacheDownloadTime: 0,
dependencyCachingEnabled: CachingKind.None,
dependencyCachingRestoredKeys: [],
extraQueryExclusions: [],
overlayDatabaseMode: OverlayDatabaseMode.None,
useOverlayDatabaseCaching: false,
overlayModeSetExplicitly: false,
repositoryProperties: {},
enableFileCoverageInformation: true,
} satisfies Config,
overrides,
);
}
export function makeTestToken(length: number = 36) {
const chars =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
return chars.repeat(Math.ceil(length / chars.length)).slice(0, length);
}