angular-cn/dev-infra/utils/testing/virtual-git-client.ts
Paul Gschwendtner 1684b70b88 test(dev-infra): always use same virtual git client instance in publish tests (#42468)
With the recent refactorings to `GitClient`, where singletons
are created and can be retrieved through a static method, the
test has been updated to also install spies for the static methods
of `GitClient`. This commit updates the spy installation so that
the same mock git client is used that is also passed manually to
the release actions. Having two separate instances of the mock
git client could result in false-positive test results.

PR Close #42468
2021-06-03 14:34:33 -07:00

210 lines
6.9 KiB
TypeScript

/**
* @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 {SemVer} from 'semver';
import {NgDevConfig} from '../config';
import {AuthenticatedGitClient} from '../git/authenticated-git-client';
import {GitClient} from '../git/git-client';
/**
* Temporary directory which will be used as project directory in tests. Note that
* this environment variable is automatically set by Bazel for tests.
*/
export const testTmpDir: string = process.env['TEST_TMPDIR']!;
/** A mock instance of a configuration for the ng-dev toolset for default testing. */
export const mockNgDevConfig: NgDevConfig = {
github: {
name: 'name',
owner: 'owner',
}
};
/** 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 AuthenticatedGitClient {
static createInstance(config = mockNgDevConfig, tmpDir = testTmpDir): VirtualGitClient {
return new VirtualGitClient('abc123', tmpDir, config);
}
/** 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 the actual GitClient getLatestSemverTag, as an actual tag cannot be retrieved in
* testing.
*/
getLatestSemverTag() {
return new SemVer('0.0.0');
}
/** Override for the actual Git client command execution. */
runGraceful(args: string[], options: SpawnSyncOptions = {}): SpawnSyncReturns<string> {
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', 'q', 'quiet']});
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],
};
}
}
export function installVirtualGitClientSpies(mockInstance = VirtualGitClient.createInstance()) {
spyOn(GitClient, 'get').and.returnValue(mockInstance);
spyOn(AuthenticatedGitClient, 'get').and.returnValue(mockInstance);
}