[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/github/codeql-action/default-setup-testing/src/testing-utils.ts [Back]  [Original]

import { TextDecoder } from "node:util";
import * as os from "os";
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 { joinMessageStrings, Loggable, LoggableError, Logger } from "./logging";
import { OverlayDatabaseMode } from "./overlay/overlay-database-mode";
import { getBundlePlatform } from "./platform";
import { ActionName } from "./status-report";
import { ToolsFeature } from "./tools-features";
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) {
  const typedTest = testFn as TestFn;

  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;
  });
}

export interface AvaSinonAssertions {
  /**
   * Asserts that `stub` must have been called once with at least the `expected` arguments.
   */
  onceWith: (
    stub: sinon.SinonStub,
    ...expected: sinon.MatchPartialArguments
  ) => boolean;
}

/** Adds additional assertions for use with `sinon` to `t`. */
export function addSinonAssertions(
  t: ExecutionContext,
): ExecutionContext & AvaSinonAssertions {
  return {
    ...t,
    onceWith: (
      stub: sinon.SinonStub,
      ...expected: sinon.MatchPartialArguments
    ) => {
      // Fail if the stub hasn't been called at all so that we can safely compare
      // the arguments of the first call in the else branch.
      if (stub.callCount === 0) {
        t.fail("The stub wasn't called.");
      } else {
        // Reduce the arguments of each to the same number that was provided,
        // so that extra arguments don't lead to a failure.
        const actual = stub.args.map(
          (args: TArgs) =>
            args.slice(
              0,
              expected.length,
            ) as sinon.MatchPartialArguments,
        );
        return t.deepEqual(actual, [expected]);
      }
    },
  };
}

/**
 * Declare a reusable test implementation, with better type safety than `test.macro`.
 */
export function makeMacro(
  decl: MacroDeclarationOptions,
) {
  const m = test.macro(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;
  }
}

/**
 * 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` value. */
export function initAllState(
  overrides?: Partial,
): ActionState {
  const env = getTestEnv();
  return {
    name: ActionName.Init,
    startedAt: new Date(),
    platform: process.platform,
    arch: process.arch,
    osRelease: os.release(),
    logger: new RecordingLogger(),
    env,
    actions: getTestActionsEnv(env),
    apiClient: github.getOctokit("123"),
    features: createFeatures([]),
    ...overrides,
  };
}

type DelayedCheck<
  Args extends readonly any[],
  R,
  Fs extends ReadonlyArray,
> = (
  env: Readonly,
  result: Result,
) => Promise;

export type Mutation = (val: T) => void;
export type ValueOrMutation = T | Mutation;

/**
 * Wraps a function that accepts an `ActionState` for testing in different environments.
 */
abstract class BaseEnvBuilder<
  Args extends readonly any[],
  R,
  Fs extends ReadonlyArray,
> {
  protected readonly fn: (state: ActionState, ...args: Args) => R;
  private logger: RecordingLogger;
  private actions: TestActionsEnv;
  protected state: ActionState;
  protected checks: Array;

  constructor(
    fn: (state: ActionState, ...args: Args) => R,
    cloneFrom?: BaseEnvBuilder,
  ) {
    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;
    } 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 {
    return this.state;
  }

  public withArgs(...args: Args): CallableEnvBuilder {
    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): 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): 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, ...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,
    fn: (
      value: Awaited | undefined,
      error: ThrownError | undefined,
    ) => Record,
  ): 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, ...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,
> extends BaseEnvBuilder {
  protected clone(): this {
    return new EnvBuilder(this.fn, this) as this;
  }
}

export interface PassedAssertion {
  result: Awaited;
  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 {
  passes(
    assertion: (val: Awaited, ...assertionArgs: AArgs) => AResult,
    ...assertionArgs: AArgs
  ): Promise;

  throws(
    t: ExecutionContext,
    expectations?: ThrowsExpectation,
  ): Promise;
}

class CallableEnvBuilder<
  Args extends readonly any[],
  R,
  Fs extends ReadonlyArray,
>
  extends BaseEnvBuilder
  implements AssertableTarget
{
  private args: Args;

  constructor(
    fn: (state: ActionState, ...args: Args) => R,
    args: Args,
    cloneFrom?: BaseEnvBuilder,
  ) {
    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, ...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(
    assertion: (val: Awaited, ...assertionArgs: AArgs) => AResult,
    ...assertionArgs: AArgs
  ): Promise {
    // 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`.
    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(
    t: ExecutionContext,
    expectations?: ThrowsExpectation,
  ): Promise {
    // 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, ...args: Args) => R): EnvBuilder {
  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;

/** Partial mappings from GitHub Actions environment variables to values. */
export type ActionVarOverrides = Partial<
  Record
>;

/**
 * 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 = new Set();
  private currentGroup: string | undefined = undefined;

  constructor(private readonly logToConsole: boolean = true) {}

  private addMessage(level: LogLevel, message: LoggableError): void {
    const joinedMessage = joinMessageStrings(message);
    this.messages.push({ type: level, message: joinedMessage });

    if (this.logToConsole) {
      // eslint-disable-next-line no-console
      console.debug(joinedMessage);
    }
  }

  /**
   * 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: Loggable) {
    this.addMessage("debug", message);
  }

  info(message: Loggable) {
    this.addMessage("info", message);
  }

  warning(message: LoggableError) {
    this.addMessage("warning", message);
  }

  error(message: LoggableError) {
    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,
  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,
  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,
  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,
): Promise {
  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?: { [key in ToolsFeature]?: 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 {
  return Object.assign(
    {},
    {
      version: getActionVersion(),
      repositoryRoot: undefined,
      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,
      successfullyInitialised: false,
    } satisfies Config,
    overrides,
  );
}

export function makeTestToken(length: number = 36) {
  const chars =
    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
  return chars.repeat(Math.ceil(length / chars.length)).slice(0, length);
}

/**
 * Get a real, newly created CodeQL instance for testing. The instance refers to
 * a non-existent placeholder codeql command, so tests that use this function
 * should also stub the toolrunner.ToolRunner constructor.
 */
export async function getCodeQLForTesting(
  cmd = "codeql-for-testing",
  logger: Logger = new RecordingLogger(),
  env: Env = getEnv(),
): Promise {
  return codeql.getCodeQLForCmd(logger, cmd, false, env);
}

Web Proxy Viewer  |  New URL  |  Original Page