mirror of
https://github.com/github/codeql-action.git
synced 2026-10-03 17:41:28 +00:00
1007 lines
28 KiB
TypeScript
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);
|
|
}
|