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