From 8b01d42e5dc379898f047555f2c62a854d0bbc33 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 9 Sep 2020 14:50:02 +0200 Subject: [PATCH] feat(dev-infra): add shared testing utilities folder with git mock (#38656) Adds a new folder to dev-infra where shared testing utilities could be placed in. This commit already adds initial testing utilities for dealing with the `GitClient` and SemVer versions. The `GitClient` in the testing utilities simulates actual Git behavior in a virtual manner. It's not complete at all, but can be extended based on our needs. The currently implemented commands are the most basic ones that we'd need for our release tooling. PR Close #38656 --- dev-infra/utils/testing/BUILD.bazel | 15 ++ dev-infra/utils/testing/index.ts | 11 ++ dev-infra/utils/testing/semver-matchers.ts | 12 ++ dev-infra/utils/testing/virtual-git-client.ts | 173 ++++++++++++++++++ .../utils/testing/virtual-git-matchers.ts | 41 +++++ 5 files changed, 252 insertions(+) create mode 100644 dev-infra/utils/testing/BUILD.bazel create mode 100644 dev-infra/utils/testing/index.ts create mode 100644 dev-infra/utils/testing/semver-matchers.ts create mode 100644 dev-infra/utils/testing/virtual-git-client.ts create mode 100644 dev-infra/utils/testing/virtual-git-matchers.ts diff --git a/dev-infra/utils/testing/BUILD.bazel b/dev-infra/utils/testing/BUILD.bazel new file mode 100644 index 0000000000..bc24a5a00b --- /dev/null +++ b/dev-infra/utils/testing/BUILD.bazel @@ -0,0 +1,15 @@ +load("@npm_bazel_typescript//:index.bzl", "ts_library") + +ts_library( + name = "testing", + srcs = glob(["*.ts"]), + module_name = "@angular/dev-infra-private/utils/testing", + visibility = ["//dev-infra:__subpackages__"], + deps = [ + "//dev-infra/utils", + "@npm//@types/jasmine", + "@npm//@types/minimist", + "@npm//@types/node", + "@npm//minimist", + ], +) diff --git a/dev-infra/utils/testing/index.ts b/dev-infra/utils/testing/index.ts new file mode 100644 index 0000000000..1521532ead --- /dev/null +++ b/dev-infra/utils/testing/index.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './virtual-git-client'; +export * from './virtual-git-matchers'; +export * from './semver-matchers'; diff --git a/dev-infra/utils/testing/semver-matchers.ts b/dev-infra/utils/testing/semver-matchers.ts new file mode 100644 index 0000000000..fa38e6af12 --- /dev/null +++ b/dev-infra/utils/testing/semver-matchers.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** Gets a jasmine asymmetric matcher for matching a given SemVer version. */ +export function matchesVersion(versionName: string) { + return jasmine.objectContaining({raw: versionName, version: versionName}); +} diff --git a/dev-infra/utils/testing/virtual-git-client.ts b/dev-infra/utils/testing/virtual-git-client.ts new file mode 100644 index 0000000000..207347eb1e --- /dev/null +++ b/dev-infra/utils/testing/virtual-git-client.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {SpawnSyncOptions, SpawnSyncReturns} from 'child_process'; +import * as parseArgs from 'minimist'; + +import {GitClient} from '../git/index'; + +/** Type describing a Git head. */ +interface GitHead { + /** Name of the head. Not defined in a detached state. */ + branch?: string; + /** Ref associated with this head. i.e. the remote base of this head. */ + ref?: RemoteRef; + /** List of commits added to this head (on top of the ref's base). */ + newCommits: Commit[]; +} + +/** Type describing a remote Git ref. */ +export interface RemoteRef { + /** Name of the reference. */ + name: string; + /** Repository containing this ref. */ + repoUrl: string; +} + +/** Type describing a Git commit. */ +export interface Commit { + /** Commit message. */ + message: string; + /** List of files included in this commit. */ + files: string[]; +} + +/** + * Virtual git client that mocks Git commands and keeps track of the repository state + * in memory. This allows for convenient test assertions with Git interactions. + */ +export class VirtualGitClient extends GitClient { + /** Current Git HEAD that has been previously fetched. */ + fetchHeadRef: RemoteRef|null = null; + /** List of known branches in the repository. */ + branches: {[branchName: string]: GitHead} = {master: {branch: 'master', newCommits: []}}; + /** Current checked out HEAD in the repository. */ + head: GitHead = this.branches['master']; + /** List of pushed heads to a given remote ref. */ + pushed: {remote: RemoteRef, head: GitHead}[] = []; + + /** Override for the actual Git client command execution. */ + runGraceful(args: string[], options: SpawnSyncOptions = {}): SpawnSyncReturns { + const [command, ...rawArgs] = args; + switch (command) { + case 'push': + this._push(rawArgs); + break; + case 'fetch': + this._fetch(rawArgs); + break; + case 'checkout': + this._checkout(rawArgs); + break; + case 'commit': + this._commit(rawArgs); + break; + } + + // Return a fake spawn sync return value. We error non-gracefully if any command fails + // in the tests, so we always return success and stub out the `SpawnSyncReturns` type. + return {status: 0, stderr: '', output: [], pid: -1, signal: null, stdout: ''}; + } + + /** Handler for the `git push` command. */ + private _push(args: string[]) { + const [repoUrl, refspec] = parseArgs(args)._; + const ref = this._unwrapRefspec(refspec); + const name = ref.destination || ref.source; + const existingPush = + this.pushed.find(({remote}) => remote.repoUrl === repoUrl && remote.name === name); + const pushedHead = this._cloneHead(this.head); + + // Either, update a previously pushed branch, or keep track of a newly + // performed branch push. We don't respect the `--force` flag. + if (existingPush !== undefined) { + existingPush.head = pushedHead; + } else { + this.pushed.push({remote: {repoUrl, name}, head: pushedHead}); + } + } + + /** Handler for the `git commit` command. */ + private _commit(rawArgs: string[]) { + const args = parseArgs(rawArgs, {string: ['m', 'message']}); + const message = args['m'] || args['message']; + const files = args._; + if (!message) { + throw Error('No commit message has been specified.'); + } + this.head.newCommits.push({message, files}); + } + + /** Handler for the `git fetch` command. */ + private _fetch(rawArgs: string[]) { + const args = parseArgs(rawArgs, {boolean: ['f', 'force']}); + const [repoUrl, refspec] = args._; + const force = args['f'] || args['force']; + const ref = this._unwrapRefspec(refspec); + + // Keep track of the fetch head, so that it can be checked out + // later in a detached state. + this.fetchHeadRef = {name: ref.source, repoUrl}; + + // If a destination has been specified in the ref spec, add it to the + // list of available local branches. + if (ref.destination) { + if (this.branches[ref.destination] && !force) { + throw Error('Cannot override existing local branch when fetching.'); + } + this.branches[ref.destination] = { + branch: ref.destination, + ref: this.fetchHeadRef, + newCommits: [] + }; + } + } + + /** Handler for the `git checkout` command. */ + private _checkout(rawArgs: string[]) { + const args = parseArgs(rawArgs, {boolean: ['detach', 'B']}); + const createBranch = args['B']; + const detached = args['detach']; + const [target] = args._; + + if (target === 'FETCH_HEAD') { + if (this.fetchHeadRef === null) { + throw Error('Unexpectedly trying to check out "FETCH_HEAD". Not fetch head set.'); + } + this.head = {ref: this.fetchHeadRef, newCommits: []}; + } else if (this.branches[target]) { + this.head = {...this._cloneHead(this.branches[target], detached)}; + } else if (createBranch) { + this.head = this.branches[target] = {branch: target, ...this._cloneHead(this.head, detached)}; + } else { + throw Error(`Unexpected branch checked out: ${target}`); + } + } + + /** + * Unwraps a refspec into the base and target ref names. + * https://git-scm.com/docs/git-fetch#Documentation/git-fetch.txt-ltrefspecgt. + */ + private _unwrapRefspec(refspec: string): {source: string, destination?: string} { + const [source, destination] = refspec.split(':'); + if (!destination) { + return {source}; + } else { + return {source, destination}; + } + } + + /** Clones the specified Git head with respect to the detached flag. */ + private _cloneHead(head: GitHead, detached = false): GitHead { + return { + branch: detached ? undefined : head.branch, + ref: head.ref, + newCommits: [...head.newCommits], + }; + } +} diff --git a/dev-infra/utils/testing/virtual-git-matchers.ts b/dev-infra/utils/testing/virtual-git-matchers.ts new file mode 100644 index 0000000000..41c021aa6f --- /dev/null +++ b/dev-infra/utils/testing/virtual-git-matchers.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {GithubRepo} from '../git/github'; + +import {Commit} from './virtual-git-client'; + +/** Interface describing the match parameters for a virtual Git client push. */ +interface BranchPushMatchParameters { + targetRepo: GithubRepo; + targetBranch: string; + baseRepo: GithubRepo; + baseBranch: string; + expectedCommits: Commit[]|jasmine.ArrayContaining; +} + +/** + * Gets a jasmine object matcher for asserting that a virtual Git client push + * matches the specified branch push (through the match parameters). + */ +export function getBranchPushMatcher(options: BranchPushMatchParameters) { + const {targetRepo, targetBranch, baseBranch, baseRepo, expectedCommits} = options; + return jasmine.objectContaining({ + remote: { + repoUrl: `https://github.com/${targetRepo.owner}/${targetRepo.name}.git`, + name: `refs/heads/${targetBranch}` + }, + head: jasmine.objectContaining({ + newCommits: expectedCommits, + ref: { + repoUrl: `https://github.com/${baseRepo.owner}/${baseRepo.name}.git`, + name: baseBranch, + }, + }) + }); +}