Skip to content

Commit 6975192

Browse files
alan-agius4dherges
authored andcommitted
feat: add watch and buildAsObservable methods (#982)
At the moment, when doing having `watch`, the `build` promise will never get resolved due to that the observable never completes. Exposing an observable sequence makes it also easier for consumers to `close` the `watch` and do more when an observable pipeline.
1 parent e44ab14 commit 6975192

File tree

13 files changed

+255
-17
lines changed

13 files changed

+255
-17
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,6 @@ jspm_packages/
6868
.ng_build/
6969
.ng_pkg_build/
7070
dist/
71+
72+
# Tests
73+
.tmp

integration/tsconfig.specs.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"buildOnSave": false,
33
"compileOnSave": false,
44
"compilerOptions": {
5-
"target": "es5",
5+
"target": "es2015",
66
"allowJs": true,
77
"experimentalDecorators": true,
88
"lib": ["es2015", "es2016"],

integration/watch/basic.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { expect } from 'chai';
2+
import { TestHarness } from './test-harness';
3+
4+
describe('basic', () => {
5+
const harness = new TestHarness('basic');
6+
7+
before(async () => {
8+
await harness.initialize();
9+
});
10+
11+
afterEach(() => {
12+
harness.reset();
13+
});
14+
15+
after(() => {
16+
harness.dispose();
17+
});
18+
19+
it("should perform initial compilation when 'watch' is started", () => {
20+
harness.expectDtsToMatch('public_api', /title = "hello world"/);
21+
harness.expectFesm5ToContain('basic', 'title', 'hello world');
22+
harness.expectFesm2015ToContain('basic', 'title', 'hello world');
23+
harness.expectMetadataToContain('basic', 'metadata.title', 'hello world');
24+
});
25+
26+
describe('when file changes', () => {
27+
it('should perform a partial compilation and emit the updated files', done => {
28+
harness.copyTestCase('valid-text');
29+
30+
harness.onComplete(() => {
31+
harness.expectDtsToMatch('public_api', /title = "foo bar"/);
32+
harness.expectFesm5ToContain('basic', 'title', 'foo bar');
33+
harness.expectFesm2015ToContain('basic', 'title', 'foo bar');
34+
harness.expectMetadataToContain('basic', 'metadata.title', 'foo bar');
35+
done();
36+
});
37+
});
38+
39+
it('should recover from errors', done => {
40+
harness.copyTestCase('invalid-type');
41+
42+
harness.onComplete(() => {
43+
harness.expectDtsToMatch('public_api', /title = "foo bar"/);
44+
harness.expectFesm5ToContain('basic', 'title', 'foo bar');
45+
harness.expectFesm2015ToContain('basic', 'title', 'foo bar');
46+
harness.expectMetadataToContain('basic', 'metadata.title', 'foo bar');
47+
done();
48+
});
49+
50+
harness.onFailure(error => {
51+
harness.copyTestCase('valid-text');
52+
expect(error.message).to.match(/is not assignable to type 'boolean'/);
53+
});
54+
});
55+
});
56+
});

integration/watch/basic/package.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"$schema": "../../../src/package.schema.json",
3+
"name": "basic",
4+
"description": "A sample library testing Angular Package Format",
5+
"version": "1.0.0-pre.0",
6+
"private": true,
7+
"repository": "https://github.com/dherges/ng-packagr.git",
8+
"peerDependencies": {
9+
"@angular/core": "^4.1.2",
10+
"@angular/common": "^4.1.2"
11+
},
12+
"ngPackage": {
13+
"lib": {
14+
"entryFile": "public_api.ts"
15+
}
16+
}
17+
}

integration/watch/basic/public_api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { PrimaryAngularModule } from './src/primary.module';
2+
export const title = 'hello world';
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { Component } from '@angular/core';
2+
3+
@Component({
4+
selector: 'ng-component',
5+
template: '<h1>Angular!</h1>'
6+
})
7+
export class PrimaryAngularComponent {}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { NgModule } from '@angular/core';
2+
import { CommonModule } from '@angular/common';
3+
import { PrimaryAngularComponent } from './primary.component';
4+
5+
@NgModule({
6+
imports: [CommonModule],
7+
declarations: [PrimaryAngularComponent],
8+
exports: [PrimaryAngularComponent]
9+
})
10+
export class PrimaryAngularModule {}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { PrimaryAngularModule } from './src/primary.module';
2+
export const title: boolean = 'foo bar';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { PrimaryAngularModule } from './src/primary.module';
2+
export const title = 'foo bar';

integration/watch/test-harness.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import * as fs from 'fs-extra';
2+
import * as sinon from 'sinon';
3+
import * as path from 'path';
4+
import * as log from '../../src/lib/util/log';
5+
import { expect } from 'chai';
6+
import { Subscription } from 'rxjs';
7+
import { ngPackagr } from '../../src/public_api';
8+
import { tap } from 'rxjs/operators';
9+
10+
/**
11+
* A testing harness class to setup the enviroment andtest the incremental builds.
12+
*/
13+
export class TestHarness {
14+
private completeHandler = () => undefined;
15+
16+
private tmpPath = path.join(__dirname, '.tmp', this.testName);
17+
private testSrc = path.join(__dirname, this.testName);
18+
private ngPackagr$$: Subscription;
19+
20+
constructor(private testName: string) {}
21+
22+
initialize(): Promise<void> {
23+
// the below is done in order to avoid poluting the test reporter with build logs
24+
sinon.stub(log, 'msg');
25+
sinon.stub(log, 'info');
26+
sinon.stub(log, 'debug');
27+
sinon.stub(log, 'success');
28+
sinon.stub(log, 'warn');
29+
30+
this.emptyTestDirectory();
31+
fs.copySync(this.testSrc, this.tmpPath);
32+
return this.setUpNgPackagr();
33+
}
34+
35+
dispose(): void {
36+
this.reset();
37+
38+
if (this.ngPackagr$$) {
39+
this.ngPackagr$$.unsubscribe();
40+
}
41+
42+
this.emptyTestDirectory();
43+
}
44+
45+
reset(): void {
46+
this.completeHandler = () => undefined;
47+
}
48+
49+
readFileSync(filePath: string, isJson = false): string | object {
50+
const file = path.join(this.tmpPath, 'dist', filePath);
51+
return isJson ? fs.readJsonSync(file) : fs.readFileSync(file, { encoding: 'utf-8' });
52+
}
53+
54+
/**
55+
* Copy a test case to it's temporary destination immediately.
56+
*/
57+
copyTestCase(caseName: string) {
58+
fs.copySync(path.join(this.testSrc, 'test_files', caseName), this.tmpPath);
59+
}
60+
61+
expectFesm5ToContain(fileName: string, path: string, value: any): Chai.Assertion {
62+
return expect(this.requireNoCache(`fesm5/${fileName}.js`)).to.have.nested.property(path, value);
63+
}
64+
65+
expectFesm2015ToContain(fileName: string, path: string, value: any): Chai.Assertion {
66+
return expect(this.requireNoCache(`fesm2015/${fileName}.js`)).to.have.nested.property(path, value);
67+
}
68+
69+
expectDtsToMatch(fileName: string, regexp: RegExp): Chai.Assertion {
70+
return expect(this.readFileSync(`${fileName}.d.ts`)).to.match(regexp);
71+
}
72+
73+
expectMetadataToContain(fileName: string, path: string, value: any): Chai.Assertion {
74+
const data = this.readFileSync(`${fileName}.metadata.json`, true);
75+
return expect(data).to.have.nested.property(path, value);
76+
}
77+
78+
/**
79+
* Gets invoked when a compilation errors without any error
80+
*/
81+
onComplete(done: () => void): void {
82+
this.completeHandler = done;
83+
}
84+
85+
/**
86+
* Gets invoked when a compilation error occuries
87+
*/
88+
onFailure(done: (error: Error) => void): void {
89+
sinon.stub(log, 'error').callsFake(done);
90+
}
91+
92+
/**
93+
* Remove the entire directory for the current test case
94+
*/
95+
emptyTestDirectory(): void {
96+
fs.emptyDirSync(this.tmpPath);
97+
}
98+
99+
private setUpNgPackagr(): Promise<void> {
100+
return new Promise(resolve => {
101+
this.ngPackagr$$ = ngPackagr()
102+
.forProject(path.join(this.tmpPath, 'package.json'))
103+
.watch()
104+
.pipe(
105+
tap(() => resolve()), // we are only interested when in the first builds, that's why we are resolving it.
106+
tap(() => this.completeHandler())
107+
)
108+
.subscribe();
109+
});
110+
}
111+
112+
private requireNoCache(modulePath: string): any {
113+
const moduleFile = this.buildFilePath(modulePath);
114+
delete require.cache[path.resolve(moduleFile)];
115+
return require(moduleFile);
116+
}
117+
118+
private buildFilePath(filePath: string): string {
119+
return path.join(this.tmpPath, 'dist', filePath);
120+
}
121+
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,9 @@
130130
"integration:specs": "cross-env TS_NODE_PROJECT=integration/tsconfig.specs.json mocha --require ts-node/register \"integration/samples/*/specs/**/*.ts\"",
131131
"integration:consumers": "integration/consumers.sh",
132132
"integration:consumers:ngc": "ngc -p integration/consumers/tsc/tsconfig.json",
133+
"integration:watch:specs": "cross-env TS_NODE_PROJECT=integration/tsconfig.specs.json mocha --timeout 10000 --require ts-node/register \"integration/watch/*.spec.ts\"",
133134
"test:specs": "cross-env TS_NODE_PROJECT=src/tsconfig.specs.json mocha --require ts-node/register \"src/**/*.spec.ts\"",
134-
"test": "yarn build && yarn test:specs && yarn integration:samples && yarn integration:specs && yarn integration:consumers",
135+
"test": "yarn build && yarn test:specs && yarn integration:samples && yarn integration:specs && yarn integration:watch:specs && yarn integration:consumers",
135136
"commitmsg": "commitlint -e",
136137
"precommit": "pretty-quick --staged",
137138
"gh-pages": "gh-pages -d docs/ghpages"

src/lib/ng-v5/package.transform.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Observable, concat as concatStatic, from as fromPromise, of as observableOf, pipe, EMPTY } from 'rxjs';
1+
import { Observable, concat as concatStatic, from as fromPromise, of as observableOf, pipe, NEVER } from 'rxjs';
22
import { concatMap, map, switchMap, takeLast, tap, mapTo, catchError, startWith, debounceTime } from 'rxjs/operators';
33
import { BuildGraph } from '../brocc/build-graph';
44
import { DepthBuilder } from '../brocc/depth';
@@ -122,14 +122,13 @@ const watchTransformFactory = (
122122
);
123123
}),
124124
switchMap(graph => {
125-
const { url } = graph.find(isPackage) as PackageNode;
126125
return observableOf(graph).pipe(
127126
buildTransformFactory(project, analyseSourcesTransform, entryPointTransform),
128127
tap(() => log.msg(CompleteWaitingForFileChange)),
129128
catchError(error => {
130129
log.error(error);
131130
log.msg(FailedWaitingForFileChange);
132-
return EMPTY;
131+
return NEVER;
133132
})
134133
);
135134
})

