diff --git a/etc/api/angular_devkit/architect/src/index.d.ts b/etc/api/angular_devkit/architect/src/_golden-api.d.ts similarity index 100% rename from etc/api/angular_devkit/architect/src/index.d.ts rename to etc/api/angular_devkit/architect/src/_golden-api.d.ts diff --git a/etc/api/angular_devkit/core/src/_golden-api.d.ts b/etc/api/angular_devkit/core/src/_golden-api.d.ts index e07a427eb66f..7dab611a0ced 100644 --- a/etc/api/angular_devkit/core/src/_golden-api.d.ts +++ b/etc/api/angular_devkit/core/src/_golden-api.d.ts @@ -5,7 +5,7 @@ export interface AdditionalPropertiesValidatorError extends SchemaValidatorError }; } -export declare function addUndefinedDefaults(value: JsonValue, _pointer: JsonPointer, schema?: JsonObject): JsonValue; +export declare function addUndefinedDefaults(value: JsonValue, _pointer: JsonPointer, schema?: JsonSchema): JsonValue; export declare class AliasHost extends ResolverHost { protected _aliases: Map; @@ -989,7 +989,7 @@ export declare class UnsupportedPlatformException extends BaseException { export declare type UriHandler = (uri: string) => Observable | Promise | null | undefined; -export declare function visitJson(json: JsonValue, visitor: JsonVisitor, schema?: JsonObject, refResolver?: ReferenceResolver, context?: ContextT): Observable; +export declare function visitJson(json: JsonValue, visitor: JsonVisitor, schema?: JsonSchema, refResolver?: ReferenceResolver, context?: ContextT): Observable; export declare function visitJsonSchema(schema: JsonSchema, visitor: JsonSchemaVisitor): void; diff --git a/package.json b/package.json index b58e937118a3..1c9a03e88c25 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ ] }, "dependencies": { + "@types/progress": "^2.0.3", "glob": "^7.0.3", "node-fetch": "^2.2.0", "puppeteer": "1.12.2", diff --git a/packages/_/builders/builders.json b/packages/_/builders/builders.json deleted file mode 100644 index 87914ed792d2..000000000000 --- a/packages/_/builders/builders.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "../architect/src/builders-schema.json", - "builders": { - "true": { - "class": "./src/true", - "schema": "./src/noop-schema.json", - "description": "Always succeed." - } - } -} diff --git a/packages/_/builders/package.json b/packages/_/builders/package.json deleted file mode 100644 index 0f9d3729d481..000000000000 --- a/packages/_/builders/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@_/builders", - "version": "0.0.0", - "description": "CLI tool for Angular", - "main": "src/index.js", - "typings": "src/index.d.ts", - "builders": "builders.json", - "private": true, - "dependencies": { - "rxjs": "6.3.3" - } -} diff --git a/packages/_/builders/src/true.ts b/packages/_/builders/src/true.ts deleted file mode 100644 index efe9b7cb0046..000000000000 --- a/packages/_/builders/src/true.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @license - * Copyright Google Inc. 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 { Observable, of } from 'rxjs'; - -export class TrueBuilder { - constructor() {} - - run(): Observable<{ success: boolean }> { - return of({ - success: true, - }); - } -} - -export default TrueBuilder; diff --git a/packages/angular_devkit/architect/BUILD b/packages/angular_devkit/architect/BUILD index 49dc46ed8807..e54ab10149c2 100644 --- a/packages/angular_devkit/architect/BUILD +++ b/packages/angular_devkit/architect/BUILD @@ -7,9 +7,52 @@ licenses(["notice"]) # MIT load("@build_bazel_rules_typescript//:defs.bzl", "ts_library") load("@build_bazel_rules_nodejs//:defs.bzl", "npm_package") +load("//tools:ts_json_schema.bzl", "ts_json_schema") package(default_visibility = ["//visibility:public"]) +ts_json_schema( + name = "builder_input_schema", + src = "src/input-schema.json", +) +ts_json_schema( + name = "builder_output_schema", + src = "src/output-schema.json", +) +ts_json_schema( + name = "builder_builders_schema", + src = "src/builders-schema.json", +) +ts_json_schema( + name = "progress_schema", + src = "src/progress-schema.json", +) + +ts_library( + name = "node", + srcs = glob( + include = ["node/**/*.ts"], + exclude = [ + "**/*_spec.ts", + "**/*_spec_large.ts", + ], + ), + module_name = "@angular-devkit/architect/node", + module_root = "node/index.d.ts", + # strict_checks = False, + deps = [ + "//packages/angular_devkit/core", + "//packages/angular_devkit/core:node", + "@rxjs", + "@rxjs//operators", + "@npm//@types/node", + ":architect", + ":builder_builders_schema", + ":builder_input_schema", + ":builder_output_schema", + ], +) + ts_library( name = "architect", srcs = glob( @@ -29,6 +72,9 @@ ts_library( "@rxjs", "@rxjs//operators", "@npm//@types/node", + ":builder_input_schema", + ":builder_output_schema", + ":progress_schema", ], ) diff --git a/packages/angular_devkit/architect/builders/all-of.ts b/packages/angular_devkit/architect/builders/all-of.ts new file mode 100644 index 000000000000..491bdd682322 --- /dev/null +++ b/packages/angular_devkit/architect/builders/all-of.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google Inc. 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 { json } from '@angular-devkit/core'; +import { EMPTY, from, of } from 'rxjs'; +import { map, mergeMap } from 'rxjs/operators'; +import { BuilderOutput, BuilderRun, createBuilder } from '../src/index2'; +import { Schema as OperatorSchema } from './operator-schema'; + +export default createBuilder((options, context) => { + const allRuns: Promise<[number, BuilderRun]>[] = []; + + context.reportProgress(0, + (options.targets ? options.targets.length : 0) + + (options.builders ? options.builders.length : 0), + ); + + if (options.targets) { + allRuns.push(...options.targets.map(({ target: targetStr, overrides }, i) => { + const [project, target, configuration] = targetStr.split(/:/g, 3); + + return context.scheduleTarget({ project, target, configuration }, overrides || {}) + .then(run => [i, run] as [number, BuilderRun]); + })); + } + + if (options.builders) { + allRuns.push(...options.builders.map(({ builder, options }, i) => { + return context.scheduleBuilder(builder, options || {}) + .then(run => [i, run] as [number, BuilderRun]); + })); + } + + const allResults: (BuilderOutput | null)[] = allRuns.map(() => null); + let n = 0; + context.reportProgress(n++, allRuns.length); + + return from(allRuns).pipe( + mergeMap(runPromise => from(runPromise)), + mergeMap(([i, run]: [number, BuilderRun]) => run.output.pipe(map(output => [i, output]))), + mergeMap<[number, BuilderOutput], BuilderOutput>(([i, output]) => { + allResults[i] = output; + context.reportProgress(n++, allRuns.length); + + if (allResults.some(x => x === null)) { + // Some builders aren't done running yet. + return EMPTY; + } else { + return of({ + success: allResults.every(x => x ? x.success : false), + }); + } + }), + ); +}); diff --git a/packages/angular_devkit/architect/builders/builders.json b/packages/angular_devkit/architect/builders/builders.json new file mode 100644 index 000000000000..5bbff8289447 --- /dev/null +++ b/packages/angular_devkit/architect/builders/builders.json @@ -0,0 +1,25 @@ +{ + "$schema": "../src/builders-schema.json", + "builders": { + "true": { + "implementation": "./true", + "schema": "./noop-schema.json", + "description": "Always succeed." + }, + "false": { + "implementation": "./false", + "schema": "./noop-schema.json", + "description": "Always fails." + }, + "allOf": { + "implementation": "./all-of", + "schema": "./operator-schema.json", + "description": "A builder that executes many builders in parallel, and succeed if both succeeds." + }, + "concat": { + "implementation": "./concat", + "schema": "./operator-schema.json", + "description": "A builder that executes many builders one after the other, and stops when one fail. It will succeed if all builders succeeds (and return the last output)" + } + } +} diff --git a/packages/angular_devkit/architect/builders/concat.ts b/packages/angular_devkit/architect/builders/concat.ts new file mode 100644 index 000000000000..53ed963be610 --- /dev/null +++ b/packages/angular_devkit/architect/builders/concat.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google Inc. 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 { json } from '@angular-devkit/core'; +import { from, of } from 'rxjs'; +import { concatMap, first, last, map, switchMap } from 'rxjs/operators'; +import { BuilderOutput, BuilderRun, createBuilder } from '../src/index2'; +import { Schema as OperatorSchema } from './operator-schema'; + +export default createBuilder((options, context) => { + const allRuns: (() => Promise)[] = []; + + context.reportProgress(0, + (options.targets ? options.targets.length : 0) + + (options.builders ? options.builders.length : 0), + ); + + if (options.targets) { + allRuns.push(...options.targets.map(({ target: targetStr, overrides }) => { + const [project, target, configuration] = targetStr.split(/:/g, 3); + + return () => context.scheduleTarget({ project, target, configuration }, overrides || {}); + })); + } + + if (options.builders) { + allRuns.push(...options.builders.map(({ builder, options }) => { + return () => context.scheduleBuilder(builder, options || {}); + })); + } + + let stop: BuilderOutput | null = null; + let i = 0; + context.reportProgress(i++, allRuns.length); + + return from(allRuns).pipe( + concatMap(fn => stop ? of(null) : from(fn()).pipe( + switchMap(run => run === null ? of(null) : run.output.pipe(first())), + )), + map(output => { + context.reportProgress(i++, allRuns.length); + if (output === null || stop !== null) { + return stop || { success: false }; + } else if (output.success === false) { + return stop = output; + } else { + return output; + } + }), + last(), + ); +}); diff --git a/packages/angular_devkit/architect/builders/false.ts b/packages/angular_devkit/architect/builders/false.ts new file mode 100644 index 000000000000..4c980ae57752 --- /dev/null +++ b/packages/angular_devkit/architect/builders/false.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright Google Inc. 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 { of } from 'rxjs'; +import { createBuilder } from '../src/index2'; + +export default createBuilder(() => of({ + success: false, + error: 'False builder always errors.', +})); diff --git a/packages/_/builders/src/noop-schema.json b/packages/angular_devkit/architect/builders/noop-schema.json similarity index 100% rename from packages/_/builders/src/noop-schema.json rename to packages/angular_devkit/architect/builders/noop-schema.json diff --git a/packages/angular_devkit/architect/builders/operator-schema.json b/packages/angular_devkit/architect/builders/operator-schema.json new file mode 100644 index 000000000000..8519c83ee7d4 --- /dev/null +++ b/packages/angular_devkit/architect/builders/operator-schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/schema", + "description": "All input types of builders that perform operations on one or multiple sub-builders.", + "type": "object", + "properties": { + "builders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "builder": { + "type": "string", + "pattern": ".*:.*" + }, + "options": { + "type": "object" + } + }, + "required": [ + "builder" + ] + }, + "minItems": 1 + }, + "targets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "target": { + "type": "string", + "pattern": ".*:.*" + }, + "overrides": { + "type": "object" + } + }, + "required": [ + "target" + ] + }, + "minItems": 1 + } + } +} diff --git a/packages/angular_devkit/architect/builders/true.ts b/packages/angular_devkit/architect/builders/true.ts new file mode 100644 index 000000000000..cbb1e2ee55a3 --- /dev/null +++ b/packages/angular_devkit/architect/builders/true.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright Google Inc. 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 { of } from 'rxjs'; +import { createBuilder } from '../src/index2'; + +export default createBuilder(() => of({ success: true })); diff --git a/packages/angular_devkit/architect/node/index.ts b/packages/angular_devkit/architect/node/index.ts new file mode 100644 index 000000000000..721047f44093 --- /dev/null +++ b/packages/angular_devkit/architect/node/index.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright Google Inc. 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 './node-modules-architect-host'; diff --git a/packages/angular_devkit/architect/node/node-modules-architect-host.ts b/packages/angular_devkit/architect/node/node-modules-architect-host.ts new file mode 100644 index 000000000000..7574f0e78eed --- /dev/null +++ b/packages/angular_devkit/architect/node/node-modules-architect-host.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright Google Inc. 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 { experimental, json } from '@angular-devkit/core'; +import { resolve } from '@angular-devkit/core/node'; +import * as path from 'path'; +import { Schema as BuilderSchema } from '../src/builders-schema'; +import { BuilderInfo } from '../src/index2'; +import { Target } from '../src/input-schema'; +import { ArchitectHost, Builder, BuilderSymbol } from '../src/internal'; + + +export type NodeModulesBuilderInfo = BuilderInfo & { + import: string; +}; + + +// TODO: create a base class for all workspace related hosts. +export class WorkspaceNodeModulesArchitectHost implements ArchitectHost { + constructor( + protected _workspace: experimental.workspace.Workspace, + protected _root: string, + ) {} + + async getBuilderNameForTarget(target: Target) { + return this._workspace.getProjectTargets(target.project)[target.target]['builder']; + } + + /** + * Resolve a builder. This needs to be a string which will be used in a dynamic `import()` + * clause. This should throw if no builder can be found. The dynamic import will throw if + * it is unsupported. + * @param builderStr The name of the builder to be used. + * @returns All the info needed for the builder itself. + */ + resolveBuilder(builderStr: string): Promise { + const [packageName, builderName] = builderStr.split(':', 2); + if (!builderName) { + throw new Error('No builder name specified.'); + } + + const packageJsonPath = resolve(packageName, { + basedir: this._root, + checkLocal: true, + checkGlobal: true, + resolvePackageJson: true, + }); + + const packageJson = require(packageJsonPath); + if (!packageJson['builders']) { + throw new Error(`Package ${JSON.stringify(packageName)} has no builders defined.`); + } + + const builderJsonPath = path.resolve(path.dirname(packageJsonPath), packageJson['builders']); + const builderJson = require(builderJsonPath) as BuilderSchema; + + const builder = builderJson.builders && builderJson.builders[builderName]; + + if (!builder) { + throw new Error(`Cannot find builder ${JSON.stringify(builderName)}.`); + } + + const importPath = builder.implementation; + if (!importPath) { + throw new Error('Invalid builder JSON'); + } + + return Promise.resolve({ + name: builderStr, + builderName, + description: builder['description'], + optionSchema: require(path.resolve(path.dirname(builderJsonPath), builder.schema)), + import: path.resolve(path.dirname(builderJsonPath), importPath), + }); + } + + async getCurrentDirectory() { + return process.cwd(); + } + + async getWorkspaceRoot() { + return this._root; + } + + async getOptionsForTarget(target: Target): Promise { + const targetSpec = this._workspace.getProjectTargets(target.project)[target.target]; + if (target.configuration && !targetSpec['configurations']) { + throw new Error('Configuration not set in the workspace.'); + } + + return { + ...targetSpec['options'], + ...(target.configuration ? targetSpec['configurations'][target.configuration] : 0), + }; + } + + async loadBuilder(info: NodeModulesBuilderInfo): Promise { + const builder = (await import(info.import)).default; + if (builder[BuilderSymbol]) { + return builder; + } + + throw new Error('Builder is not a builder'); + } +} diff --git a/packages/angular_devkit/architect/package.json b/packages/angular_devkit/architect/package.json index f04b3a985ffa..1a5c8b0c3c93 100644 --- a/packages/angular_devkit/architect/package.json +++ b/packages/angular_devkit/architect/package.json @@ -7,5 +7,6 @@ "dependencies": { "@angular-devkit/core": "0.0.0", "rxjs": "6.3.3" - } -} \ No newline at end of file + }, + "builders": "./builders/builders.json" +} diff --git a/packages/angular_devkit/architect/src/_golden-api.d.ts b/packages/angular_devkit/architect/src/_golden-api.d.ts new file mode 100644 index 000000000000..3e50b7278e3e --- /dev/null +++ b/packages/angular_devkit/architect/src/_golden-api.d.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright Google Inc. 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 './architect-legacy'; +// export * from './api'; +// export { Architect, ScheduleOptions } from './architect'; +// export { createBuilder } from './create-builder'; diff --git a/packages/angular_devkit/architect/src/api.ts b/packages/angular_devkit/architect/src/api.ts new file mode 100644 index 000000000000..6aabb6160448 --- /dev/null +++ b/packages/angular_devkit/architect/src/api.ts @@ -0,0 +1,230 @@ +/** + * @license + * Copyright Google Inc. 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 { experimental, json, logging } from '@angular-devkit/core'; +import { Observable } from 'rxjs'; +import { Schema as RealBuilderInput, Target as RealTarget } from './input-schema'; +import { Schema as RealBuilderOutput } from './output-schema'; +import { Schema as RealBuilderProgress, State as BuilderProgressState } from './progress-schema'; + +export type Target = json.JsonObject & RealTarget; +export { + BuilderProgressState, +}; + +// Type short hands. +export type BuilderRegistry = + experimental.jobs.Registry; + + +/** + * An API typed BuilderProgress. The interface generated from the schema is too permissive, + * so this API is the one we show in our API. Please note that not all fields are in there; this + * is in addition to fields in the schema. + */ +export type TypedBuilderProgress = ( + { state: BuilderProgressState.Stopped; } + | { state: BuilderProgressState.Error; error: json.JsonValue; } + | { state: BuilderProgressState.Waiting; status?: string; } + | { state: BuilderProgressState.Running; status?: string; current: number; total?: number; } +); + +/** + * Declaration of those types as JsonObject compatible. JsonObject is not compatible with + * optional members, so those wouldn't be directly assignable to our internal Json typings. + * Forcing the type to be both a JsonObject and the type from the Schema tells Typescript they + * are compatible (which they are). + * These types should be used everywhere. + */ +export type BuilderInput = json.JsonObject & RealBuilderInput; +export type BuilderOutput = json.JsonObject & RealBuilderOutput; +export type BuilderProgress = json.JsonObject & RealBuilderProgress & TypedBuilderProgress; + +/** + * A progress report is what the tooling will receive. It contains the builder info and the target. + * Although these are serializable, they are only exposed through the tooling interface, not the + * builder interface. The watch dog sends BuilderProgress and the Builder has a set of functions + * to manage the state. + */ +export type BuilderProgressReport = BuilderProgress & ({ + target?: Target; + builder: BuilderInfo; +}); + +/** + * A Run, which is what is returned by scheduleBuilder or scheduleTarget functions. This should + * be reconstructed across memory boundaries (it's not serializable but all internal information + * are). + */ +export interface BuilderRun { + /** + * Unique amongst runs. This is the same ID as the context generated for the run. It can be + * used to identify multiple unique runs. There is no guarantee that a run is a single output; + * a builder can rebuild on its own and will generate multiple outputs. + */ + id: number; + + /** + * The builder information. + */ + info: BuilderInfo; + + /** + * The next output from a builder. This is recommended when scheduling a builder and only being + * interested in the result of that single run, not of a watch-mode builder. + */ + result: Promise; + + /** + * The output(s) from the builder. A builder can have multiple outputs. + * This always replay the last output when subscribed. + */ + output: Observable; + + /** + * The progress report. A progress also contains an ID, which can be different than this run's + * ID (if the builder calls scheduleBuilder or scheduleTarget). + * This will always replay the last progress on new subscriptions. + */ + progress: Observable; + + /** + * Stop the builder from running. Returns a promise that resolves when the builder is stopped. + * Some builders might not handle stopping properly and should have a timeout here. + */ + stop(): Promise; +} + +/** + * The context received as a second argument in your builder. + */ +export interface BuilderContext { + /** + * Unique amongst contexts. Contexts instances are not guaranteed to be the same (but it could + * be the same context), and all the fields in a context could be the same, yet the builder's + * context could be different. This is the same ID as the corresponding run. + */ + id: number; + + /** + * The builder info that called your function. Since the builder info is from the builder.json + * (or the host), it could contain information that is different than expected. + */ + builder: BuilderInfo; + + /** + * A logger that appends messages to a log. This could be a separate interface or completely + * ignored. `console.log` could also be completely ignored. + */ + logger: logging.LoggerApi; + + /** + * The absolute workspace root of this run. This is a system path and will not be normalized; + * ie. on Windows it will starts with `C:\\` (or whatever drive). + */ + workspaceRoot: string; + + /** + * The current directory the user is in. This could be outside the workspace root. This is a + * system path and will not be normalized; ie. on Windows it will starts with `C:\\` (or + * whatever drive). + */ + currentDirectory: string; + + /** + * The target that was used to run this builder. + * Target is optional if a builder was ran using `scheduleBuilder()`. + */ + target?: Target; + + /** + * Schedule a target in the same workspace. This can be the same target that is being executed + * right now, but targets of the same name are serialized. + * Running the same target and waiting for it to end will result in a deadlocking scenario. + * Targets are considered the same if the project, the target AND the configuration are the same. + * @param target The target to schedule. + * @param overrides A set of options to override the workspace set of options. + * @return A promise of a run. It will resolve when all the members of the run are available. + */ + scheduleTarget( + target: Target, + overrides?: json.JsonObject, + ): Promise; + + /** + * Schedule a builder by its name. This can be the same builder that is being executed. + * @param builderName The name of the builder, ie. its `packageName:builderName` tuple. + * @param options All options to use for the builder (by default empty object). There is no + * additional options added, e.g. from the workspace. + * @return A promise of a run. It will resolve when all the members of the run are available. + */ + scheduleBuilder( + builderName: string, + options?: json.JsonObject, + ): Promise; + + /** + * Set the builder to running. This should be used if an external event triggered a re-run, + * e.g. a file watched was changed. + */ + reportRunning(): void; + + /** + * Update the status string shown on the interface. + * @param status The status to set it to. An empty string can be used to remove the status. + */ + reportStatus(status: string): void; + + /** + * Update the progress for this builder run. + * @param current The current progress. This will be between 0 and total. + * @param total A new total to set. By default at the start of a run this is 1. If omitted it + * will use the same value as the last total. + * @param status Update the status string. If omitted the status string is not modified. + */ + reportProgress(current: number, total?: number, status?: string): void; +} + + +/** + * An accepted return value from a builder. Can be either an Observable, a Promise or a vector. + */ +export type BuilderOutputLike = Observable | Promise | BuilderOutput; + + +/** + * A builder handler function. The function signature passed to `createBuilder()`. + */ +export interface BuilderHandlerFn { + /** + * Builders are defined by users to perform any kind of task, like building, testing or linting, + * and should use this interface. + * @param input The options (a JsonObject), validated by the schema and received by the + * builder. This can include resolved options from the CLI or the workspace. + * @param context A context that can be used to interact with the Architect framework. + * @return One or many builder output. + */ + (input: A, context: BuilderContext): BuilderOutputLike; +} + +/** + * A Builder general information. This is generated by the host and is expanded by the host, but + * the public API contains those fields. + */ +export type BuilderInfo = json.JsonObject & { + builderName: string; + description: string; + optionSchema: json.schema.JsonSchema; +}; + + +/** + * Returns a string of "project:target[:configuration]" for the target object. + */ +export function targetStringFromTarget({project, target, configuration}: Target) { + return `${project}:${target}${configuration !== undefined ? ':' + configuration : ''}`; +} diff --git a/packages/angular_devkit/architect/src/architect.ts b/packages/angular_devkit/architect/src/architect.ts new file mode 100644 index 000000000000..36f46a7eca1e --- /dev/null +++ b/packages/angular_devkit/architect/src/architect.ts @@ -0,0 +1,288 @@ +/** + * @license + * Copyright Google Inc. 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 { experimental, json, logging } from '@angular-devkit/core'; +import { Observable, from, of } from 'rxjs'; +import { concatMap, first, map, shareReplay } from 'rxjs/operators'; +import { + BuilderInfo, + BuilderInput, + BuilderOutput, + BuilderRegistry, + BuilderRun, + Target, + targetStringFromTarget, +} from './api'; +import { ArchitectHost, BuilderDescription, BuilderJobHandler } from './internal'; +import { scheduleByName, scheduleByTarget } from './schedule-by-name'; + +const inputSchema = require('./input-schema.json'); +const outputSchema = require('./output-schema.json'); + +function _createJobHandlerFromBuilderInfo( + info: BuilderInfo, + target: Target | undefined, + host: ArchitectHost, + registry: json.schema.SchemaRegistry, + baseOptions: json.JsonObject, +): Observable { + const jobDescription: BuilderDescription = { + name: target ? `{${targetStringFromTarget(target)}}` : info.builderName, + argument: { type: 'object' }, + input: inputSchema, + output: outputSchema, + info, + }; + + function handler(argument: json.JsonObject, context: experimental.jobs.JobHandlerContext) { + const inboundBus = context.inboundBus.pipe( + concatMap(message => { + if (message.kind === experimental.jobs.JobInboundMessageKind.Input) { + const v = message.value as BuilderInput; + const options = { + ...baseOptions, + ...v.options, + }; + + // Validate v against the options schema. + return registry.compile(info.optionSchema).pipe( + concatMap(validation => validation(options)), + map(result => { + if (result.success) { + return { ...v, options: result.data } as BuilderInput; + } else if (result.errors) { + throw new Error('Options did not validate.' + result.errors.join()); + } else { + return v; + } + }), + map(value => ({ ...message, value })), + ); + } else { + return of(message as experimental.jobs.JobInboundMessage); + } + }), + // Using a share replay because the job might be synchronously sending input, but + // asynchronously listening to it. + shareReplay(1), + ); + + return from(host.loadBuilder(info)).pipe( + concatMap(builder => { + if (builder === null) { + throw new Error(`Cannot load builder for builderInfo ${JSON.stringify(info, null, 2)}`); + } + + return builder.handler(argument, { ...context, inboundBus }).pipe( + map(output => { + if (output.kind === experimental.jobs.JobOutboundMessageKind.Output) { + // Add target to it. + return { + ...output, + value: { + ...output.value, + ...target ? { target } : 0, + } as json.JsonObject, + }; + } else { + return output; + } + }), + ); + }), + ); + } + + return of(Object.assign(handler, { jobDescription }) as BuilderJobHandler); +} + +export interface ScheduleOptions { + logger?: logging.Logger; +} + + +/** + * A JobRegistry that resolves builder targets from the host. + */ +export class ArchitectBuilderJobRegistry implements BuilderRegistry { + constructor( + protected _host: ArchitectHost, + protected _registry: json.schema.SchemaRegistry, + protected _jobCache?: Map>, + protected _infoCache?: Map>, + ) {} + + protected _resolveBuilder(name: string): Observable { + const cache = this._infoCache; + if (cache) { + const maybeCache = cache.get(name); + if (maybeCache !== undefined) { + return maybeCache; + } + + const info = from(this._host.resolveBuilder(name)).pipe( + shareReplay(1), + ); + cache.set(name, info); + + return info; + } + + return from(this._host.resolveBuilder(name)); + } + + protected _createBuilder( + info: BuilderInfo, + target?: Target, + options?: json.JsonObject, + ): Observable { + const cache = this._jobCache; + if (target) { + const maybeHit = cache && cache.get(targetStringFromTarget(target)); + if (maybeHit) { + return maybeHit; + } + } else { + const maybeHit = cache && cache.get(info.builderName); + if (maybeHit) { + return maybeHit; + } + } + + const result = _createJobHandlerFromBuilderInfo( + info, + target, + this._host, + this._registry, + options || {}, + ); + + if (cache) { + if (target) { + cache.set(targetStringFromTarget(target), result.pipe(shareReplay(1))); + } else { + cache.set(info.builderName, result.pipe(shareReplay(1))); + } + } + + return result; + } + + get< + A extends json.JsonObject, + I extends BuilderInput, + O extends BuilderOutput, + >(name: string): Observable | null> { + const m = name.match(/^([^:]+):([^:]+)$/i); + if (!m) { + return of(null); + } + + return from(this._resolveBuilder(name)).pipe( + concatMap(builderInfo => builderInfo ? this._createBuilder(builderInfo) : of(null)), + first(null, null), + ) as Observable | null>; + } +} + +/** + * A JobRegistry that resolves targets from the host. + */ +export class ArchitectTargetJobRegistry extends ArchitectBuilderJobRegistry { + get< + A extends json.JsonObject, + I extends BuilderInput, + O extends BuilderOutput, + >(name: string): Observable | null> { + const m = name.match(/^{([^:]+):([^:]+)(?::([^:]*))?}$/i); + if (!m) { + return of(null); + } + + const target = { + project: m[1], + target: m[2], + configuration: m[3], + }; + + return from(Promise.all([ + this._host.getBuilderNameForTarget(target), + this._host.getOptionsForTarget(target), + ])).pipe( + concatMap(([builderStr, options]) => { + if (builderStr === null || options === null) { + return of(null); + } + + return this._resolveBuilder(builderStr).pipe( + concatMap(builderInfo => { + if (builderInfo === null) { + return of(null); + } + + return this._createBuilder(builderInfo, target, options); + }), + ); + }), + first(null, null), + ) as Observable | null>; + } +} + + +export class Architect { + private readonly _scheduler: experimental.jobs.Scheduler; + private readonly _jobCache = new Map>(); + private readonly _infoCache = new Map>(); + + constructor( + private _host: ArchitectHost, + private _registry: json.schema.SchemaRegistry = new json.schema.CoreSchemaRegistry(), + additionalJobRegistry?: experimental.jobs.Registry, + ) { + const jobRegistry = new experimental.jobs.FallbackRegistry([ + new ArchitectTargetJobRegistry(_host, _registry, this._jobCache, this._infoCache), + new ArchitectBuilderJobRegistry(_host, _registry, this._jobCache, this._infoCache), + ...(additionalJobRegistry ? [additionalJobRegistry] : []), + ] as experimental.jobs.Registry[]); + + this._scheduler = new experimental.jobs.SimpleScheduler(jobRegistry, _registry); + } + + has(name: experimental.jobs.JobName) { + return this._scheduler.has(name); + } + + scheduleBuilder( + name: string, + options: json.JsonObject, + scheduleOptions: ScheduleOptions = {}, + ): Promise { + if (!/^[^:]+:[^:]+$/.test(name)) { + throw new Error('Invalid builder name: ' + JSON.stringify(name)); + } + + return scheduleByName(name, options, { + scheduler: this._scheduler, + logger: scheduleOptions.logger || new logging.NullLogger(), + currentDirectory: this._host.getCurrentDirectory(), + workspaceRoot: this._host.getWorkspaceRoot(), + }); + } + scheduleTarget( + target: Target, + overrides: json.JsonObject = {}, + scheduleOptions: ScheduleOptions = {}, + ): Promise { + return scheduleByTarget(target, overrides, { + scheduler: this._scheduler, + logger: scheduleOptions.logger || new logging.NullLogger(), + currentDirectory: this._host.getCurrentDirectory(), + workspaceRoot: this._host.getWorkspaceRoot(), + }); + } +} diff --git a/packages/angular_devkit/architect/src/builders-schema.json b/packages/angular_devkit/architect/src/builders-schema.json index d111b24f0103..8c3f3e6cbc24 100644 --- a/packages/angular_devkit/architect/src/builders-schema.json +++ b/packages/angular_devkit/architect/src/builders-schema.json @@ -9,33 +9,62 @@ "description": "Link to schema." }, "builders": { + "type": "object", "additionalProperties": { "$ref": "#/definitions/builder" } } }, + "required": [ + "builders" + ], "definitions": { "builder": { "type": "object", - "description": "Target options.", - "properties": { - "class": { - "type": "string", - "description": "The builder class module." - }, - "schema": { - "type": "string", - "description": "Schema for builder option validation." + "description": "Target options for Builders.", + "allOf": [ + { + "properties": { + "schema": { + "type": "string", + "description": "Schema for builder option validation." + }, + "description": { + "type": "string", + "description": "Builder description." + } + }, + "required": [ + "schema", + "description" + ] }, - "description": { - "type": "string", - "description": "Builder description." + { + "anyOf": [ + { + "properties": { + "implementation": { + "type": "string", + "description": "The next generation builder module." + } + }, + "required": [ + "implementation" + ] + }, + { + "properties": { + "class": { + "type": "string", + "description": "The builder class module." + } + }, + "required": [ + "class" + ] + } + ] } - }, - "required": [ - "class", - "schema", - "description" ] } } diff --git a/packages/angular_devkit/architect/src/create-builder.ts b/packages/angular_devkit/architect/src/create-builder.ts new file mode 100644 index 000000000000..9ff2ff6824b8 --- /dev/null +++ b/packages/angular_devkit/architect/src/create-builder.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright Google Inc. 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 { experimental, isPromise, json, logging } from '@angular-devkit/core'; +import { Observable, Subscription, from, isObservable, of } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { + BuilderContext, + BuilderHandlerFn, + BuilderInfo, + BuilderInput, + BuilderOutput, + BuilderProgressState, + Target, + TypedBuilderProgress, + targetStringFromTarget, +} from './api'; +import { Builder, BuilderSymbol, BuilderVersionSymbol } from './internal'; +import { scheduleByName, scheduleByTarget } from './schedule-by-name'; + + +export function createBuilder( + fn: BuilderHandlerFn, +): Builder { + const cjh = experimental.jobs.createJobHandler; + const handler = cjh((options, context) => { + const scheduler = context.scheduler; + const progressChannel = context.createChannel('progress'); + const logChannel = context.createChannel('log'); + let currentState: BuilderProgressState = BuilderProgressState.Stopped; + let current = 0; + let status = ''; + let total = 1; + + function log(entry: logging.LogEntry) { + logChannel.next(entry); + } + function progress(progress: TypedBuilderProgress, context: BuilderContext) { + currentState = progress.state; + if (progress.state === BuilderProgressState.Running) { + current = progress.current; + total = progress.total !== undefined ? progress.total : total; + + if (progress.status === undefined) { + progress.status = status; + } else { + status = progress.status; + } + } + + progressChannel.next({ + ...progress as json.JsonObject, + ...(context.target && { target: context.target }), + ...(context.builder && { builder: context.builder }), + id: context.id, + }); + } + + return new Observable(observer => { + const subscriptions: Subscription[] = []; + + const inputSubscription = context.inboundBus.subscribe( + i => { + switch (i.kind) { + case experimental.jobs.JobInboundMessageKind.Stop: + observer.complete(); + break; + case experimental.jobs.JobInboundMessageKind.Input: + onInput(i.value); + break; + } + }, + ); + + function onInput(i: BuilderInput) { + const builder = i.info as BuilderInfo; + const loggerName = i.target + ? targetStringFromTarget(i.target as Target) + : builder.builderName; + const logger = new logging.Logger(loggerName); + + subscriptions.push(logger.subscribe(entry => log(entry))); + + const context: BuilderContext = { + builder, + workspaceRoot: i.workspaceRoot, + currentDirectory: i.currentDirectory, + target: i.target as Target, + logger: logger, + id: i.id, + async scheduleTarget(target: Target, overrides: json.JsonObject = {}) { + const run = await scheduleByTarget(target, overrides, { + scheduler, + logger: logger.createChild(''), + workspaceRoot: i.workspaceRoot, + currentDirectory: i.currentDirectory, + }); + + // We don't want to subscribe errors and complete. + subscriptions.push(run.progress.subscribe(event => progressChannel.next(event))); + + return run; + }, + async scheduleBuilder(builderName: string, options: json.JsonObject = {}) { + const run = await scheduleByName(builderName, options, { + scheduler, + logger: logger.createChild(''), + workspaceRoot: i.workspaceRoot, + currentDirectory: i.currentDirectory, + }); + + // We don't want to subscribe errors and complete. + subscriptions.push(run.progress.subscribe(event => progressChannel.next(event))); + + return run; + }, + reportRunning() { + switch (currentState) { + case BuilderProgressState.Waiting: + case BuilderProgressState.Stopped: + progress({state: BuilderProgressState.Running, current: 0, total}, context); + break; + } + }, + reportStatus(status: string) { + switch (currentState) { + case BuilderProgressState.Running: + progress({ state: currentState, status, current, total }, context); + break; + case BuilderProgressState.Waiting: + progress({ state: currentState, status }, context); + break; + } + }, + reportProgress(current: number, total?: number, status?: string) { + switch (currentState) { + case BuilderProgressState.Running: + progress({ state: currentState, current, total, status }, context); + } + }, + }; + + context.reportRunning(); + let result = fn(i.options as OptT, context); + + if (isPromise(result)) { + result = from(result); + } else if (!isObservable(result)) { + result = of(result); + } + + // Manage some state automatically. + progress({ state: BuilderProgressState.Running, current: 0, total: 1 }, context); + subscriptions.push(result.pipe( + tap(() => { + progress({ state: BuilderProgressState.Running, current: total }, context); + progress({ state: BuilderProgressState.Stopped }, context); + }), + ).subscribe( + message => observer.next(message), + error => observer.error(error), + () => observer.complete(), + )); + } + + return () => { + subscriptions.forEach(x => x.unsubscribe()); + inputSubscription.unsubscribe(); + }; + }); + }); + + return { + handler, + [BuilderSymbol]: true, + [BuilderVersionSymbol]: require('../package.json').version, + }; +} diff --git a/packages/angular_devkit/architect/src/index.ts b/packages/angular_devkit/architect/src/index.ts index 1041aa4e5be0..d219e68bc09d 100644 --- a/packages/angular_devkit/architect/src/index.ts +++ b/packages/angular_devkit/architect/src/index.ts @@ -9,3 +9,8 @@ * @deprecated */ export * from './architect-legacy'; + +import * as index2 from './index2'; +export { + index2, +}; diff --git a/packages/angular_devkit/architect/src/index2.ts b/packages/angular_devkit/architect/src/index2.ts new file mode 100644 index 000000000000..a21d4ff324b2 --- /dev/null +++ b/packages/angular_devkit/architect/src/index2.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright Google Inc. 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 './api'; +export { Architect, ScheduleOptions } from './architect'; +export { createBuilder } from './create-builder'; diff --git a/packages/angular_devkit/architect/src/index2_spec.ts b/packages/angular_devkit/architect/src/index2_spec.ts new file mode 100644 index 000000000000..d8c7b876c844 --- /dev/null +++ b/packages/angular_devkit/architect/src/index2_spec.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright Google Inc. 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 { schema } from '@angular-devkit/core'; +import { timer } from 'rxjs'; +import { map, take, tap, toArray } from 'rxjs/operators'; +import { TestingArchitectHost } from '../testing/testing-architect-host'; +import { BuilderOutput } from './api'; +import { Architect } from './architect'; +import { createBuilder } from './create-builder'; + +describe('architect', () => { + let testArchitectHost: TestingArchitectHost; + let architect: Architect; + let called = 0; + let options: {} = {}; + const target1 = { + project: 'test', + target: 'test', + }; + const target2 = { + project: 'test', + target: 'abc', + }; + + beforeEach(async () => { + const registry = new schema.CoreSchemaRegistry(); + registry.addPostTransform(schema.transforms.addUndefinedDefaults); + testArchitectHost = new TestingArchitectHost(); + architect = new Architect(testArchitectHost, registry); + + called = 0; + testArchitectHost.addBuilder('package:test', createBuilder(async () => { + called++; + + return new Promise(resolve => { + setTimeout(() => resolve({ success: true }), 10); + }); + })); + testArchitectHost.addBuilder('package:test-options', createBuilder(o => { + options = o; + + return { success: true }; + })); + + testArchitectHost.addTarget(target1, 'package:test'); + testArchitectHost.addTarget(target2, 'package:test'); + }); + + it('works', async () => { + testArchitectHost.addBuilder('package:test', createBuilder(() => ({ success: true }))); + + const run = await architect.scheduleBuilder('package:test', {}); + expect(await run.result).toEqual(jasmine.objectContaining({ success: true })); + await run.stop(); + }); + + it('works with async builders', async () => { + testArchitectHost.addBuilder('package:test', createBuilder(async () => ({ success: true }))); + + const run = await architect.scheduleBuilder('package:test', {}); + expect(await run.result).toEqual(jasmine.objectContaining({ success: true })); + await run.stop(); + }); + + it('runs builders parallel', async () => { + const run = await architect.scheduleBuilder('package:test', {}); + const run2 = await architect.scheduleBuilder('package:test', {}); + expect(called).toBe(2); + expect(await run.result).toEqual(jasmine.objectContaining({ success: true })); + expect(await run2.result).toEqual(jasmine.objectContaining({ success: true })); + expect(called).toBe(2); + await run.stop(); + }); + + it('runs targets parallel', async () => { + const run = await architect.scheduleTarget(target1, {}); + const run2 = await architect.scheduleTarget(target1, {}); + expect(called).toBe(2); + expect(await run.result).toEqual(jasmine.objectContaining({ success: true })); + expect(await run2.result).toEqual(jasmine.objectContaining({ success: true })); + expect(called).toBe(2); + await run.stop(); + }); + + it('passes options to builders', async () => { + const o = { hello: 'world' }; + const run = await architect.scheduleBuilder('package:test-options', o); + expect(await run.result).toEqual(jasmine.objectContaining({ success: true })); + expect(options).toEqual(o); + }); + + it('passes options to targets', async () => { + const o = { hello: 'world' }; + const run = await architect.scheduleTarget(target1, o); + expect(await run.result).toEqual(jasmine.objectContaining({ success: true })); + expect(options).toEqual(o); + }); + + it('errors when builder cannot be resolved', async () => { + try { + await architect.scheduleBuilder('non:existent', {}); + expect('to throw').not.toEqual('to throw'); + } catch { + } + }); + + it('works with watching builders', async () => { + let results = 0; + testArchitectHost.addBuilder('package:test-watch', createBuilder((_, context) => { + called++; + + return timer(10, 10).pipe( + take(10), + map(() => { + context.reportRunning(); + + return { success: true }; + }), + tap(() => results++), + ); + })); + + const run = await architect.scheduleBuilder('package:test-watch', {}); + await run.result; + expect(called).toBe(1); + expect(results).toBe(1); + + const all = await run.output.pipe(toArray()).toPromise(); + expect(called).toBe(1); + expect(results).toBe(10); + expect(all.length).toBe(10); + }); +}); diff --git a/packages/angular_devkit/architect/src/input-schema.json b/packages/angular_devkit/architect/src/input-schema.json new file mode 100644 index 000000000000..47d9c30014e5 --- /dev/null +++ b/packages/angular_devkit/architect/src/input-schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "BuilderInputSchema", + "title": "Input schema for builders.", + "type": "object", + "properties": { + "workspaceRoot": { + "type": "string" + }, + "currentDirectory": { + "type": "string" + }, + "id": { + "type": "number" + }, + "target": { + "type": "object", + "properties": { + "project": { + "type": "string" + }, + "target": { + "type": "string" + }, + "configuration": { + "type": "string" + } + }, + "required": [ + "project", + "target" + ] + }, + "info": { + "type": "object" + }, + "options": { + "type": "object" + } + }, + "required": [ + "currentDirectory", + "id", + "info", + "workspaceRoot" + ] +} diff --git a/packages/angular_devkit/architect/src/internal.ts b/packages/angular_devkit/architect/src/internal.ts new file mode 100644 index 000000000000..0ccf439926ee --- /dev/null +++ b/packages/angular_devkit/architect/src/internal.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright Google Inc. 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 { experimental, json } from '@angular-devkit/core'; +import { BuilderInfo, BuilderInput, BuilderOutput, Target } from './api'; + +// Internal types that should not be exported directly. These are used by the host and architect +// itself. Host implementations should import the host.ts file. + +/** + * BuilderSymbol used for knowing if a function was created using createBuilder(). This is a + * property set on the function that should be `true`. + * Using Symbol.for() as it's a global registry that's the same for all installations of + * Architect (if some libraries depends directly on architect instead of sharing the files). + */ +export const BuilderSymbol = Symbol.for('@angular-devkit/architect:builder'); + +/** + * BuilderVersionSymbol used for knowing which version of the library createBuilder() came from. + * This is to make sure we don't try to use an incompatible builder. + * Using Symbol.for() as it's a global registry that's the same for all installations of + * Architect (if some libraries depends directly on architect instead of sharing the files). + */ +export const BuilderVersionSymbol = Symbol.for('@angular-devkit/architect:version'); + +/** + * A Specialization of the JobHandler type. This exposes BuilderDescription as the job description + * type. + */ +export type BuilderJobHandler< + A extends json.JsonObject = json.JsonObject, + I extends BuilderInput = BuilderInput, + O extends BuilderOutput = BuilderOutput, +> = experimental.jobs.JobHandler & { jobDescription: BuilderDescription }; + +/** + * A Builder description, which is used internally. Adds the builder info which is the + * metadata attached to a builder in Architect. + */ +export interface BuilderDescription extends experimental.jobs.JobDescription { + info: BuilderInfo; +} + +/** + * A Builder instance. Use createBuilder() to create one of these. + */ +export interface Builder { + // A fully compatible job handler. + handler: experimental.jobs.JobHandler; + + // Metadata associated with this builder. + [BuilderSymbol]: true; + [BuilderVersionSymbol]: string; +} + +export interface ArchitectHost { + /** + * Get the builder name for a target. + * @param target The target to inspect. + */ + getBuilderNameForTarget(target: Target): Promise; + + /** + * Resolve a builder. This needs to return a string which will be used in a dynamic `import()` + * clause. This should throw if no builder can be found. The dynamic import will throw if + * it is unsupported. + * @param builderName The name of the builder to be used. + * @returns All the info needed for the builder itself. + */ + resolveBuilder(builderName: string): Promise; + loadBuilder(info: BuilderInfoT): Promise; + + getCurrentDirectory(): Promise; + getWorkspaceRoot(): Promise; + + getOptionsForTarget(target: Target): Promise; +} diff --git a/packages/angular_devkit/architect/src/output-schema.json b/packages/angular_devkit/architect/src/output-schema.json new file mode 100644 index 000000000000..8dbcc54add57 --- /dev/null +++ b/packages/angular_devkit/architect/src/output-schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "BuilderOutputSchema", + "title": "Output schema for builders.", + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "target": { + "type": "object", + "properties": { + "project": { + "type": "string" + }, + "target": { + "type": "string" + }, + "configuration": { + "type": "string" + } + } + }, + "info": { + "type": "object" + } + }, + "required": [ + "success" + ] +} diff --git a/packages/angular_devkit/architect/src/progress-schema.json b/packages/angular_devkit/architect/src/progress-schema.json new file mode 100644 index 000000000000..24cd967a4f92 --- /dev/null +++ b/packages/angular_devkit/architect/src/progress-schema.json @@ -0,0 +1,102 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "BuilderProgressSchema", + "title": "Progress schema for builders.", + "type": "object", + "allOf": [ + { + "type": "object", + "oneOf": [ + { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "stopped" + ] + } + }, + "required": [ + "state" + ] + }, + { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "waiting" + ] + }, + "status": { + "type": "string" + } + }, + "required": [ + "state" + ] + }, + { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "running" + ] + }, + "current": { + "type": "number", + "minimum": 0 + }, + "total": { + "type": "number", + "minimum": 0 + }, + "status": { + "type": "string" + } + }, + "required": [ + "state" + ] + }, + { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "error" + ] + }, + "error": true + }, + "required": [ + "state" + ] + } + ] + }, + { + "type": "object", + "properties": { + "builder": { + "type": "object" + }, + "target": { + "type": "object" + }, + "id": { + "type": "number" + } + }, + "required": [ + "builder", + "id" + ] + } + ] +} diff --git a/packages/angular_devkit/architect/src/schedule-by-name.ts b/packages/angular_devkit/architect/src/schedule-by-name.ts new file mode 100644 index 000000000000..0022c9b53207 --- /dev/null +++ b/packages/angular_devkit/architect/src/schedule-by-name.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright Google Inc. 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 { experimental, json, logging } from '@angular-devkit/core'; +import { Subscription } from 'rxjs'; +import { first, ignoreElements, map, shareReplay } from 'rxjs/operators'; +import { + BuilderInfo, + BuilderInput, + BuilderOutput, BuilderProgressReport, + BuilderRun, + Target, + targetStringFromTarget, +} from './api'; + +const progressSchema = require('./progress-schema.json'); + + +let _uniqueId = 0; + +export async function scheduleByName( + name: string, + buildOptions: json.JsonObject, + options: { + target?: Target, + scheduler: experimental.jobs.Scheduler, + logger: logging.LoggerApi, + workspaceRoot: string | Promise, + currentDirectory: string | Promise, + }, +): Promise { + const childLoggerName = options.target ? `{${targetStringFromTarget(options.target)}}` : name; + const logger = options.logger.createChild(childLoggerName); + const job = options.scheduler.schedule<{}, BuilderInput, BuilderOutput>(name, {}); + let stateSubscription: Subscription; + + const workspaceRoot = await options.workspaceRoot; + const currentDirectory = await options.currentDirectory; + + const description = await job.description.toPromise(); + const info = description.info as BuilderInfo; + const id = ++_uniqueId; + + const message = { + id, + currentDirectory: workspaceRoot, + workspaceRoot: currentDirectory, + info: info, + options: buildOptions, + ...(options.target ? { target: options.target } : {}), + }; + + // Wait for the job to be ready. + if (job.state !== experimental.jobs.JobState.Started) { + stateSubscription = job.outboundBus.subscribe(event => { + if (event.kind === experimental.jobs.JobOutboundMessageKind.Start) { + job.input.next(message); + } + }); + } else { + job.input.next(message); + } + + const logChannelSub = job.getChannel('log').subscribe(entry => { + logger.next(entry); + }); + + const s = job.outboundBus.subscribe( + undefined, + undefined, + () => { + s.unsubscribe(); + logChannelSub.unsubscribe(); + if (stateSubscription) { + stateSubscription.unsubscribe(); + } + }, + ); + const output = job.output.pipe( + map(output => ({ + ...output, + ...options.target ? { target: options.target } : 0, + info, + } as BuilderOutput)), + ); + + return { + id, + info, + result: output.pipe(first()).toPromise(), + output, + progress: job.getChannel('progress', progressSchema).pipe( + shareReplay(1), + ), + stop() { + job.stop(); + + return output.pipe(ignoreElements()).toPromise(); + }, + }; +} + +export async function scheduleByTarget( + target: Target, + overrides: json.JsonObject, + options: { + scheduler: experimental.jobs.Scheduler, + logger: logging.LoggerApi, + workspaceRoot: string | Promise, + currentDirectory: string | Promise, + }, +): Promise { + return scheduleByName(`{${targetStringFromTarget(target)}}`, overrides, { + ...options, + target, + logger: options.logger, + }); +} diff --git a/packages/angular_devkit/architect/testing/index2.ts b/packages/angular_devkit/architect/testing/index2.ts new file mode 100644 index 000000000000..783d596d1a54 --- /dev/null +++ b/packages/angular_devkit/architect/testing/index2.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google Inc. 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 './testing-architect-host'; diff --git a/packages/angular_devkit/architect/testing/testing-architect-host.ts b/packages/angular_devkit/architect/testing/testing-architect-host.ts new file mode 100644 index 000000000000..1e4824aac36f --- /dev/null +++ b/packages/angular_devkit/architect/testing/testing-architect-host.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright Google Inc. 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 { json } from '@angular-devkit/core'; +import { BuilderInfo, Target, targetStringFromTarget } from '../src/index2'; +import { ArchitectHost, Builder } from '../src/internal'; + +export class TestingArchitectHost implements ArchitectHost { + private _builderImportMap = new Map(); + private _builderMap = new Map(); + private _targetMap = new Map(); + + /** + * Can provide a backend host, in case of integration tests. + * @param workspaceRoot The workspace root to use. + * @param currentDirectory The current directory to use. + * @param _backendHost A host to defer calls that aren't resolved here. + */ + constructor( + public workspaceRoot = '', + public currentDirectory = workspaceRoot, + private _backendHost: ArchitectHost | null = null, + ) {} + + addBuilder( + builderName: string, + builder: Builder, + description = 'Testing only builder.', + optionSchema: json.schema.JsonSchema = { type: 'object' }, + ) { + this._builderImportMap.set(builderName, builder); + this._builderMap.set(builderName, { builderName, description, optionSchema }); + } + async addBuilderFromPackage(packageName: string) { + const packageJson = await import(packageName + '/package.json'); + if (!('builders' in packageJson)) { + throw new Error('Invalid package.json, builders key not found.'); + } + + const builderJsonPath = packageName + '/' + packageJson['builders']; + const builderJson = await import(builderJsonPath); + const builders = builderJson['builders']; + if (!builders) { + throw new Error('Invalid builders.json, builders key not found.'); + } + + for (const builderName of Object.keys(builders)) { + const b = builders[builderName]; + // TODO: remove this check as v1 is not supported anymore. + if (!b.implementation) { continue; } + const handler = await import(builderJsonPath + '/../' + b.implementation); + const optionsSchema = await import(builderJsonPath + '/../' + b.schema); + this.addBuilder(builderName, handler, b.description, optionsSchema); + } + } + addTarget(target: Target, builderName: string, options: json.JsonObject = {}) { + this._targetMap.set(targetStringFromTarget(target), { builderName, options }); + } + + async getBuilderNameForTarget(target: Target): Promise { + const name = targetStringFromTarget(target); + const maybeTarget = this._targetMap.get(name); + if (!maybeTarget) { + return this._backendHost && this._backendHost.getBuilderNameForTarget(target); + } + + return maybeTarget.builderName; + } + + /** + * Resolve a builder. This needs to return a string which will be used in a dynamic `import()` + * clause. This should throw if no builder can be found. The dynamic import will throw if + * it is unsupported. + * @param builderName The name of the builder to be used. + * @returns All the info needed for the builder itself. + */ + async resolveBuilder(builderName: string): Promise { + return this._builderMap.get(builderName) + || (this._backendHost && this._backendHost.resolveBuilder(builderName)); + } + + async getCurrentDirectory(): Promise { + return this.currentDirectory; + } + async getWorkspaceRoot(): Promise { + return this.workspaceRoot; + } + + async getOptionsForTarget(target: Target): Promise { + const name = targetStringFromTarget(target); + const maybeTarget = this._targetMap.get(name); + if (!maybeTarget) { + return this._backendHost && this._backendHost.getOptionsForTarget(target); + } + + return maybeTarget.options; + } + + async loadBuilder(info: BuilderInfo): Promise { + return this._builderImportMap.get(info.builderName) + || (this._backendHost && this._backendHost.loadBuilder(info)); + } + +} diff --git a/packages/angular_devkit/architect_cli/BUILD b/packages/angular_devkit/architect_cli/BUILD index 225116f0cf1d..d7d12f885ea4 100644 --- a/packages/angular_devkit/architect_cli/BUILD +++ b/packages/angular_devkit/architect_cli/BUILD @@ -12,15 +12,17 @@ ts_library( name = "architect_cli", srcs = [ "bin/architect.ts", - ], + ] + glob(["src/**/*.ts"]), module_name = "@angular-devkit/architect-cli", deps = [ "//packages/angular_devkit/architect", + "//packages/angular_devkit/architect:node", "//packages/angular_devkit/core", "//packages/angular_devkit/core:node", "@rxjs", "@rxjs//operators", "@npm//@types/node", "@npm//@types/minimist", + "@npm//@types/progress", ], ) diff --git a/packages/angular_devkit/architect_cli/bin/architect.ts b/packages/angular_devkit/architect_cli/bin/architect.ts index 36aa58b7f53e..4af723751d42 100644 --- a/packages/angular_devkit/architect_cli/bin/architect.ts +++ b/packages/angular_devkit/architect_cli/bin/architect.ts @@ -6,18 +6,22 @@ * 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 'symbol-observable'; -// symbol polyfill must go first -// tslint:disable-next-line:ordered-imports import-groups -import { Architect } from '@angular-devkit/architect'; -import { dirname, experimental, normalize, tags } from '@angular-devkit/core'; +import { index2 } from '@angular-devkit/architect'; +import { WorkspaceNodeModulesArchitectHost } from '@angular-devkit/architect/node'; +import { + dirname, + experimental, + json, + logging, + normalize, + schema, + tags, terminal, +} from '@angular-devkit/core'; import { NodeJsSyncHost, createConsoleLogger } from '@angular-devkit/core/node'; import { existsSync, readFileSync } from 'fs'; import * as minimist from 'minimist'; import * as path from 'path'; -import { throwError } from 'rxjs'; -import { concatMap } from 'rxjs/operators'; +import { MultiProgressBar } from '../src/progress'; function findUp(names: string | string[], from: string) { @@ -44,7 +48,7 @@ function findUp(names: string | string[], from: string) { /** * Show usage of the CLI tool, and exit the process. */ -function usage(exitCode = 0): never { +function usage(logger: logging.Logger, exitCode = 0): never { logger.info(tags.stripIndent` architect [project][:target][:configuration] [options, ...] @@ -63,86 +67,165 @@ function usage(exitCode = 0): never { throw 0; // The node typing sometimes don't have a never type for process.exit(). } -/** Parse the command line. */ -const argv = minimist(process.argv.slice(2), { boolean: ['help'] }); +function _targetStringFromTarget({project, target, configuration}: index2.Target) { + return `${project}:${target}${configuration !== undefined ? ':' + configuration : ''}`; +} -/** Create the DevKit Logger used through the CLI. */ -const logger = createConsoleLogger(argv['verbose']); -// Check the target. -const targetStr = argv._.shift(); -if (!targetStr && argv.help) { - // Show architect usage if there's no target. - usage(); +interface BarInfo { + status?: string; + builder: index2.BuilderInfo; + target?: index2.Target; } -// Split a target into its parts. -let project: string, targetName: string, configuration: string; -if (targetStr) { - [project, targetName, configuration] = targetStr.split(':'); -} -// Load workspace configuration file. -const currentPath = process.cwd(); -const configFileNames = [ - 'angular.json', - '.angular.json', - 'workspace.json', - '.workspace.json', -]; - -const configFilePath = findUp(configFileNames, currentPath); - -if (!configFilePath) { - logger.fatal(`Workspace configuration file (${configFileNames.join(', ')}) cannot be found in ` - + `'${currentPath}' or in parent directories.`); - process.exit(3); - throw 3; // TypeScript doesn't know that process.exit() never returns. +async function _executeTarget( + parentLogger: logging.Logger, + workspace: experimental.workspace.Workspace, + root: string, + argv: minimist.ParsedArgs, + registry: json.schema.SchemaRegistry, +) { + const architectHost = new WorkspaceNodeModulesArchitectHost(workspace, root); + const architect = new index2.Architect(architectHost, registry); + + // Split a target into its parts. + const targetStr = argv._.shift() || ''; + const [project, target, configuration] = targetStr.split(':'); + const targetSpec = { project, target, configuration }; + + delete argv['help']; + delete argv['_']; + + const logger = new logging.Logger('jobs'); + const logs: logging.LogEntry[] = []; + logger.subscribe(entry => logs.push({ ...entry, message: `${entry.name}: ` + entry.message })); + + const run = await architect.scheduleTarget(targetSpec, argv, { logger }); + const bars = new MultiProgressBar(':name :bar (:current/:total) :status'); + + run.progress.subscribe( + update => { + const data = bars.get(update.id) || { + id: update.id, + builder: update.builder, + target: update.target, + status: update.status || '', + name: ((update.target ? _targetStringFromTarget(update.target) : update.builder.name) + + ' '.repeat(80) + ).substr(0, 40), + }; + + if (update.status !== undefined) { + data.status = update.status; + } + + switch (update.state) { + case index2.BuilderProgressState.Error: + data.status = 'Error: ' + update.error; + bars.update(update.id, data); + break; + + case index2.BuilderProgressState.Stopped: + data.status = 'Done.'; + bars.complete(update.id); + bars.update(update.id, data, update.total, update.total); + break; + + case index2.BuilderProgressState.Waiting: + bars.update(update.id, data); + break; + + case index2.BuilderProgressState.Running: + bars.update(update.id, data, update.current, update.total); + break; + } + + bars.render(); + }, + ); + + // Wait for full completion of the builder. + try { + const result = await run.result; + + if (result.success) { + parentLogger.info(terminal.green('SUCCESS')); + } else { + parentLogger.info(terminal.yellow('FAILURE')); + } + + parentLogger.info('\nLogs:'); + logs.forEach(l => parentLogger.next(l)); + + await run.stop(); + bars.terminate(); + + return result.success ? 0 : 1; + } catch (err) { + parentLogger.info(terminal.red('ERROR')); + parentLogger.info('\nLogs:'); + logs.forEach(l => parentLogger.next(l)); + + parentLogger.fatal('Exception:'); + parentLogger.fatal(err.stack); + + return 2; + } } -const root = dirname(normalize(configFilePath)); -const configContent = readFileSync(configFilePath, 'utf-8'); -const workspaceJson = JSON.parse(configContent); -const host = new NodeJsSyncHost(); -const workspace = new experimental.workspace.Workspace(root, host); +async function main(args: string[]): Promise { + /** Parse the command line. */ + const argv = minimist(args, { boolean: ['help'] }); -let lastBuildEvent = { success: true }; + /** Create the DevKit Logger used through the CLI. */ + const logger = createConsoleLogger(argv['verbose']); -workspace.loadWorkspaceFromJson(workspaceJson).pipe( - concatMap(ws => new Architect(ws).loadArchitect()), - concatMap(architect => { + // Check the target. + const targetStr = argv._[0] || ''; + if (!targetStr || argv.help) { + // Show architect usage if there's no target. + usage(logger); + } - const overrides = { ...argv }; - delete overrides['help']; - delete overrides['_']; + // Load workspace configuration file. + const currentPath = process.cwd(); + const configFileNames = [ + 'angular.json', + '.angular.json', + 'workspace.json', + '.workspace.json', + ]; - const targetSpec = { - project, - target: targetName, - configuration, - overrides, - }; + const configFilePath = findUp(configFileNames, currentPath); - // TODO: better logging of what's happening. - if (argv.help) { - // TODO: add target help - return throwError('Target help NYI.'); - // architect.help(targetOptions, logger); - } else { - const builderConfig = architect.getBuilderConfiguration(targetSpec); + if (!configFilePath) { + logger.fatal(`Workspace configuration file (${configFileNames.join(', ')}) cannot be found in ` + + `'${currentPath}' or in parent directories.`); - return architect.run(builderConfig, { logger }); - } - }), -).subscribe({ - next: (buildEvent => lastBuildEvent = buildEvent), - complete: () => process.exit(lastBuildEvent.success ? 0 : 1), - error: (err: Error) => { - logger.fatal(err.message); - if (err.stack) { - logger.fatal(err.stack); - } - process.exit(1); - }, -}); + return 3; + } + + const root = dirname(normalize(configFilePath)); + const configContent = readFileSync(configFilePath, 'utf-8'); + const workspaceJson = JSON.parse(configContent); + + const registry = new schema.CoreSchemaRegistry(); + registry.addPostTransform(schema.transforms.addUndefinedDefaults); + + const host = new NodeJsSyncHost(); + const workspace = new experimental.workspace.Workspace(root, host); + + await workspace.loadWorkspaceFromJson(workspaceJson).toPromise(); + + return await _executeTarget(logger, workspace, root, argv, registry); +} + +main(process.argv.slice(2)) + .then(code => { + process.exit(code); + }, err => { + console.error('Error: ' + err.stack || err.message || err); + process.exit(-1); + }); diff --git a/packages/angular_devkit/architect_cli/package.json b/packages/angular_devkit/architect_cli/package.json index 183dee060281..c90812606347 100644 --- a/packages/angular_devkit/architect_cli/package.json +++ b/packages/angular_devkit/architect_cli/package.json @@ -12,10 +12,13 @@ "tooling" ], "dependencies": { - "@angular-devkit/core": "0.0.0", "@angular-devkit/architect": "0.0.0", + "@angular-devkit/core": "0.0.0", + "@types/progress": "^2.0.3", + "ascii-progress": "^1.0.5", "minimist": "1.2.0", - "symbol-observable": "1.2.0", - "rxjs": "6.3.3" + "progress": "^2.0.3", + "rxjs": "6.3.3", + "symbol-observable": "1.2.0" } } diff --git a/packages/angular_devkit/architect_cli/src/progress.ts b/packages/angular_devkit/architect_cli/src/progress.ts new file mode 100644 index 000000000000..d85763ceb56f --- /dev/null +++ b/packages/angular_devkit/architect_cli/src/progress.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright Google Inc. 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 { terminal } from '@angular-devkit/core'; +import * as ProgressBar from 'progress'; +import * as readline from 'readline'; + +export class MultiProgressBar { + private _bars = new Map(); + + constructor(private _status: string, private _stream = process.stderr) {} + private _add(id: Key, data: T): { data: T, bar: ProgressBar } { + const width = Math.min(80, terminal.getCapabilities(this._stream).columns || 80); + const value = { + data, + bar: new ProgressBar(this._status, { + clear: true, + total: 1, + width: width, + complete: '#', + incomplete: '.', + stream: this._stream, + }), + }; + this._bars.set(id, value); + readline.moveCursor(this._stream, 0, 1); + + return value; + } + + complete(id: Key) { + const maybeBar = this._bars.get(id); + if (maybeBar) { + maybeBar.bar.complete = true; + } + } + + add(id: Key, data: T) { + this._add(id, data); + } + + get(key: Key): T | undefined { + const maybeValue = this._bars.get(key); + + return maybeValue && maybeValue.data; + } + has(key: Key) { + return this._bars.has(key); + } + update(key: Key, data: T, current?: number, total?: number) { + let maybeBar = this._bars.get(key); + + if (!maybeBar) { + maybeBar = this._add(key, data); + } + + maybeBar.data = data; + if (total !== undefined) { + maybeBar.bar.total = total; + } + if (current !== undefined) { + maybeBar.bar.curr = Math.max(0, Math.min(current, maybeBar.bar.total)); + } + } + + render(max = Infinity, sort?: (a: T, b: T) => number) { + const stream = this._stream; + + readline.moveCursor(stream, 0, -this._bars.size); + readline.cursorTo(stream, 0); + + let values: Iterable<{ data: T, bar: ProgressBar }> = this._bars.values(); + if (sort) { + values = [...values].sort((a, b) => sort(a.data, b.data)); + } + + for (const { data, bar } of values) { + if (max-- == 0) { + return; + } + + bar.render(data); + readline.moveCursor(stream, 0, 1); + readline.cursorTo(stream, 0); + } + } + + terminate() { + for (const { bar } of this._bars.values()) { + bar.terminate(); + } + this._bars.clear(); + } +} diff --git a/packages/angular_devkit/build_angular/builders.json b/packages/angular_devkit/build_angular/builders.json index bf8b1a6ca284..6d299c5751b3 100644 --- a/packages/angular_devkit/build_angular/builders.json +++ b/packages/angular_devkit/build_angular/builders.json @@ -32,6 +32,7 @@ "description": "Run protractor over a dev server." }, "tslint": { + "implementation": "./src/tslint/index2", "class": "./src/tslint", "schema": "./src/tslint/schema.json", "description": "Run tslint over a TS project." diff --git a/packages/angular_devkit/build_angular/src/tslint/index2.ts b/packages/angular_devkit/build_angular/src/tslint/index2.ts new file mode 100644 index 000000000000..c301628db64e --- /dev/null +++ b/packages/angular_devkit/build_angular/src/tslint/index2.ts @@ -0,0 +1,234 @@ +/** + * @license + * Copyright Google Inc. 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 { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect/src/index2'; +import { json } from '@angular-devkit/core'; +import { readFileSync } from 'fs'; +import * as glob from 'glob'; +import { Minimatch } from 'minimatch'; +import * as path from 'path'; +import * as tslint from 'tslint'; // tslint:disable-line:no-implicit-dependencies +import * as ts from 'typescript'; // tslint:disable-line:no-implicit-dependencies +import { stripBom } from '../angular-cli-files/utilities/strip-bom'; +import { Schema as RealTslintBuilderOptions } from './schema'; + + +type TslintBuilderOptions = RealTslintBuilderOptions & json.JsonObject; + + +async function _loadTslint() { + let tslint; + try { + tslint = await import('tslint'); // tslint:disable-line:no-implicit-dependencies + } catch { + throw new Error('Unable to find TSLint. Ensure TSLint is installed.'); + } + + const version = tslint.Linter.VERSION && tslint.Linter.VERSION.split('.'); + if (!version || version.length < 2 || Number(version[0]) < 5 || Number(version[1]) < 5) { + throw new Error('TSLint must be version 5.5 or higher.'); + } + + return tslint; +} + + +async function _run(config: TslintBuilderOptions, context: BuilderContext): Promise { + const systemRoot = context.workspaceRoot; + process.chdir(context.currentDirectory); + const options = config; + const projectName = context.target && context.target.project || ''; + + // Print formatter output only for non human-readable formats. + const printInfo = ['prose', 'verbose', 'stylish'].includes(options.format || '') + && !options.silent; + + context.reportStatus(`Linting ${JSON.stringify(projectName)}...`); + if (printInfo) { + context.logger.info(`Linting ${JSON.stringify(projectName)}...`); + } + + if (!options.tsConfig && options.typeCheck) { + throw new Error('A "project" must be specified to enable type checking.'); + } + + const projectTslint = await _loadTslint(); + const tslintConfigPath = options.tslintConfig + ? path.resolve(systemRoot, options.tslintConfig) + : null; + const Linter = projectTslint.Linter; + + let result: undefined | tslint.LintResult = undefined; + if (options.tsConfig) { + const tsConfigs = Array.isArray(options.tsConfig) ? options.tsConfig : [options.tsConfig]; + context.reportProgress(0, tsConfigs.length); + const allPrograms = tsConfigs.map(tsConfig => { + return Linter.createProgram(path.resolve(systemRoot, tsConfig)); + }); + + let i = 0; + for (const program of allPrograms) { + const partial + = await _lint(projectTslint, systemRoot, tslintConfigPath, options, program, allPrograms); + if (result === undefined) { + result = partial; + } else { + result.failures = result.failures + .filter(curr => { + return !partial.failures.some(prev => curr.equals(prev)); + }) + .concat(partial.failures); + + // we are not doing much with 'errorCount' and 'warningCount' + // apart from checking if they are greater than 0 thus no need to dedupe these. + result.errorCount += partial.errorCount; + result.warningCount += partial.warningCount; + + if (partial.fixes) { + result.fixes = result.fixes ? result.fixes.concat(partial.fixes) : partial.fixes; + } + } + + context.reportProgress(++i, allPrograms.length); + } + } else { + result = await _lint(projectTslint, systemRoot, tslintConfigPath, options); + } + + if (result == undefined) { + throw new Error('Invalid lint configuration. Nothing to lint.'); + } + + if (!options.silent) { + const Formatter = projectTslint.findFormatter(options.format || ''); + if (!Formatter) { + throw new Error(`Invalid lint format "${options.format}".`); + } + const formatter = new Formatter(); + + const output = formatter.format(result.failures, result.fixes); + if (output.trim()) { + context.logger.info(output); + } + } + + if (result.warningCount > 0 && printInfo) { + context.logger.warn('Lint warnings found in the listed files.'); + } + + if (result.errorCount > 0 && printInfo) { + context.logger.error('Lint errors found in the listed files.'); + } + + if (result.warningCount === 0 && result.errorCount === 0 && printInfo) { + context.logger.info('All files pass linting.'); + } + + return { + success: options.force || result.errorCount === 0, + }; +} + + +export default createBuilder(_run); + + +async function _lint( + projectTslint: typeof tslint, + systemRoot: string, + tslintConfigPath: string | null, + options: TslintBuilderOptions, + program?: ts.Program, + allPrograms?: ts.Program[], +) { + const Linter = projectTslint.Linter; + const Configuration = projectTslint.Configuration; + + const files = getFilesToLint(systemRoot, options, Linter, program); + const lintOptions = { + fix: !!options.fix, + formatter: options.format, + }; + + const linter = new Linter(lintOptions, program); + + let lastDirectory: string | undefined = undefined; + let configLoad; + for (const file of files) { + if (program && allPrograms) { + // If it cannot be found in ANY program, then this is an error. + if (allPrograms.every(p => p.getSourceFile(file) === undefined)) { + throw new Error( + `File ${JSON.stringify(file)} is not part of a TypeScript project '${options.tsConfig}'.`, + ); + } else if (program.getSourceFile(file) === undefined) { + // The file exists in some other programs. We will lint it later (or earlier) in the loop. + continue; + } + } + + const contents = getFileContents(file); + + // Only check for a new tslint config if the path changes. + const currentDirectory = path.dirname(file); + if (currentDirectory !== lastDirectory) { + configLoad = Configuration.findConfiguration(tslintConfigPath, file); + lastDirectory = currentDirectory; + } + + if (configLoad) { + // Give some breathing space to other promises that might be waiting. + await Promise.resolve(); + linter.lint(file, contents, configLoad.results); + } + } + + return linter.getResult(); +} + +function getFilesToLint( + root: string, + options: TslintBuilderOptions, + linter: typeof tslint.Linter, + program?: ts.Program, +): string[] { + const ignore = options.exclude; + const files = options.files || []; + + if (files.length > 0) { + return files + .map(file => glob.sync(file, { cwd: root, ignore, nodir: true })) + .reduce((prev, curr) => prev.concat(curr), []) + .map(file => path.join(root, file)); + } + + if (!program) { + return []; + } + + let programFiles = linter.getFileNames(program); + + if (ignore && ignore.length > 0) { + // normalize to support ./ paths + const ignoreMatchers = ignore + .map(pattern => new Minimatch(path.normalize(pattern), { dot: true })); + + programFiles = programFiles + .filter(file => !ignoreMatchers.some(matcher => matcher.match(path.relative(root, file)))); + } + + return programFiles; +} + +function getFileContents(file: string): string { + // NOTE: The tslint CLI checks for and excludes MPEG transport streams; this does not. + try { + return stripBom(readFileSync(file, 'utf-8')); + } catch { + throw new Error(`Could not read file '${file}'.`); + } +} diff --git a/packages/angular_devkit/build_angular/test/tslint/works2_spec_large.ts b/packages/angular_devkit/build_angular/test/tslint/works2_spec_large.ts new file mode 100644 index 000000000000..4fcb9d15916f --- /dev/null +++ b/packages/angular_devkit/build_angular/test/tslint/works2_spec_large.ts @@ -0,0 +1,278 @@ +/** + * @license + * Copyright Google Inc. 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 { WorkspaceNodeModulesArchitectHost } from '@angular-devkit/architect/node'; +import { Architect, Target } from '@angular-devkit/architect/src/index2'; +import { TestingArchitectHost } from '@angular-devkit/architect/testing/index2'; +import { experimental, join, logging, normalize, schema } from '@angular-devkit/core'; +import { NodeJsSyncHost } from '@angular-devkit/core/node'; +import * as fs from 'fs'; +import * as path from 'path'; + +const devkitRoot = normalize((global as any)._DevKitRoot); // tslint:disable-line:no-any +const workspaceRoot = join(devkitRoot, 'tests/angular_devkit/build_angular/hello-world-app/'); +const lintTarget: Target = { project: 'app', target: 'lint' }; + +// tslint:disable-next-line:no-big-function +describe('Tslint Target', () => { + // const filesWithErrors = { 'src/foo.ts': 'const foo = "";\n' }; + let testArchitectHost: TestingArchitectHost; + let architect: Architect; + + beforeEach(async () => { + const vfHost = new NodeJsSyncHost(); + const configContent = fs.readFileSync(path.join(workspaceRoot, 'angular.json'), 'utf-8'); + const workspaceJson = JSON.parse(configContent); + + const registry = new schema.CoreSchemaRegistry(); + registry.addPostTransform(schema.transforms.addUndefinedDefaults); + + const workspace = new experimental.workspace.Workspace(workspaceRoot, vfHost); + await workspace.loadWorkspaceFromJson(workspaceJson).toPromise(); + + testArchitectHost = new TestingArchitectHost( + workspaceRoot, workspaceRoot, + new WorkspaceNodeModulesArchitectHost(workspace, workspaceRoot), + ); + architect = new Architect(testArchitectHost, registry); + }); + + it('works', async () => { + const run = await architect.scheduleTarget({ project: 'app', target: 'lint' }); + const output = await run.result; + expect(output.success).toBe(true); + await run.stop(); + }, 30000); + + it(`should show project name as status and in the logs`, async () => { + // Check logs. + const logger = new logging.Logger('lint-info'); + const allLogs: string[] = []; + logger.subscribe(entry => allLogs.push(entry.message)); + + const run = await architect.scheduleTarget(lintTarget, {}, { logger }); + + // Check status updates. + const allStatus: string[] = []; + run.progress.subscribe(progress => { + if (progress.status !== undefined) { + allStatus.push(progress.status); + } + }); + + const output = await run.result; + expect(output.success).toBe(true); + expect(allStatus).toContain(jasmine.stringMatching(/linting.*"app".*/i)); + expect(allLogs).toContain(jasmine.stringMatching(/linting.*"app".*/i)); + await run.stop(); + }); + + it(`should not show project name when formatter is non human readable`, async () => { + const overrides = { + format: 'checkstyle', + }; + + // Check logs. + const logger = new logging.Logger('lint-info'); + const allLogs: string[] = []; + logger.subscribe(entry => allLogs.push(entry.message)); + + const run = await architect.scheduleTarget(lintTarget, overrides, { logger }); + + // Check status updates. + const allStatus: string[] = []; + run.progress.subscribe(progress => { + if (progress.status !== undefined) { + allStatus.push(progress.status); + } + }); + + const output = await run.result; + expect(output.success).toBe(true); + expect(allStatus).toContain(jasmine.stringMatching(/linting.*"app".*/i)); + expect(allLogs).not.toContain(jasmine.stringMatching(/linting.*"app".*/i)); + await run.stop(); + }, 30000); + + // it('should report lint error once', (done) => { + // host.writeMultipleFiles({'src/app/app.component.ts': 'const foo = "";\n' }); + // const logger = new TestLogger('lint-error'); + // + // runTargetSpec(host, tslintTargetSpec, undefined, DefaultTimeout, logger).pipe( + // tap((buildEvent) => expect(buildEvent.success).toBe(false)), + // tap(() => { + // // this is to make sure there are no duplicates + // expect(logger.includes(`" should be \'\nERROR`)).toBe(false); + // + // expect(logger.includes(`" should be '`)).toBe(true); + // expect(logger.includes(`Lint errors found in the listed files`)).toBe(true); + // }), + // ).toPromise().then(done, done.fail); + // }, 30000); + // + // it('supports exclude with glob', (done) => { + // host.writeMultipleFiles(filesWithErrors); + // const overrides: Partial = { exclude: ['**/foo.ts'] }; + // + // runTargetSpec(host, tslintTargetSpec, overrides).pipe( + // tap((buildEvent) => expect(buildEvent.success).toBe(true)), + // ).toPromise().then(done, done.fail); + // }, 30000); + // + // it('supports exclude with relative paths', (done) => { + // host.writeMultipleFiles(filesWithErrors); + // const overrides: Partial = { exclude: ['src/foo.ts'] }; + // + // runTargetSpec(host, tslintTargetSpec, overrides).pipe( + // tap((buildEvent) => expect(buildEvent.success).toBe(true)), + // ).toPromise().then(done, done.fail); + // }, 30000); + // + // it(`supports exclude with paths starting with './'`, (done) => { + // host.writeMultipleFiles(filesWithErrors); + // const overrides: Partial = { exclude: ['./src/foo.ts'] }; + // + // runTargetSpec(host, tslintTargetSpec, overrides).pipe( + // tap((buildEvent) => expect(buildEvent.success).toBe(true)), + // ).toPromise().then(done, done.fail); + // }, 30000); + // + // it('supports fix', (done) => { + // host.writeMultipleFiles(filesWithErrors); + // const overrides: Partial = { fix: true }; + // + // runTargetSpec(host, tslintTargetSpec, overrides).pipe( + // tap((buildEvent) => expect(buildEvent.success).toBe(true)), + // tap(() => { + // const fileName = normalize('src/foo.ts'); + // const content = virtualFs.fileBufferToString(host.scopedSync().read(fileName)); + // expect(content).toContain(`const foo = '';`); + // }), + // ).toPromise().then(done, done.fail); + // }, 30000); + // + // it('supports force', (done) => { + // host.writeMultipleFiles(filesWithErrors); + // const logger = new TestLogger('lint-force'); + // const overrides: Partial = { force: true }; + // + // runTargetSpec(host, tslintTargetSpec, overrides, DefaultTimeout, logger).pipe( + // tap((buildEvent) => expect(buildEvent.success).toBe(true)), + // tap(() => { + // expect(logger.includes(`" should be '`)).toBe(true); + // expect(logger.includes(`Lint errors found in the listed files`)).toBe(true); + // }), + // ).toPromise().then(done, done.fail); + // }, 30000); + // + // it('supports format', (done) => { + // host.writeMultipleFiles(filesWithErrors); + // const logger = new TestLogger('lint-format'); + // const overrides: Partial = { format: 'stylish' }; + // + // runTargetSpec(host, tslintTargetSpec, overrides, DefaultTimeout, logger).pipe( + // tap((buildEvent) => expect(buildEvent.success).toBe(false)), + // tap(() => { + // expect(logger.includes(`quotemark`)).toBe(true); + // }), + // ).toPromise().then(done, done.fail); + // }, 30000); + // + // it('supports finding configs', (done) => { + // host.writeMultipleFiles({ + // 'src/app/foo/foo.ts': `const foo = '';\n`, + // 'src/app/foo/tslint.json': ` + // { + // "rules": { + // "quotemark": [ + // true, + // "double" + // ] + // } + // } + // `, + // }); + // const overrides: Partial = { tslintConfig: undefined }; + // + // runTargetSpec(host, tslintTargetSpec, overrides).pipe( + // tap((buildEvent) => expect(buildEvent.success).toBe(false)), + // ).toPromise().then(done, done.fail); + // }, 30000); + // + // it('supports overriding configs', (done) => { + // host.writeMultipleFiles({ + // 'src/app/foo/foo.ts': `const foo = '';\n`, + // 'src/app/foo/tslint.json': ` + // { + // "rules": { + // "quotemark": [ + // true, + // "double" + // ] + // } + // } + // `, + // }); + // const overrides: Partial = { tslintConfig: 'tslint.json' }; + // + // runTargetSpec(host, tslintTargetSpec, overrides).pipe( + // tap((buildEvent) => expect(buildEvent.success).toBe(true)), + // ).toPromise().then(done, done.fail); + // }, 30000); + // + // it('supports using files with no project', (done) => { + // const overrides: Partial = { + // tsConfig: undefined, + // files: ['src/app/**/*.ts'], + // }; + // + // runTargetSpec(host, tslintTargetSpec, overrides).pipe( + // tap((buildEvent) => expect(buildEvent.success).toBe(true)), + // ).toPromise().then(done, done.fail); + // }, 30000); + // + // it('supports using one project as a string', (done) => { + // const overrides: Partial = { + // tsConfig: 'src/tsconfig.app.json', + // }; + // + // runTargetSpec(host, tslintTargetSpec, overrides).pipe( + // tap((buildEvent) => expect(buildEvent.success).toBe(true)), + // ).toPromise().then(done, done.fail); + // }, 30000); + // + // it('supports using one project as an array', (done) => { + // const overrides: Partial = { + // tsConfig: ['src/tsconfig.app.json'], + // }; + // + // runTargetSpec(host, tslintTargetSpec, overrides).pipe( + // tap((buildEvent) => expect(buildEvent.success).toBe(true)), + // ).toPromise().then(done, done.fail); + // }, 30000); + // + // it('supports using two projects', (done) => { + // const overrides: Partial = { + // tsConfig: ['src/tsconfig.app.json', 'src/tsconfig.spec.json'], + // }; + // + // runTargetSpec(host, tslintTargetSpec, overrides).pipe( + // tap((buildEvent) => expect(buildEvent.success).toBe(true)), + // ).toPromise().then(done, done.fail); + // }, 30000); + // + // it('errors when type checking is used without a project', (done) => { + // const overrides: Partial = { + // tsConfig: undefined, + // typeCheck: true, + // }; + // + // runTargetSpec(host, tslintTargetSpec, overrides) + // .subscribe(undefined, () => done(), done.fail); + // }, 30000); +}); diff --git a/packages/angular_devkit/core/src/experimental/jobs/README.md b/packages/angular_devkit/core/src/experimental/jobs/README.md index 3989c30d0d38..e1640a5382b9 100644 --- a/packages/angular_devkit/core/src/experimental/jobs/README.md +++ b/packages/angular_devkit/core/src/experimental/jobs/README.md @@ -19,10 +19,6 @@ The I/O model is like that of an executable, where the argument corresponds to a command line, the input channel to STDIN, the output channel to STDOUT, and the channels would be additional output streams. -In addition, a `Job` has a logging channel that can be used to log messages to the user. The -code that schedules the job must listen for or forward these messages. You can think of those -messages as STDERR. - ## LifeCycle A `Job` goes through multiple LifeCycle messages before its completion; 1. `JobState.Queued`. The job was queued and is waiting. This is the default state from the @@ -162,8 +158,7 @@ export const add = jobs.createJobHandler( ``` You can also return a Promise or an Observable, as jobs are asynchronous. This helper will set -start and end messages appropriately, and will pass in a logger. It will also manage channels -automatically (see below). +start and end messages appropriately. It will also manage channels automatically (see below). A more complex job can be declared like this: @@ -246,7 +241,6 @@ member of the `JobOutboundMessage` interface dictates what kind of message it dependent jobs. `createJobHandler()` automatically send this message. 1. `JobOutboundMessageKind.Pong`. The job should answer a `JobInboundMessageKind.Ping` message with this. Automatically done by `createJobHandler()`. -1. `JobOutboundMessageKind.Log`. A logging message (side effect that should be shown to the user). 1. `JobOutboundMessageKind.Output`. An `Output` has been generated by the job. 1. `JobOutboundMessageKind.ChannelMessage`, `JobOutboundMessageKind.ChannelError` and `JobOutboundMessageKind.ChannelComplete` are used for output channels. These correspond to the diff --git a/packages/angular_devkit/core/src/experimental/jobs/api.ts b/packages/angular_devkit/core/src/experimental/jobs/api.ts index 227c7e2eb51d..5d109bf0904b 100644 --- a/packages/angular_devkit/core/src/experimental/jobs/api.ts +++ b/packages/angular_devkit/core/src/experimental/jobs/api.ts @@ -7,7 +7,6 @@ */ import { Observable, Observer } from 'rxjs'; import { JsonObject, JsonValue, schema } from '../../json/index'; -import { LogEntry, LoggerApi } from '../../logger/index'; import { DeepReadonly } from '../../utils/index'; /** @@ -135,7 +134,6 @@ export enum JobOutboundMessageKind { Pong = 'p', // Feedback messages. - Log = 'l', Output = 'o', // Channel specific messages. @@ -172,14 +170,6 @@ export interface JobOutboundMessageStart extends JobOutboundMessageBase { readonly kind: JobOutboundMessageKind.Start; } -/** - * A logging message, supporting the logging.LogEntry. - */ -export interface JobOutboundMessageLog extends JobOutboundMessageBase { - readonly kind: JobOutboundMessageKind.Log; - readonly entry: LogEntry; -} - /** * An output value is available. */ @@ -274,7 +264,6 @@ export interface JobOutboundMessagePong extends JobOutboundMessageBase { export type JobOutboundMessage = JobOutboundMessageOnReady | JobOutboundMessageStart - | JobOutboundMessageLog | JobOutboundMessageOutput | JobOutboundMessageChannelCreate | JobOutboundMessageChannelMessage @@ -381,11 +370,6 @@ export interface Job< * Options for scheduling jobs. */ export interface ScheduleJobOptions { - /** - * Where should logging be passed in. By default logging will be dropped. - */ - logger?: LoggerApi; - /** * Jobs that need to finish before scheduling this job. These dependencies will be passed * to the job itself in its context. diff --git a/packages/angular_devkit/core/src/experimental/jobs/architecture.md b/packages/angular_devkit/core/src/experimental/jobs/architecture.md index 216339f167dd..ded5a0bd5f83 100644 --- a/packages/angular_devkit/core/src/experimental/jobs/architecture.md +++ b/packages/angular_devkit/core/src/experimental/jobs/architecture.md @@ -101,7 +101,6 @@ and output values (STDOUT) as well as diagnostic (STDERR). They can be plugged o ,______________________ JobInboundMessage --> | handler(argument: A) | --> JobOutboundMessage - `⎻⎻⎻⎻⎻⎻⎻⎻⎻⎻⎻⎻⎻⎻⎻⎻⎻⎻⎻⎻⎻⎻ - JobOutboundMessageKind.Log - JobOutboundMessageKind.Output - ... ``` @@ -129,7 +128,6 @@ and output values (STDOUT) as well as diagnostic (STDERR). They can be plugged o unblock dependent jobs. `createJobHandler()` automatically send this message. 1. `JobOutboundMessageKind.Pong`. The job should answer a `JobInboundMessageKind.Ping` message with this. Automatically done by `createJobHandler()`. -1. `JobOutboundMessageKind.Log`. A logging message (side effect that should be shown to the user). 1. `JobOutboundMessageKind.Output`. An `Output` has been generated by the job. 1. `JobOutboundMessageKind.ChannelMessage`, `JobOutboundMessageKind.ChannelError` and `JobOutboundMessageKind.ChannelComplete` are used for output channels. These correspond to @@ -251,7 +249,7 @@ example would be an operator that takes a module path and run the job from that process. Or even a separate server, using HTTP calls. Another limitation is that the boilerplate is complex. Manually managing start/end life cycle, and -other messages such as logging, etc. is tedious and requires a lot of code. A good way to keep +other messages such as ping/pong, etc. is tedious and requires a lot of code. A good way to keep this limitation under control is to provide helpers to create `JobHandler`s which manage those messages for the developer. A simple handler could be to get a `Promise` and return the output of that promise automatically. diff --git a/packages/angular_devkit/core/src/experimental/jobs/create-job-handler.ts b/packages/angular_devkit/core/src/experimental/jobs/create-job-handler.ts index 5e62439a078b..08f381dc081f 100644 --- a/packages/angular_devkit/core/src/experimental/jobs/create-job-handler.ts +++ b/packages/angular_devkit/core/src/experimental/jobs/create-job-handler.ts @@ -10,7 +10,7 @@ import { Observable, Observer, Subject, Subscription, from, isObservable, of } f import { switchMap, tap } from 'rxjs/operators'; import { BaseException } from '../../exception/index'; import { JsonValue } from '../../json/index'; -import { Logger, LoggerApi } from '../../logger/index'; +import { LoggerApi } from '../../logger'; import { isPromise } from '../../utils/index'; import { JobDescription, @@ -37,7 +37,6 @@ export interface SimpleJobHandlerContext< I extends JsonValue, O extends JsonValue, > extends JobHandlerContext { - logger: LoggerApi; createChannel: (name: string) => Observer; input: Observable; } @@ -100,23 +99,12 @@ export function createJobHandler { - subject.next({ - kind: JobOutboundMessageKind.Log, - description, - entry, - }); - }); - // Execute the function with the additional context. const channels = new Map>(); const newContext: SimpleJobHandlerContext = { ...context, input: inputChannel.asObservable(), - logger, createChannel(name: string) { if (channels.has(name)) { throw new ChannelAlreadyExistException(name); @@ -164,7 +152,6 @@ export function createJobHandler complete(), ); subscription.add(inboundSub); - subscription.add(logSub); return subscription; }); diff --git a/packages/angular_devkit/core/src/experimental/jobs/simple-scheduler.ts b/packages/angular_devkit/core/src/experimental/jobs/simple-scheduler.ts index c7edcd907613..9c1c4fb7d88a 100644 --- a/packages/angular_devkit/core/src/experimental/jobs/simple-scheduler.ts +++ b/packages/angular_devkit/core/src/experimental/jobs/simple-scheduler.ts @@ -22,13 +22,12 @@ import { filter, first, ignoreElements, - map, share, + map, shareReplay, switchMap, tap, } from 'rxjs/operators'; import { JsonValue, schema } from '../../json'; -import { NullLogger } from '../../logger'; import { Job, JobDescription, @@ -305,8 +304,6 @@ export class SimpleScheduler< let state = JobState.Queued; let pingId = 0; - const logger = options.logger ? options.logger.createChild('job') : new NullLogger(); - // Create the input channel by having a filter. const input = new Subject(); input.pipe( @@ -348,10 +345,6 @@ export class SimpleScheduler< state = this._updateState(message, state); switch (message.kind) { - case JobOutboundMessageKind.Log: - logger.next(message.entry); - break; - case JobOutboundMessageKind.ChannelCreate: { const maybeSubject = channelsSubject.get(message.name); // If it doesn't exist or it's closed on the other end. diff --git a/packages/angular_devkit/core/src/json/schema/registry.ts b/packages/angular_devkit/core/src/json/schema/registry.ts index aa98bac3e18c..b04bbc8c7b82 100644 --- a/packages/angular_devkit/core/src/json/schema/registry.ts +++ b/packages/angular_devkit/core/src/json/schema/registry.ts @@ -347,10 +347,6 @@ export class CoreSchemaRegistry implements SchemaRegistry { // tslint:disable-next-line:no-any https://github.com/ReactiveX/rxjs/issues/3989 result = (result as any).pipe( ...[...this._pre].map(visitor => concatMap((data: JsonValue) => { - if (schema === false || schema === true) { - return of(data); - } - return visitJson(data, visitor, schema, this._resolver, validate); })), ); @@ -418,10 +414,6 @@ export class CoreSchemaRegistry implements SchemaRegistry { // tslint:disable-next-line:no-any https://github.com/ReactiveX/rxjs/issues/3989 result = (result as any).pipe( ...[...this._post].map(visitor => concatMap((data: JsonValue) => { - if (schema === false || schema === true) { - return of(schema); - } - return visitJson(data, visitor, schema, this._resolver, validate); })), ); diff --git a/packages/angular_devkit/core/src/json/schema/registry_spec.ts b/packages/angular_devkit/core/src/json/schema/registry_spec.ts index fa512977605f..337e82e719df 100644 --- a/packages/angular_devkit/core/src/json/schema/registry_spec.ts +++ b/packages/angular_devkit/core/src/json/schema/registry_spec.ts @@ -382,6 +382,18 @@ describe('CoreSchemaRegistry', () => { .toPromise().then(done, done.fail); }); + it('works with true as a schema and post-transforms', async () => { + const registry = new CoreSchemaRegistry(); + registry.addPostTransform(addUndefinedDefaults); + const data: any = { a: 1, b: 2 }; // tslint:disable-line:no-any + + const validate = await registry.compile(true).toPromise(); + const result = await validate(data).toPromise(); + + expect(result.success).toBe(true); + expect(result.data).toBe(data); + }); + it('adds undefined properties', done => { const registry = new CoreSchemaRegistry(); registry.addPostTransform(addUndefinedDefaults); diff --git a/packages/angular_devkit/core/src/json/schema/transforms.ts b/packages/angular_devkit/core/src/json/schema/transforms.ts index 9c6694e4e32e..e047d3c38f2a 100644 --- a/packages/angular_devkit/core/src/json/schema/transforms.ts +++ b/packages/angular_devkit/core/src/json/schema/transforms.ts @@ -7,14 +7,18 @@ */ import { JsonObject, JsonValue, isJsonObject } from '../interface'; import { JsonPointer } from './interface'; +import { JsonSchema } from './schema'; import { getTypesOfSchema } from './utility'; export function addUndefinedDefaults( value: JsonValue, _pointer: JsonPointer, - schema?: JsonObject, + schema?: JsonSchema, ): JsonValue { - if (!schema) { + if (schema === true || schema === false) { + return value; + } + if (schema === undefined) { return value; } diff --git a/packages/angular_devkit/core/src/json/schema/visitor.ts b/packages/angular_devkit/core/src/json/schema/visitor.ts index 130ef4925846..b3b46c40df9f 100644 --- a/packages/angular_devkit/core/src/json/schema/visitor.ts +++ b/packages/angular_devkit/core/src/json/schema/visitor.ts @@ -19,7 +19,7 @@ export interface ReferenceResolver { } function _getObjectSubSchema( - schema: JsonObject | undefined, + schema: JsonSchema | undefined, key: string, ): JsonObject | undefined { if (typeof schema !== 'object' || schema === null) { @@ -51,11 +51,15 @@ function _visitJsonRecursive( json: JsonValue, visitor: JsonVisitor, ptr: JsonPointer, - schema?: JsonObject, + schema?: JsonSchema, refResolver?: ReferenceResolver, context?: ContextT, // tslint:disable-line:no-any root?: JsonObject | JsonArray, ): Observable { + if (schema === true || schema === false) { + // There's no schema definition, so just visit the JSON recursively. + schema = undefined; + } if (schema && schema.hasOwnProperty('$ref') && typeof schema['$ref'] == 'string') { if (refResolver) { const resolved = refResolver(schema['$ref'] as string, context); @@ -132,7 +136,7 @@ function _visitJsonRecursive( export function visitJson( json: JsonValue, visitor: JsonVisitor, - schema?: JsonObject, + schema?: JsonSchema, refResolver?: ReferenceResolver, context?: ContextT, // tslint:disable-line:no-any ): Observable { diff --git a/packages/angular_devkit/core/src/logger/logger.ts b/packages/angular_devkit/core/src/logger/logger.ts index f4682a80d41e..08bb66a117cd 100644 --- a/packages/angular_devkit/core/src/logger/logger.ts +++ b/packages/angular_devkit/core/src/logger/logger.ts @@ -102,7 +102,7 @@ export class Logger extends Observable implements LoggerApi { } log(level: LogLevel, message: string, metadata: JsonObject = {}): void { - const entry: LogEntry = Object.assign({}, this._metadata, metadata, { + const entry: LogEntry = Object.assign({}, metadata, this._metadata, { level, message, timestamp: +Date.now(), }); this._subject.next(entry); diff --git a/packages/angular_devkit/core/src/terminal/index.ts b/packages/angular_devkit/core/src/terminal/index.ts index a33a3ba65ac9..d853f3c1c9bb 100644 --- a/packages/angular_devkit/core/src/terminal/index.ts +++ b/packages/angular_devkit/core/src/terminal/index.ts @@ -6,4 +6,5 @@ * found in the LICENSE file at https://angular.io/license */ export * from './text'; +export * from './caps'; export * from './colors'; diff --git a/yarn.lock b/yarn.lock index fbb2d72aaa8a..94e9afde1add 100644 --- a/yarn.lock +++ b/yarn.lock @@ -409,6 +409,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-6.14.0.tgz#85c6998293fc6f2945915419296c7fbb63384f66" integrity sha512-6tQyh4Q4B5pECcXBOQDZ5KjyBIxRZGzrweGPM47sAYTdVG4+7R+2EGMTmp0h6ZwgqHrFRCeg2gdhsG9xXEl2Sg== +"@types/progress@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/progress/-/progress-2.0.3.tgz#7ccbd9c6d4d601319126c469e73b5bb90dfc8ccc" + integrity sha512-bPOsfCZ4tsTlKiBjBhKnM8jpY5nmIll166IPD58D92hR7G7kZDfx5iB9wGF4NfZrdKolebjeAr3GouYkSGoJ/A== + dependencies: + "@types/node" "*" + "@types/q@^0.0.32": version "0.0.32" resolved "http://registry.npmjs.org/@types/q/-/q-0.0.32.tgz#bd284e57c84f1325da702babfc82a5328190c0c5" @@ -951,6 +958,13 @@ ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" +ansi.js@0.0.5: + version "0.0.5" + resolved "https://registry.yarnpkg.com/ansi.js/-/ansi.js-0.0.5.tgz#e3e9e45eb6977ba0eeeeed11677d12144675348c" + integrity sha1-4+nkXraXe6Du7u0RZ30SFEZ1NIw= + dependencies: + on-new-line "0.0.1" + anymatch@^1.3.0: version "1.3.2" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a" @@ -1109,6 +1123,17 @@ asap@^2.0.0, asap@~2.0.3: resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= +ascii-progress@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/ascii-progress/-/ascii-progress-1.0.5.tgz#9610aa127ab794af561e893613c36c906f78d9ee" + integrity sha1-lhCqEnq3lK9WHok2E8NskG942e4= + dependencies: + ansi.js "0.0.5" + end-with "^1.0.2" + get-cursor-position "1.0.3" + on-new-line "1.0.0" + start-with "^1.0.2" + ascli@~1: version "1.0.1" resolved "https://registry.yarnpkg.com/ascli/-/ascli-1.0.1.tgz#bcfa5974a62f18e81cabaeb49732ab4a88f906bc" @@ -3244,6 +3269,11 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0: dependencies: once "^1.4.0" +end-with@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/end-with/-/end-with-1.0.2.tgz#a432755ab4f51e7fc74f3a719c6b81df5d668bdc" + integrity sha1-pDJ1WrT1Hn/HTzpxnGuB311mi9w= + engine.io-client@~3.1.0: version "3.1.6" resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.1.6.tgz#5bdeb130f8b94a50ac5cbeb72583e7a4a063ddfd" @@ -4118,6 +4148,11 @@ get-caller-file@^1.0.1: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== +get-cursor-position@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/get-cursor-position/-/get-cursor-position-1.0.3.tgz#0e41d60343b705836a528d69a5e099e2c5108d63" + integrity sha1-DkHWA0O3BYNqUo1ppeCZ4sUQjWM= + get-pkg-repo@^1.0.0: version "1.4.0" resolved "https://registry.yarnpkg.com/get-pkg-repo/-/get-pkg-repo-1.4.0.tgz#c73b489c06d80cc5536c2c853f9e05232056972d" @@ -7139,6 +7174,16 @@ on-headers@~1.0.1: resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.1.tgz#928f5d0f470d49342651ea6794b0857c100693f7" integrity sha1-ko9dD0cNSTQmUepnlLCFfBAGk/c= +on-new-line@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/on-new-line/-/on-new-line-0.0.1.tgz#99339cb06dcfe3e78d6964a2ef2af374a145f8fb" + integrity sha1-mTOcsG3P4+eNaWSi7yrzdKFF+Ps= + +on-new-line@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/on-new-line/-/on-new-line-1.0.0.tgz#8585bc2866c8c0e192e410a6d63bdd1722148ae7" + integrity sha1-hYW8KGbIwOGS5BCm1jvdFyIUiuc= + once@1.x, once@^1.3.0, once@^1.3.1, once@^1.3.3, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" @@ -7785,7 +7830,7 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= -progress@^2.0.1: +progress@^2.0.1, progress@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== @@ -9460,6 +9505,11 @@ stack-trace@0.0.x: resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA= +start-with@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/start-with/-/start-with-1.0.2.tgz#a069a5f46a95fca7f0874f85a28f653d0095c267" + integrity sha1-oGml9GqV/Kfwh0+Foo9lPQCVwmc= + static-eval@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.0.0.tgz#0e821f8926847def7b4b50cda5d55c04a9b13864"