|
8 | 8 |
|
9 | 9 | import {
|
10 | 10 | Path,
|
11 |
| - getSystemPath, |
| 11 | + basename, |
| 12 | + dirname, |
| 13 | + join, |
12 | 14 | normalize,
|
| 15 | + relative, |
13 | 16 | virtualFs,
|
14 | 17 | } from '@angular-devkit/core';
|
15 | 18 | import { NodeJsSyncHost } from '@angular-devkit/core/node';
|
16 |
| -import { SpawnOptions, spawn } from 'child_process'; |
17 | 19 | import { Stats } from 'fs';
|
18 |
| -import { EMPTY, Observable } from 'rxjs'; |
19 |
| -import { concatMap, map } from 'rxjs/operators'; |
| 20 | +import { EMPTY, Observable, from, of } from 'rxjs'; |
| 21 | +import { concatMap, delay, map, mergeMap, retry, tap } from 'rxjs/operators'; |
20 | 22 |
|
21 | 23 |
|
22 |
| -interface ProcessOutput { |
23 |
| - stdout: string; |
24 |
| - stderr: string; |
25 |
| -} |
26 |
| - |
27 | 24 | export class TestProjectHost extends NodeJsSyncHost {
|
28 |
| - private _scopedSyncHost: virtualFs.SyncDelegateHost<Stats>; |
| 25 | + private _currentRoot: Path | null = null; |
| 26 | + private _scopedSyncHost: virtualFs.SyncDelegateHost<Stats> | null = null; |
29 | 27 |
|
30 |
| - constructor(protected _root: Path) { |
| 28 | + constructor(protected _templateRoot: Path) { |
31 | 29 | super();
|
32 |
| - this._scopedSyncHost = new virtualFs.SyncDelegateHost(new virtualFs.ScopedHost(this, _root)); |
33 | 30 | }
|
34 | 31 |
|
35 |
| - scopedSync() { |
36 |
| - return this._scopedSyncHost; |
37 |
| - } |
| 32 | + root(): Path { |
| 33 | + if (this._currentRoot === null) { |
| 34 | + throw new Error('TestProjectHost must be initialized before being used.'); |
| 35 | + } |
38 | 36 |
|
39 |
| - initialize(): Observable<void> { |
40 |
| - return this.exists(normalize('.git')).pipe( |
41 |
| - concatMap(exists => !exists ? this._gitInit() : EMPTY), |
42 |
| - ); |
| 37 | + // tslint:disable-next-line:non-null-operator |
| 38 | + return this._currentRoot!; |
43 | 39 | }
|
44 | 40 |
|
45 |
| - restore(): Observable<void> { |
46 |
| - return this._gitClean(); |
| 41 | + scopedSync(): virtualFs.SyncDelegateHost<Stats> { |
| 42 | + if (this._currentRoot === null || this._scopedSyncHost === null) { |
| 43 | + throw new Error('TestProjectHost must be initialized before being used.'); |
| 44 | + } |
| 45 | + |
| 46 | + // tslint:disable-next-line:non-null-operator |
| 47 | + return this._scopedSyncHost!; |
47 | 48 | }
|
48 | 49 |
|
49 |
| - private _gitClean(): Observable<void> { |
50 |
| - return this._exec('git', ['clean', '-fd']).pipe( |
51 |
| - concatMap(() => this._exec('git', ['checkout', '.'])), |
52 |
| - map(() => { }), |
| 50 | + initialize(): Observable<void> { |
| 51 | + const recursiveList = (path: Path): Observable<Path> => this.list(path).pipe( |
| 52 | + // Emit each fragment individually. |
| 53 | + concatMap(fragments => from(fragments)), |
| 54 | + // Join the path with fragment. |
| 55 | + map(fragment => join(path, fragment)), |
| 56 | + // Emit directory content paths instead of the directory path. |
| 57 | + mergeMap(path => this.isDirectory(path).pipe( |
| 58 | + concatMap(isDir => isDir ? recursiveList(path) : of(path)), |
| 59 | + )), |
53 | 60 | );
|
54 |
| - } |
55 | 61 |
|
56 |
| - private _gitInit(): Observable<void> { |
57 |
| - return this._exec('git', ['init']).pipe( |
58 |
| - concatMap(() => this._exec('git', ['config', 'user.email', 'angular-core+e2e@google.com'])), |
59 |
| - concatMap(() => this._exec('git', ['config', 'user.name', 'Angular DevKit Tests'])), |
60 |
| - concatMap(() => this._exec('git', ['add', '--all'])), |
61 |
| - concatMap(() => this._exec('git', ['commit', '-am', '"Initial commit"'])), |
| 62 | + // Find a unique folder that we can write to to use as current root. |
| 63 | + return this.findUniqueFolderPath().pipe( |
| 64 | + // Save the path and create a scoped host for it. |
| 65 | + tap(newFolderPath => { |
| 66 | + this._currentRoot = newFolderPath; |
| 67 | + this._scopedSyncHost = new virtualFs.SyncDelegateHost( |
| 68 | + new virtualFs.ScopedHost(this, this.root())); |
| 69 | + }), |
| 70 | + // List all files in root. |
| 71 | + concatMap(() => recursiveList(this._templateRoot)), |
| 72 | + // Copy them over to the current root. |
| 73 | + concatMap(from => { |
| 74 | + const to = join(this.root(), relative(this._templateRoot, from)); |
| 75 | + |
| 76 | + return this.read(from).pipe( |
| 77 | + concatMap(buffer => this.write(to, buffer)), |
| 78 | + ); |
| 79 | + }), |
62 | 80 | map(() => { }),
|
63 | 81 | );
|
64 | 82 | }
|
65 | 83 |
|
66 |
| - private _exec(cmd: string, args: string[]): Observable<ProcessOutput> { |
67 |
| - return new Observable(obs => { |
68 |
| - args = args.filter(x => x !== undefined); |
69 |
| - let stdout = ''; |
70 |
| - let stderr = ''; |
71 |
| - |
72 |
| - const spawnOptions: SpawnOptions = { cwd: getSystemPath(this._root) }; |
73 |
| - |
74 |
| - if (process.platform.startsWith('win')) { |
75 |
| - args.unshift('/c', cmd); |
76 |
| - cmd = 'cmd.exe'; |
77 |
| - spawnOptions['stdio'] = 'pipe'; |
78 |
| - } |
79 |
| - |
80 |
| - const childProcess = spawn(cmd, args, spawnOptions); |
81 |
| - childProcess.stdout.on('data', (data: Buffer) => stdout += data.toString('utf-8')); |
82 |
| - childProcess.stderr.on('data', (data: Buffer) => stderr += data.toString('utf-8')); |
83 |
| - |
84 |
| - // Create the error here so the stack shows who called this function. |
85 |
| - const err = new Error(`Running "${cmd} ${args.join(' ')}" returned error code `); |
86 |
| - |
87 |
| - childProcess.on('exit', (code) => { |
88 |
| - if (!code) { |
89 |
| - obs.next({ stdout, stderr }); |
90 |
| - } else { |
91 |
| - err.message += `${code}.\n\nSTDOUT:\n${stdout}\n\nSTDERR:\n${stderr}\n`; |
92 |
| - obs.error(err); |
93 |
| - } |
94 |
| - obs.complete(); |
95 |
| - }); |
96 |
| - }); |
| 84 | + restore(): Observable<void> { |
| 85 | + if (this._currentRoot === null) { |
| 86 | + return EMPTY; |
| 87 | + } |
| 88 | + |
| 89 | + // Delete the current root and clear the variables. |
| 90 | + // Wait 50ms and retry up to 10 times, to give time for file locks to clear. |
| 91 | + return this.exists(this.root()).pipe( |
| 92 | + delay(50), |
| 93 | + concatMap(exists => exists ? this.delete(this.root()) : of(null)), |
| 94 | + retry(10), |
| 95 | + tap(() => { |
| 96 | + this._currentRoot = null; |
| 97 | + this._scopedSyncHost = null; |
| 98 | + }), |
| 99 | + map(() => { }), |
| 100 | + ); |
97 | 101 | }
|
98 | 102 |
|
99 | 103 | writeMultipleFiles(files: { [path: string]: string | ArrayBufferLike | Buffer }): void {
|
@@ -137,4 +141,15 @@ export class TestProjectHost extends NodeJsSyncHost {
|
137 | 141 | const content = this.scopedSync().read(normalize(from));
|
138 | 142 | this.scopedSync().write(normalize(to), content);
|
139 | 143 | }
|
| 144 | + |
| 145 | + private findUniqueFolderPath(): Observable<Path> { |
| 146 | + // 11 character alphanumeric string. |
| 147 | + const randomString = Math.random().toString(36).slice(2); |
| 148 | + const newFolderName = `test-project-host-${basename(this._templateRoot)}-${randomString}`; |
| 149 | + const newFolderPath = join(dirname(this._templateRoot), newFolderName); |
| 150 | + |
| 151 | + return this.exists(newFolderPath).pipe( |
| 152 | + concatMap(exists => exists ? this.findUniqueFolderPath() : of(newFolderPath)), |
| 153 | + ); |
| 154 | + } |
140 | 155 | }
|
0 commit comments