src/lib/ng-v5/packagr.ts

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { InjectionToken, Provider, ReflectiveInjector } from 'injection-js';
2-
import { of as observableOf } from 'rxjs';
2+
import { of as observableOf, Observable } from 'rxjs';
33
import { map, catchError } from 'rxjs/operators';
44
import { BuildGraph } from '../brocc/build-graph';
55
import { Transform } from '../brocc/transform';
@@ -90,20 +90,38 @@ export class NgPackagr {
9090
* @return A promisified result of the transformation pipeline.
9191
*/
9292
public build(): Promise<void> {
93+
return this.buildAsObservable().toPromise();
94+
}
95+
96+
/**
97+
* Builds and watch for changes by kick-starting the 'watch' transform over an (initially) empty `BuildGraph``
98+
*
99+
* @return An observable result of the transformation pipeline.
100+
*/
101+
public watch(): Observable<void> {
102+
this.providers.push(provideOptions({ watch: true }));
103+
104+
return this.buildAsObservable();
105+
}
106+
107+
/**
108+
* Builds the project by kick-starting the 'build' transform over an (initially) empty `BuildGraph``
109+
*
110+
* @return An observable result of the transformation pipeline.
111+
*/
112+
public buildAsObservable(): Observable<void> {
93113
const injector = ReflectiveInjector.resolveAndCreate(this.providers);
94114
const buildTransformOperator = injector.get(this.buildTransform);
95115

96-
return observableOf(new BuildGraph())
97-
.pipe(
98-
buildTransformOperator,
99-
catchError(err => {
100-
// Report error and re-throw to subscribers
101-
log.error(err);
102-
throw err;
103-
}),
104-
map(() => undefined)
105-
)
106-
.toPromise();
116+
return observableOf(new BuildGraph()).pipe(
117+
buildTransformOperator,
118+
catchError(err => {
119+
// Report error and re-throw to subscribers
120+
log.error(err);
121+
throw err;
122+
}),
123+
map(() => undefined)
124+
);
107125
}
108126
}
109127

0 commit comments

Comments
 (0)