diff --git a/contracts/package.json b/contracts/package.json new file mode 100644 index 0000000000..4c4d479642 --- /dev/null +++ b/contracts/package.json @@ -0,0 +1,4 @@ +{ + "main": "../lib/contracts/index.js", + "types": "../lib/contracts/index.d.ts" +} diff --git a/dependency-injection.md b/dependency-injection.md new file mode 100644 index 0000000000..e645ebdf56 --- /dev/null +++ b/dependency-injection.md @@ -0,0 +1,275 @@ +Dependency Injection +==================== + +The NativeScript CLI is migrating from name-based dependency injection (the +`$injector` global, where a constructor parameter named `$doctorService` +resolves the service registered under the string `"doctorService"`) to a typed, +token-based container. Both APIs are backed by **one container**, so they can be +mixed freely: a service registered under a legacy string name is resolvable +through its typed token and vice versa. The legacy `$injector` surface remains +fully supported, is marked `@deprecated` in-editor, and its usage is traced at +runtime so removal can be staged over releases. + +Inside the CLI, import from `lib/common/di`. Extension and hook authors import +the same API from the `nativescript/contracts` subpath (see +[For extension and hook authors](#for-extension-and-hook-authors)). + +At a glance +----------- + +```ts +import { inject, DoctorService } from "nativescript/contracts"; + +class PlatformChecker { + private doctorService = inject(DoctorService); // typed, no decorators needed + + async check(projectDir: string): Promise { + return this.doctorService.canExecuteLocalBuild({ projectDir }); + } +} +``` + +Services that have no typed token yet remain reachable by their registry name — +`inject("logger")` — but that is the migration bridge, not the API: prefer the +token wherever one exists, and mint a token rather than a new string name. + +Tokens: `@Contract` +------------------- + +A token is an abstract class annotated with `@Contract`. The class is both the +compile-time type and the runtime lookup key; the decorator records the token's +canonical string name: + +```ts +import { Contract } from "nativescript/contracts"; + +@Contract({ name: "doctorService" }) // canonical form, no `$` +export abstract class DoctorService { + abstract canExecuteLocalBuild(configuration?: { + platform?: string; + projectDir?: string; + }): Promise; +} +``` + +Rules: + +- **The name is an explicit string literal** — never derived from `class.name`, + which changes under minification. +- Names are minted at this single choke point: declaring two contracts with the + same name **throws at load time**, because a duplicate would silently alias + two tokens. +- The options object leaves room for future fields without changing call sites. +- Implementations do not become tokens by extending or implementing a contract; + only the decorated class itself is a token. + +Resolving: `inject()` and `Injector` +------------------------------------ + +`inject(token)` returns the singleton for a token from the current injection +context. It is **synchronous by design** and valid only: + +- in field initializers, +- in constructor bodies, +- in provider factories, +- inside an explicit `runInInjectionContext(injector, fn)`. + +It is **not** valid after an `await`. For late or conditional lookups, +self-inject the `Injector` and use `get()`: + +```ts +import { inject, Injector } from "nativescript/contracts"; + +class EnvironmentChecker { + private injector = inject(Injector); + + async check(projectDir: string) { + await somethingAsync(); + return this.injector.get(DoctorService); // fine after await + } +} +``` + +`Injector.get()` accepts a contract class, a string name, or a `$`-prefixed +string name — all three return the same instance. The string forms exist for +interoperability with the legacy registry; use the class token whenever one +exists. + +Both `inject()` and `get()` take Angular-shaped options as their second +argument: + +```ts +inject(DoctorService, { optional: true }); // DoctorService | null — no throw +inject("logger", { skipSelf: true }); // start at the parent: escapes a + // child scope's shadowing entry +inject("options", { self: true }); // this level only — no fallthrough +``` + +`optional` covers not-found only; a found-but-misconfigured provider still +throws. `self` and `skipSelf` cannot be combined. There is deliberately no +`host` option: it is an Angular component-tree concept with no analog in the +CLI's injector hierarchy. + +Registering: providers +---------------------- + +```ts +import { provide, provideLazy, Injector } from "nativescript/contracts"; + +const injector = new Injector([ + // eager class binding; type-checked: the impl must satisfy the token + provide(DoctorService, DoctorServiceImpl), + + // deferred loading: the module is require()d on first resolution only + provideLazy(DoctorService, () => require("./doctor-service").DoctorServiceImpl), + + { provide: Config, useValue: { DISABLE_HOOKS: false } }, + { provide: Dispatcher, useFactory: () => createDispatcher(), shared: false }, +]); + +// registration is also allowed after construction; re-registering a token +// updates the existing record in place +injector.register(provide(ProjectNameService, ProjectNameServiceImpl)); +``` + +Provider kinds: + +| Kind | Shape | Notes | +|---|---|---| +| Class | `provide(Token, Impl)` / `{ provide, useClass }` | constructed with `new Impl()` inside an injection context, so `inject()` works in its fields | +| Lazy class | `provideLazy(Token, () => Impl)` / `{ provide, useLazyClass }` | loader runs on first `get()` only — keeps startup lazy | +| Value | `{ provide, useValue }` | registered instance; re-registering replaces the cached instance. With `shared: false` there is no resolver and `get()` throws — a preserved legacy quirk | +| Factory | `{ provide, useFactory }` | called inside an injection context | + +`shared: false` makes a provider transient: every resolution constructs a fresh +instance. Transient instances are still retained by the container so +`dispose()` reaches them. + +String keys are accepted anywhere a token is (`{ provide: "logger", useValue }`) +— that is how the legacy facade registers, and how per-call overrides address +not-yet-migrated dependencies. New registrations should mint a `@Contract` +token instead of a new string name. + +For per-call construction with overrides (a fresh instance of a class with some +dependencies replaced), use `createInstance`: + +```ts +const debugService = injector.createInstance(IOSDeviceDebugService, [ + { provide: "device", useValue: device }, +]); +``` + +Overrides shadow **one level deep only** — the direct dependencies of the class +being constructed. Nested dependencies are constructed by the injector that +owns them and never see the per-call providers. + +Resolution semantics +-------------------- + +- Lookup is **class object first, token name on a miss**, checked per injector + level before delegating to the parent. Both keys index the same provider + record, so re-registering a service by its string name (as plugins are + documented to do with `$logger`) stays visible to `inject(Logger)` consumers. +- A leading `$` is stripped from string tokens: `get("$fs")` and `get("fs")` + are the same registration. +- The name fallback also makes **duplicated contract copies interchangeable**: + if an extension's dependency tree carries its own copy of a contract class, + that copy resolves to the same provider by name. "Works locally, breaks when + installed" is not a failure mode of this design. +- Cyclic dependencies fail with the full resolution path + (`Cyclic dependency detected on dependency 'a'. Resolution path: a -> b -> a`). + +Child scopes +------------ + +`injector.createChild(providers)` creates a scope that shadows its parent for +the given tokens and falls through for everything else. Sibling scopes are +isolated. Scopes are how per-invocation data (hook payloads, per-call +overrides) is layered over the shared singletons without ever entering the +root container. + +`forwardRef` +------------ + +Provider arrays are evaluated at module load. When a token is declared later in +the same file (TDZ) or reached through a circular import, wrap the reference in +a thunk; it is read only when the injector processes the provider: + +```ts +import { forwardRef } from "nativescript/contracts"; + +const providers = [ + { provide: forwardRef(() => DoctorService), useClass: DoctorServiceImpl }, +]; +``` + +`forwardRef` defers *references*, not construction — it cannot break an +instantiation cycle between two services. For that, self-inject the `Injector` +and resolve late (see above). + +Working alongside the legacy `$injector` +---------------------------------------- + +The `Yok` facade (`global.$injector`) IS an `Injector` — the class extends the +token-based container — so the new API works on it directly: + +```ts +$injector.resolve("doctorService") === $injector.get(DoctorService); // true +$injector.register(provide(DoctorService, DoctorServiceImpl)); +runInInjectionContext($injector, () => inject(DoctorService)); +``` + +- Legacy string names are permanent: a contract's token name is its interop + identity, used by hooks, plugins, and the public API. Nothing is deleted + per-service. +- Every legacy member (`resolve`, `register`, `require*`, the command-registry + surface) carries `@deprecated` JSDoc naming its replacement. +- Legacy usage at the external entry points (param-name hooks, require-time + extension registration, help templating) is reported through a deprecation + tracer. It logs at trace level today; set `NS_DEPRECATIONS=warn` or + `NS_DEPRECATIONS=error` to preview the stricter stages that later releases + will default to. + +For extension and hook authors +------------------------------ + +Depend on `nativescript` itself (as a `peerDependency`, plus a `devDependency` +for local development) and import from the `contracts` subpath: + +```ts +import { inject, DoctorService } from "nativescript/contracts"; +``` + +- The subpath resolves through a directory `package.json` — the CLI's + `package.json` deliberately has **no `exports` map**, so any deep `require()` + paths you already use keep working. +- The entry point is side-effect-free: importing it never boots a CLI runtime, + even from a duplicated copy in your dependency tree. +- The existing `$injector`-based extension and hook mechanisms keep working + unchanged; the typed API is additive. + +Available contracts +------------------- + +The first tranche, growing as services migrate: + +| Token | Legacy name | +|---|---| +| `DoctorService` | `doctorService` | +| `ProjectNameService` | `projectNameService` | + +Legacy → new quick reference +---------------------------- + +`di` below is any `Injector` you hold — including `$injector` itself, which +extends `Injector`. + +| Legacy (`$injector`) | New | +|---|---| +| `resolve("name")` | `inject(Token)` in an injection context, or `di.get(Token)` | +| `resolve(SomeClass)` / `resolve(SomeClass, { dep })` | `di.createInstance(SomeClass, [{ provide: "dep", useValue }])` | +| `register("name", Impl)` | `di.register(provide(Token, Impl))` | +| `register("name", instance)` | `di.register({ provide: Token, useValue: instance })` | +| `register("name", Impl, false)` | `di.register({ provide: Token, useClass: Impl, shared: false })` | +| `require("name", "./path")` | `provideLazy(Token, () => require("./path").Impl)` | +| constructor param `$name` | `inject(Token)` field initializer | diff --git a/extending-cli.md b/extending-cli.md index 3a7b5387b5..5ace718f18 100644 --- a/extending-cli.md +++ b/extending-cli.md @@ -77,15 +77,29 @@ Execute Hooks In-Process When your hook is a Node.js script, the CLI executes it in-process. This gives you access to the entire internal state of the CLI and all of its functions. -The CLI assumes that this is a CommonJS module and calls its single exported function with four parameters. The type of the parameters is described in the `.d.ts` files which are part of the CLI source code [here](https://github.com/NativeScript/nativescript-cli/tree/master/lib/definitions) and [here](https://github.com/telerik/mobile-cli-lib/tree/master/definitions). +The CLI assumes that this is a CommonJS module and calls its single exported function. + +## Writing a hook + +Hooks run inside an injection context, so services come from `inject()` — the same API used everywhere else (see [dependency-injection.md](dependency-injection.md)). Declare a `hookArgs` parameter only if you need the payload of the operation being hooked. + +```JavaScript +const { inject, DoctorService } = require("nativescript/contracts"); + +module.exports = function (hookArgs) { + const doctorService = inject(DoctorService); + return doctorService.canExecuteLocalBuild(); +}; +``` + +* `inject()` is valid in the synchronous part of the hook body — not after an `await`. Resolve what you need up front; for late lookups, grab the container first: `const injector = inject(Injector)` (`Injector` is exported from `nativescript/contracts` too), then `injector.get(...)` later. +* `hookArgs` contains the parameters of the CLI function being hooked; its shape depends on the hook point. Declare it only when you need it — a hook may also take no parameters at all. A future typed hook API (`defineHook` with an explicit context object) will replace this parameter; it is the one remaining piece of the legacy convention. +* Tokens resolve by class first and by their canonical name on a miss, so this works even if your dependency tree carries its own copy of `nativescript` — a duplicated token class still resolves to the running CLI's service. +* Only a first tranche of services has typed tokens so far ([dependency-injection.md](dependency-injection.md#available-contracts) lists them); a service without a token is reachable by its registry name — `inject("logger")` — as a migration bridge. +* If you build your hook in TypeScript, add `nativescript` as a `devDependency` and import the same names: `import { inject, DoctorService } from "nativescript/contracts"`. + +## The hook contract -Parameter | Type | Description ----|---|--- -`$logger` | ILogger | Use the members of this class to show messages to the user cooperating with the CLI internal state. -`$projectData` | IProjectData | Contains data about the project, such as project directory, ID, dependencies, etc. -`$usbLiveSyncService` | ILiveSyncService | Use this variable to check whether a LiveSync or normal build is in progress. -`hookArgs` | Any | Contains all the parameters of the original function in the CLI which is being hooked. - The hook must return a Promise. If the hook succeeds, it must fullfil the promise, but the fullfilment value is ignored. The hook can also reject the promise with an instance of Error. The returned error can have two optional members controlling the CLI. @@ -95,8 +109,19 @@ Member | Type | Description `errorAsWarning` | Boolean | Set this to treat the returned error as warning. The CLI prints the error.message colored as a warning and continues executing the current command. If these two members are not set, the CLI prints the returned error colored as fatal error and stops executing the current command. - -Furthermore, the global variable `$injector` of type `IInjector` provides access to the CLI Dependency Injector, through which all code services are available. + +## Legacy: parameter-name injection + +Historically, a hook received CLI services by naming them as parameters: the CLI parses the exported function's parameter names and injects the service registered under each name. Existing hooks written this way keep working unchanged, but **new hooks should use the pattern above** — parameter-name service injection is slated for deprecation, and hooks that use it are reported through the CLI's deprecation tracer (visible with `--log trace`, or as warnings with `NS_DEPRECATIONS=warn`). + +Parameter | Type | Description +---|---|--- +`$logger` | ILogger | Use the members of this class to show messages to the user cooperating with the CLI internal state. +`$projectData` | IProjectData | Contains data about the project, such as project directory, ID, dependencies, etc. +`$usbLiveSyncService` | ILiveSyncService | Use this variable to check whether a LiveSync or normal build is in progress. +`hookArgs` | Any | Contains all the parameters of the original function in the CLI which is being hooked. + +The type of the parameters is described in the `.d.ts` files which are part of the CLI source code [here](https://github.com/NativeScript/nativescript-cli/tree/master/lib/definitions). Any registered service name is injectable, not only the ones listed; the global variable `$injector` of type `IInjector` likewise remains available. A parameter the CLI cannot resolve causes the hook to be skipped with a warning. Commands with Hooking Support ============================== diff --git a/lib/common/contracts/command-registry.ts b/lib/common/contracts/command-registry.ts new file mode 100644 index 0000000000..25ea0c8702 --- /dev/null +++ b/lib/common/contracts/command-registry.ts @@ -0,0 +1,31 @@ +import { Contract } from "../di/contract"; +import type { ICommand } from "../definitions/commands"; + +/** + * The command-registry face of the injector facade. Transitional contract: it + * mirrors what consumers call today, so that extracting the registry from the + * facade later is a provider swap for this token, not a consumer migration. + * Members slated for replacement keep their deprecation markers. + */ +@Contract({ name: "commandRegistry" }) +export abstract class CommandRegistry { + /** + * @deprecated Path-based command registration; slated for replacement by + * manifest-declared commands. + */ + abstract requireCommand(names: string | string[], file: string): void; + abstract registerCommand(names: string | string[], resolver: any): void; + abstract resolveCommand(name: string): ICommand; + abstract getRegisteredCommandsNames(includeDev: boolean): string[]; + abstract getChildrenCommandsNames(commandName: string): string[]; + abstract buildHierarchicalCommand( + parentCommandName: string, + commandLineArguments: string[], + ): any; + /** Side-effecting: fails with help output on a bad subcommand. */ + abstract isValidHierarchicalCommand( + commandName: string, + commandArguments: string[], + ): Promise; + abstract isDefaultCommand(commandName: string): boolean; +} diff --git a/lib/common/contracts/index.ts b/lib/common/contracts/index.ts new file mode 100644 index 0000000000..3e0dafcdee --- /dev/null +++ b/lib/common/contracts/index.ts @@ -0,0 +1,10 @@ +// Internal subsystem contracts of the injector facade. Deliberately NOT +// re-exported from nativescript/contracts: promoting one to the public +// surface is a one-line decision that should be made per contract, not by +// default. Each token resolves to the facade itself until its subsystem is +// physically extracted — at which point the provider is swapped and consumers +// keep working unchanged. +export { CommandRegistry } from "./command-registry"; +export { KeyCommandRegistry } from "./key-command-registry"; +export { ModuleRegistry } from "./module-registry"; +export { PublicApiBuilder } from "./public-api-builder"; diff --git a/lib/common/contracts/key-command-registry.ts b/lib/common/contracts/key-command-registry.ts new file mode 100644 index 0000000000..f16e1ee97d --- /dev/null +++ b/lib/common/contracts/key-command-registry.ts @@ -0,0 +1,15 @@ +import { Contract } from "../di/contract"; +import type { IKeyCommand, IValidKeyName } from "../definitions/key-commands"; + +/** + * The key-command face of the injector facade (the `keyCommands.` namespace). + * Kept separate from CommandRegistry because the two registries are redesigned + * on different tracks. + */ +@Contract({ name: "keyCommandRegistry" }) +export abstract class KeyCommandRegistry { + abstract requireKeyCommand(name: IValidKeyName, file: string): void; + abstract registerKeyCommand(name: IValidKeyName, resolver: any): void; + abstract resolveKeyCommand(name: string): IKeyCommand; + abstract getRegisteredKeyCommandsNames(): string[]; +} diff --git a/lib/common/contracts/module-registry.ts b/lib/common/contracts/module-registry.ts new file mode 100644 index 0000000000..2ebca751ab --- /dev/null +++ b/lib/common/contracts/module-registry.ts @@ -0,0 +1,13 @@ +import { Contract } from "../di/contract"; + +/** + * @deprecated The lazy module-loader face of the injector facade — the + * require-time path map. Wholly replaced by provideLazy(); this contract + * exists so its remaining consumers are typed against exactly what they use + * until the bootstrap migrates. + */ +@Contract({ name: "moduleRegistry" }) +export abstract class ModuleRegistry { + abstract require(names: string | string[], file: string): void; + abstract overrideAlreadyRequiredModule: boolean; +} diff --git a/lib/common/contracts/public-api-builder.ts b/lib/common/contracts/public-api-builder.ts new file mode 100644 index 0000000000..6ddb51cc53 --- /dev/null +++ b/lib/common/contracts/public-api-builder.ts @@ -0,0 +1,13 @@ +import { Contract } from "../di/contract"; + +/** + * The public-API-builder face of the injector facade: the machinery behind + * `require('nativescript')`. Bound by the compatibility constraints of the + * published library surface; do not add new entries through it. + */ +@Contract({ name: "publicApiBuilder" }) +export abstract class PublicApiBuilder { + abstract requirePublic(names: string | string[], file: string): void; + abstract requirePublicClass(names: string | string[], file: string): void; + abstract publicApi: any; +} diff --git a/lib/common/definitions/yok.d.ts b/lib/common/definitions/yok.d.ts index 08c663bec5..89c544444b 100644 --- a/lib/common/definitions/yok.d.ts +++ b/lib/common/definitions/yok.d.ts @@ -1,56 +1,64 @@ -import { IDisposable, IDictionary } from "../declarations"; -import { ICommand } from "./commands"; -import { IKeyCommand, IValidKeyName } from "./key-commands"; +import { IDictionary } from "../declarations"; +import { Injector } from "../di/injector"; +import { Provider } from "../di/providers"; +import { CommandRegistry } from "../contracts/command-registry"; +import { KeyCommandRegistry } from "../contracts/key-command-registry"; +import { ModuleRegistry } from "../contracts/module-registry"; +import { PublicApiBuilder } from "../contracts/public-api-builder"; -interface IInjector extends IDisposable { - require(name: string, file: string): void; - require(names: string[], file: string): void; - requirePublic(names: string | string[], file: string): void; - requirePublicClass(names: string | string[], file: string): void; - requireCommand(name: string, file: string): void; - requireCommand(names: string[], file: string): void; - requireKeyCommand(name: IValidKeyName, file: string): void; +/** + * The legacy injector facade surface. It extends the token-based `Injector` — + * the facade IS an injector — and adds the legacy subsystems, whose members + * are individually @deprecated. Only the `Yok` class hierarchy implements + * this; the interface survives until the hook/extension deprecation completes. + */ +interface IInjector + extends + Injector, + CommandRegistry, + KeyCommandRegistry, + ModuleRegistry, + PublicApiBuilder { /** * Resolves an implementation by constructor function. * The injector will create new instances for every call. + * @deprecated Use Injector.createInstance. */ resolve(ctor: Function, ctorArguments?: { [key: string]: any }): any; + /** + * @deprecated Use Injector.createInstance. + */ resolve(ctor: Function, ctorArguments?: { [key: string]: any }): T; /** * Resolves an implementation by name. * The injector will create only one instance per name and return the same instance on subsequent calls. + * @deprecated Use inject(Token) in an injection context, or Injector.get. */ resolve(name: string, ctorArguments?: IDictionary): any; + /** + * @deprecated Use inject(Token) in an injection context, or Injector.get. + */ resolve(name: string, ctorArguments?: IDictionary): T; - resolveCommand(name: string): ICommand; - resolveKeyCommand(key: string): IKeyCommand; + /** + * @deprecated Legacy name-based registration. Use the Provider overload or + * provide(); a contract's token name keeps string spellings resolvable. + */ register(name: string, resolver: any, shared?: boolean): void; - registerCommand(name: string, resolver: any): void; - registerCommand(names: string[], resolver: any): void; - registerKeyCommand(key: IValidKeyName, resolver: any): void; - getRegisteredCommandsNames(includeDev: boolean): string[]; - getRegisteredKeyCommandsNames(): string[]; + register(providers: Provider | Provider[]): void; + /** + * @deprecated String-reflective help templating; removable only together + * with the help-template pipeline. + */ dynamicCallRegex: RegExp; - dynamicCall(call: string, args?: any[]): Promise; - isDefaultCommand(commandName: string): boolean; - isValidHierarchicalCommand( - commandName: string, - commandArguments: string[] - ): Promise; - getChildrenCommandsNames(commandName: string): string[]; - buildHierarchicalCommand( - parentCommandName: string, - commandLineArguments: string[] - ): any; - publicApi: any; - /** - * Defines if it's allowed to override already required module. - * This can be used in order to allow redefinition of modules, for example $logger can be replaced by a plugin. - * Default value is false. + * @deprecated See dynamicCallRegex. */ - overrideAlreadyRequiredModule: boolean; + dynamicCall(call: string, args?: any[]): Promise; } +/** + * @deprecated The process-wide legacy injector global. New code receives the + * container via inject(Injector) from lib/common/di. + */ declare var $injector: IInjector; diff --git a/lib/common/deprecation.ts b/lib/common/deprecation.ts new file mode 100644 index 0000000000..5913b18d97 --- /dev/null +++ b/lib/common/deprecation.ts @@ -0,0 +1,95 @@ +/** + * Central reporting point for invocations of legacy/deprecated CLI APIs. + * + * The severity is a single dial so the same call sites can be staged over + * releases: trace (observe usage) → warn (tell users) → error (removal). + * `NS_DEPRECATIONS=warn|error` previews a stricter stage ahead of the default. + */ + +type DeprecationStage = "trace" | "warn" | "error"; + +const DEFAULT_STAGE: DeprecationStage = "trace"; + +interface IDeprecationLogger { + trace(...args: any[]): void; + warn(...args: any[]): void; +} + +export interface IDeprecationReport { + /** Stable identifier of the deprecated API, e.g. "hooks.param-name-signature". */ + api: string; + /** Distinguishes call sites of one API, e.g. a hook path or extension name. */ + detail?: string; + /** + * Logger to report through. When omitted, the logger is resolved lazily from + * the global injector at call time — never at import time — and the report + * is dropped if no logger is resolvable yet. + */ + logger?: IDeprecationLogger; +} + +const reported = new Set(); + +export function reportDeprecation(report: IDeprecationReport): void { + const stage = getDeprecationStage(); + const message = formatMessage(report); + + if (stage === "error") { + throw new Error(message); + } + + const key = report.detail ? `${report.api}::${report.detail}` : report.api; + if (reported.has(key)) { + return; + } + + const logger = report.logger || tryResolveGlobalLogger(); + if (!logger) { + // Deliberately not latched: a report dropped for lack of a logger must + // still be deliverable once one becomes resolvable. + return; + } + reported.add(key); + + if (stage === "warn") { + logger.warn(message); + } else { + logger.trace(message); + } +} + +/** Test seam: reports are deduplicated once per process otherwise. */ +export function clearReportedDeprecations(): void { + reported.clear(); +} + +function formatMessage(report: IDeprecationReport): string { + const detail = report.detail ? ` (${report.detail})` : ""; + return ( + `Legacy CLI API used: ${report.api}${detail}. ` + + `This API is planned for deprecation in a future release; ` + + `set NS_DEPRECATIONS=warn or NS_DEPRECATIONS=error to preview stricter handling.` + ); +} + +function getDeprecationStage(): DeprecationStage { + const value = (process.env.NS_DEPRECATIONS || "").toLowerCase(); + if (value === "warn" || value === "error" || value === "trace") { + return value; + } + return DEFAULT_STAGE; +} + +function tryResolveGlobalLogger(): IDeprecationLogger | null { + try { + // Required at call time: yok imports this module, so a static import + // would be a cycle. Every reporting site already runs with yok loaded. + const injector = require("./yok").getInjector(); + if (!injector) { + return null; + } + return injector.resolve("logger"); + } catch (err) { + return null; + } +} diff --git a/lib/common/di/contract.ts b/lib/common/di/contract.ts new file mode 100644 index 0000000000..aadab5c855 --- /dev/null +++ b/lib/common/di/contract.ts @@ -0,0 +1,70 @@ +/** + * The name is stored under a `Symbol.for` key deliberately: extensions install + * into their own node_modules tree, so a duplicated copy of this module (and + * of any contract class) must write and read the same property key. A unique + * `Symbol()` would make duplicate copies mutually invisible and break the + * name-fallback lookup in `Injector.get()`. + */ +export const CONTRACT_NAME = Symbol.for("nativescript:di:contractName"); + +export interface IContractOptions { + /** + * Canonical token name, without the `$` prefix. Must be an explicit string + * literal — never derive it from `class.name`, which changes under + * minification. + */ + name: string; +} + +// Per module instance on purpose: a duplicated CLI copy in an extensions tree +// carries its own registry, so contracts redeclared by another copy never +// false-positive here. +const mintedNames = new Map(); + +/** + * Marks an abstract class as a DI token. The decorated class resolves by + * object identity first and by its name on a miss, so duplicated copies of a + * contract remain interchangeable across node_modules trees. + */ +export function Contract( + options: IContractOptions, +): (target: Function) => void { + const { name } = options; + return (target: Function): void => { + const existing = mintedNames.get(name); + if (existing && existing !== target) { + throw new Error( + `@Contract name '${name}' is already used by '${ + existing.name || "another contract" + }'. Token names must be unique — a duplicate silently aliases two contracts.`, + ); + } + mintedNames.set(name, target); + Object.defineProperty(target, CONTRACT_NAME, { + value: name, + writable: false, + enumerable: false, + configurable: false, + }); + }; +} + +/** + * Reads the decorator-set name. Own-property check only: an implementation + * class extending a contract inherits the property, but must not itself act + * as a token. + */ +export function getContractName(token: any): string | undefined { + if ( + typeof token === "function" && + Object.prototype.hasOwnProperty.call(token, CONTRACT_NAME) + ) { + return (token)[CONTRACT_NAME]; + } + return undefined; +} + +/** Test seam — the duplicate-name registry otherwise persists per process. */ +export function clearMintedContractNames(): void { + mintedNames.clear(); +} diff --git a/lib/common/di/forward-ref.ts b/lib/common/di/forward-ref.ts new file mode 100644 index 0000000000..882559d0d3 --- /dev/null +++ b/lib/common/di/forward-ref.ts @@ -0,0 +1,25 @@ +// `Symbol.for` so a duplicated CLI copy in an extensions tree marks thunks +// with the same key this copy reads — mirrors the CONTRACT_NAME reasoning. +const FORWARD_REF = Symbol.for("nativescript:di:forwardRef"); + +/** + * Defers a token reference until the container reads it — for provider arrays + * evaluated at module load, where a class declared later in the file (TDZ) or + * reached through a circular import is not yet a usable binding. Same + * semantics as Angular's forwardRef; resolved at registration and lookup. + * + * This defers *references*, not construction: it cannot break an + * instantiation cycle between two services. For that, inject the Injector and + * resolve late. + */ +export function forwardRef(fn: () => T): T { + (fn)[FORWARD_REF] = true; + return fn; +} + +export function resolveForwardRef(token: T): T { + if (typeof token === "function" && (token)[FORWARD_REF] === true) { + return (token)(); + } + return token; +} diff --git a/lib/common/di/index.ts b/lib/common/di/index.ts new file mode 100644 index 0000000000..1ddf6a1dca --- /dev/null +++ b/lib/common/di/index.ts @@ -0,0 +1,24 @@ +export { Injector } from "./injector"; +export type { InjectOptions } from "./injector"; +export { inject, runInInjectionContext } from "./inject"; +export { forwardRef, resolveForwardRef } from "./forward-ref"; +export { + Contract, + getContractName, + CONTRACT_NAME, + clearMintedContractNames, +} from "./contract"; +export type { IContractOptions } from "./contract"; +export { provide, provideLazy } from "./providers"; +export type { + Provider, + ProviderToken, + Type, + AbstractType, + IClassProvider, + IValueProvider, + IFactoryProvider, + ILazyClassProvider, + ILegacyClassProvider, + ILazyRequireProvider, +} from "./providers"; diff --git a/lib/common/di/inject.ts b/lib/common/di/inject.ts new file mode 100644 index 0000000000..aba3377b04 --- /dev/null +++ b/lib/common/di/inject.ts @@ -0,0 +1,42 @@ +import type { Injector, InjectOptions } from "./injector"; +import type { ProviderToken } from "./providers"; + +// Sync-only by design (no AsyncLocalStorage): `current` is restored in a +// finally, so inject() is valid in field initializers, constructor bodies and +// provider factories — and never after an await. Self-inject the Injector for +// later lookups. +let current: Injector | null = null; + +export function inject(token: ProviderToken): T; +export function inject( + token: ProviderToken, + options: InjectOptions & { optional: true }, +): T | null; +export function inject( + token: ProviderToken, + options: InjectOptions, +): T; +export function inject( + token: ProviderToken, + options?: InjectOptions, +): T | null { + if (!current) { + throw new Error( + "inject() can only be called from an injection context — a field " + + "initializer, a constructor, or a provider factory running under " + + "runInInjectionContext(). It is not valid after an await; inject " + + "the Injector itself and use injector.get() for late lookups.", + ); + } + return current.get(token, options); +} + +export function runInInjectionContext(injector: Injector, fn: () => T): T { + const previous = current; + current = injector; + try { + return fn(); + } finally { + current = previous; + } +} diff --git a/lib/common/di/injector.ts b/lib/common/di/injector.ts new file mode 100644 index 0000000000..55a89e2482 --- /dev/null +++ b/lib/common/di/injector.ts @@ -0,0 +1,402 @@ +import { annotate } from "../helpers"; +import { getContractName } from "./contract"; +import { resolveForwardRef } from "./forward-ref"; +import { runInInjectionContext } from "./inject"; +import type { Provider, ProviderToken, Type } from "./providers"; + +type TokenKey = string | Function; + +export interface InjectOptions { + /** Resolve to null instead of throwing when the token is not registered. */ + optional?: boolean; + /** Resolve at this injector's level only — no parent fallthrough. */ + self?: boolean; + /** + * Start resolution at the parent — escapes a child scope's shadowing + * entry (e.g. a hook payload key that collides with a service name). + */ + skipSelf?: boolean; +} + +type ProviderKind = "value" | "class" | "factory" | "lazyClass" | "legacyClass"; + +interface IProviderRecord { + displayName: string; + kind?: ProviderKind; + shared: boolean; + useValue?: any; + useClass?: Type; + useFactory?: () => any; + useLazyClass?: () => Type; + useLegacyClass?: Function; + /** + * Deferred side-effect loader (Yok's require path). Consumed on first + * resolution; it is expected to register a real resolver onto this record. + */ + pendingLoader?: () => void; + /** Every produced instance is retained, transients included — dispose() walks them. */ + instances: any[]; + constructing: boolean; +} + +// Shared across the whole injector tree so cycle reports show the full path +// even when resolution hops between parent and child scopes. +const resolutionStack: string[] = []; + +export class Injector { + private providers = new Map(); + private instantiationOrder: any[] = []; + + constructor( + providers: Provider[] = [], + private parent?: Injector, + ) { + this.register({ provide: Injector, useValue: this }); + this.register(providers); + } + + /** + * Resolution is two-step per injector level: the token itself, then its + * decorator-set name — and only on a full local miss does lookup delegate + * to the parent. The per-level order is what lets a child scope's + * string-keyed entry (a per-call override, a hook payload) shadow a parent + * provider for class-token consumers too. + * + * `ctorArguments` is the legacy per-call bag: raw Yok semantics (own-key + * check, no `$` normalization), applied only to the construction the call + * itself triggers — it never propagates to nested resolutions. + */ + // T defaults to any so string tokens (which give inference no source and + // would otherwise land on unknown) stay ergonomic during the migration. + public get(token: ProviderToken): T; + public get( + token: ProviderToken, + options: InjectOptions & { optional: true }, + ): T | null; + public get(token: ProviderToken, options: InjectOptions): T; + public get( + token: ProviderToken, + options?: InjectOptions, + ): T | null { + if (options && options.self && options.skipSelf) { + throw new Error("inject options cannot combine self and skipSelf"); + } + token = resolveForwardRef(token); + const found = this.findRecord(token, options); + if (!found) { + if (options && options.optional) { + return null; + } + throw new Error("unable to resolve " + displayNameOf(token)); + } + return found.owner.instantiate(found.record); + } + + /** + * The legacy facade's channel for Yok's `resolve(name, bag)` sites: the + * bag applies to the construction this call itself triggers, with raw + * own-key semantics, and never propagates to nested resolutions. Not part + * of the public API — new code passes per-call providers to + * createInstance instead. + */ + protected getWithLegacyArguments( + token: ProviderToken, + ctorArguments?: { [key: string]: any }, + ): any { + token = resolveForwardRef(token); + const found = this.findRecord(token); + if (!found) { + throw new Error("unable to resolve " + displayNameOf(token)); + } + return found.owner.instantiate(found.record, ctorArguments); + } + + public createChild(providers: Provider[] = []): Injector { + return new Injector(providers, this); + } + + /** + * Constructs a class that need not be registered, resolving its annotated + * parameters against this injector (plus the given per-call providers, + * which shadow one level deep only). Products are deliberately NOT + * retained for disposal — Yok never retained by-class resolutions either. + */ + public createInstance( + cls: Type | Function, + providers: Provider[] = [], + ctorArguments?: { [key: string]: any }, + ): T { + const scope = providers.length ? this.createChild(providers) : this; + return scope.constructLegacy(cls, ctorArguments); + } + + /** Merge-mutate: re-registering a key updates the existing record in place. */ + public register(providers: Provider | Provider[]): void { + const list = Array.isArray(providers) ? providers : [providers]; + for (const provider of list) { + const keys = this.keysFor(provider.provide); + let record: IProviderRecord | undefined; + for (const key of keys) { + record = this.providers.get(key); + if (record) { + break; + } + } + if (!record) { + record = { + displayName: displayNameOf(provider.provide), + shared: true, + instances: [], + constructing: false, + }; + } + this.applyProvider(record, provider); + for (const key of keys) { + this.providers.set(key, record); + } + } + } + + /** Own-level string keys, optionally filtered by prefix — feeds command-name enumeration. */ + public getRegisteredNames(prefix: string = ""): string[] { + const names: string[] = []; + for (const key of this.providers.keys()) { + if (typeof key === "string" && key.startsWith(prefix)) { + names.push(key); + } + } + return names; + } + + public has(token: ProviderToken): boolean { + return !!this.findRecord(token); + } + + /** First cached instance for a token, without triggering construction. */ + public peek(token: ProviderToken): any { + const found = this.findRecord(token); + return found ? found.record.instances[0] : undefined; + } + + /** Deletes the local record under every key that aliases it. */ + public remove(token: ProviderToken): void { + const record = this.findRecordLocal(token); + if (!record) { + return; + } + const keys: TokenKey[] = []; + for (const [key, value] of this.providers) { + if (value === record) { + keys.push(key); + } + } + for (const key of keys) { + this.providers.delete(key); + } + } + + /** + * Reverse instantiation order, then registration-provided values. Sync, + * like Yok's — the exit paths that call this do not await it. + */ + public dispose(exclude: any[] = []): void { + const seen = new Set(exclude); + const disposeOne = (instance: any) => { + if (!instance || seen.has(instance) || instance === this) { + return; + } + seen.add(instance); + if (typeof instance.dispose === "function") { + instance.dispose(); + } + }; + + for (let i = this.instantiationOrder.length - 1; i >= 0; i--) { + disposeOne(this.instantiationOrder[i]); + } + for (const record of new Set(this.providers.values())) { + for (const instance of record.instances) { + disposeOne(instance); + } + } + } + + private keysFor(token: ProviderToken): TokenKey[] { + token = resolveForwardRef(token); + if (typeof token === "string") { + return [normalizeName(token)]; + } + const name = getContractName(token); + return name !== undefined ? [token, name] : [token]; + } + + private applyProvider(record: IProviderRecord, provider: Provider): void { + record.shared = provider.shared === undefined ? true : provider.shared; + + if ("useLazyRequire" in provider) { + record.pendingLoader = provider.useLazyRequire; + return; + } + + record.useValue = undefined; + record.useClass = undefined; + record.useFactory = undefined; + record.useLazyClass = undefined; + record.useLegacyClass = undefined; + + if ("useValue" in provider) { + record.kind = "value"; + record.useValue = provider.useValue; + if (record.shared) { + record.instances[0] = provider.useValue; + } else { + record.instances.push(provider.useValue); + } + } else if ("useClass" in provider) { + record.kind = "class"; + record.useClass = provider.useClass; + } else if ("useFactory" in provider) { + record.kind = "factory"; + record.useFactory = provider.useFactory; + } else if ("useLazyClass" in provider) { + record.kind = "lazyClass"; + record.useLazyClass = provider.useLazyClass; + } else if ("useLegacyClass" in provider) { + record.kind = "legacyClass"; + record.useLegacyClass = provider.useLegacyClass; + } + } + + private findRecordLocal(token: ProviderToken): IProviderRecord | undefined { + token = resolveForwardRef(token); + if (typeof token === "string") { + return this.providers.get(normalizeName(token)); + } + const direct = this.providers.get(token); + if (direct) { + return direct; + } + const name = getContractName(token); + return name !== undefined ? this.providers.get(name) : undefined; + } + + // self/skipSelf apply to the entry level only: the parent walk below is + // always an ordinary full lookup from that injector on. + private findRecord( + token: ProviderToken, + options?: InjectOptions, + ): { record: IProviderRecord; owner: Injector } | undefined { + if (!options || !options.skipSelf) { + const local = this.findRecordLocal(token); + if (local) { + return { record: local, owner: this }; + } + if (options && options.self) { + return undefined; + } + } + return this.parent ? this.parent.findRecord(token) : undefined; + } + + private instantiate( + record: IProviderRecord, + ctorArguments?: { [key: string]: any }, + ): any { + if (record.pendingLoader) { + const loader = record.pendingLoader; + // Cleared only after a successful run so a failing loader (missing + // module, broken require) is retried on the next resolution. + loader(); + record.pendingLoader = undefined; + } + + if (record.shared && record.instances.length) { + return record.instances[0]; + } + + if ( + record.kind === undefined || + (record.kind === "value" && !record.shared) + ) { + throw new Error("no resolver registered for " + record.displayName); + } + + if (record.kind === "value") { + return record.instances[0]; + } + + if (record.constructing) { + const cyclePath = resolutionStack.concat(record.displayName).join(" -> "); + throw new Error( + `Cyclic dependency detected on dependency '${record.displayName}'. Resolution path: ${cyclePath}`, + ); + } + + record.constructing = true; + resolutionStack.push(record.displayName); + let instance: any; + try { + instance = this.construct(record, ctorArguments); + } finally { + resolutionStack.pop(); + record.constructing = false; + } + + record.instances.push(instance); + this.instantiationOrder.push(instance); + return instance; + } + + private construct( + record: IProviderRecord, + ctorArguments?: { [key: string]: any }, + ): any { + switch (record.kind) { + case "class": + return runInInjectionContext(this, () => new record.useClass()); + case "factory": + return runInInjectionContext(this, () => record.useFactory()); + case "lazyClass": { + if (!record.useClass) { + record.useClass = record.useLazyClass(); + } + const cls = record.useClass; + return runInInjectionContext(this, () => new cls()); + } + case "legacyClass": + return this.constructLegacy(record.useLegacyClass, ctorArguments); + } + } + + private constructLegacy( + ctor: any, + ctorArguments?: { [key: string]: any }, + ): any { + annotate(ctor); + + const resolvedArgs = ctor.$inject.args.map((paramName: string) => + ctorArguments && + Object.prototype.hasOwnProperty.call(ctorArguments, paramName) + ? ctorArguments[paramName] + : this.get(paramName), + ); + + const name = ctor.$inject.name; + return runInInjectionContext(this, () => + name && name[0] === name[0].toUpperCase() + ? new ctor(...resolvedArgs) + : ctor.apply(null, resolvedArgs), + ); + } +} + +function normalizeName(name: string): string { + return name[0] === "$" ? name.slice(1) : name; +} + +function displayNameOf(token: ProviderToken): string { + if (typeof token === "string") { + return normalizeName(token); + } + return getContractName(token) || token.name || ""; +} diff --git a/lib/common/di/providers.ts b/lib/common/di/providers.ts new file mode 100644 index 0000000000..2395dfcb3d --- /dev/null +++ b/lib/common/di/providers.ts @@ -0,0 +1,63 @@ +export type Type = new (...args: any[]) => T; +export type AbstractType = abstract new (...args: any[]) => T; + +export type ProviderToken = string | Type | AbstractType; + +interface IBaseProvider { + provide: ProviderToken; + /** Defaults to true. `false` constructs a fresh instance per resolution. */ + shared?: boolean; +} + +export interface IClassProvider extends IBaseProvider { + useClass: Type; +} + +export interface IValueProvider extends IBaseProvider { + useValue: T; +} + +export interface IFactoryProvider extends IBaseProvider { + useFactory: () => T; +} + +/** The loader runs on first resolution only — module loading stays deferred. */ +export interface ILazyClassProvider extends IBaseProvider { + useLazyClass: () => Type; +} + +/** + * Yok-style resolver constructed via annotate(): parameters are resolved by + * name, and lowercase/anonymous functions are invoked as factories rather + * than new-ed. + */ +export interface ILegacyClassProvider extends IBaseProvider { + useLegacyClass: Function; +} + +/** + * Deferred side-effect loader (Yok's `require(name, path)`): running it is + * expected to register the real resolver onto this same record. + */ +export interface ILazyRequireProvider extends IBaseProvider { + useLazyRequire: () => void; +} + +export type Provider = + | IClassProvider + | IValueProvider + | IFactoryProvider + | ILazyClassProvider + | ILegacyClassProvider + | ILazyRequireProvider; + +/** Enforces at compile time that the implementation satisfies the token. */ +export const provide = ( + token: AbstractType | string, + impl: Type, +): Provider => ({ provide: token, useClass: impl }); + +export const provideLazy = ( + token: AbstractType | string, + load: () => Type, +): Provider => ({ provide: token, useLazyClass: load }); diff --git a/lib/common/helpers.ts b/lib/common/helpers.ts index 86e0f3f722..d688eafb3d 100644 --- a/lib/common/helpers.ts +++ b/lib/common/helpers.ts @@ -558,11 +558,20 @@ export function decorateMethod( }; } +/** + * @deprecated Emits the param-name hook payload contract (keyed off the + * decorated method's parameter names); slated for replacement by a typed + * hook API. + */ export function hook(commandName: string) { function getHooksService(self: any): IHooksService { let hooksService: IHooksService = self.$hooksService; if (!hooksService) { - const injector = self.$injector; + // The process-wide injector must stay the LAST resort: tests stub + // self.$hooksService / self.$injector, and a class migrated off + // property injection has neither — only then may it be used. It is + // required at call time because yok imports this module (cycle). + const injector = self.$injector || require("./yok").getInjector(); if (!injector) { throw Error( "Type with hooks needs to have either $hooksService or $injector injected.", @@ -850,6 +859,12 @@ const FN_NAME_AND_ARGS = const FN_ARG_SPLIT = /,/; const FN_ARG = /^\s*(_?)(\S+?)\1\s*$/; +/** + * @deprecated Discovers dependencies by regex-parsing constructor source text — + * the reason tests must run against tsc output and the CLI can never be + * bundled. Kept only for the legacy provider kind and param-name hook + * injection; never add new callers. + */ export function annotate(fn: any) { let $inject: any, fnText: string, argDecl: string[]; diff --git a/lib/common/services/hooks-service.ts b/lib/common/services/hooks-service.ts index 0b9676258a..a9cada85c9 100644 --- a/lib/common/services/hooks-service.ts +++ b/lib/common/services/hooks-service.ts @@ -2,6 +2,7 @@ import * as path from "path"; import * as util from "util"; import * as _ from "lodash"; import { annotate, getValueFromNestedObject } from "../helpers"; +import { reportDeprecation } from "../deprecation"; import { AnalyticsEventLabelDelimiter } from "../../constants"; import { IOptions, IPerformanceService } from "../../declarations"; import { @@ -245,6 +246,20 @@ export class HooksService implements IHooksService { projectDataHookArg; } + // Only param-name *service* injection is on the deprecation track; a + // hook declaring nothing but `hookArgs` (or no parameters) already + // follows the recommended pattern and must not be flagged. + const usesParamNameInjection = (( + hookEntryPoint.$inject.args + )).some((argument) => argument !== this.hookArgsName); + if (usesParamNameInjection) { + reportDeprecation({ + api: "hooks.param-name-signature", + detail: hook.fullPath, + logger: this.$logger, + }); + } + const maybePromise = this.$injector.resolve( hookEntryPoint, hookArguments, diff --git a/lib/common/yok.ts b/lib/common/yok.ts index cf06c51445..9187a8d030 100644 --- a/lib/common/yok.ts +++ b/lib/common/yok.ts @@ -1,34 +1,29 @@ import * as path from "path"; import * as _ from "lodash"; -import { annotate, isPromise } from "./helpers"; +import { isPromise } from "./helpers"; +import { reportDeprecation } from "./deprecation"; import { ERROR_NO_VALID_SUBCOMMAND_FORMAT } from "./constants"; import { CommandsDelimiters } from "./constants"; import { IDictionary } from "./declarations"; import { IInjector } from "./definitions/yok"; import { ICommandArgument, ICommand } from "./definitions/commands"; import { IKeyCommand, IValidKeyName } from "./definitions/key-commands"; - +import { Injector } from "./di/injector"; +import type { Provider } from "./di/providers"; +import { + CommandRegistry, + KeyCommandRegistry, + ModuleRegistry, + PublicApiBuilder, +} from "./contracts"; + +/** + * The legacy global facade binding. New code should obtain the container via + * inject(Injector) inside an injection context rather than importing this; + * every legacy member on it is individually marked @deprecated. + */ export let injector: IInjector; -let indent = ""; -function trace(formatStr: string, ...args: any[]) { - // uncomment following lines when debugging dependency injection - // const items: any[] = []; - // for (let _i = 1; _i < arguments.length; _i++) { - // items[_i - 1] = arguments[_i]; - // } - // const util = require("util"); - // console.log(util.format.apply(util, [indent + formatStr].concat(args))); -} - -function pushIndent() { - indent += " "; -} - -function popIndent() { - indent = indent.slice(0, -2); -} - function forEachName(names: any, action: (name: string) => void): void { if (_.isString(names)) { action(names); @@ -37,6 +32,9 @@ function forEachName(names: any, action: (name: string) => void): void { } } +/** + * @deprecated Yok-era class decorator with zero call sites; do not adopt. + */ export function register(...rest: any[]) { return function (target: any): void { // TODO: Check if 'rest' has more arguments that have to be registered @@ -44,6 +42,10 @@ export function register(...rest: any[]) { }; } +/** + * @deprecated Shape of Yok's old internal records; the container now keeps + * provider records in lib/common/di. + */ export interface IDependency { require?: string; resolver?: () => any; @@ -51,22 +53,47 @@ export interface IDependency { shared?: boolean; } -export class Yok implements IInjector { +/** + * The Yok facade IS the token-based `Injector` — it extends it — plus the + * legacy surface: command routing, the key-command namespace, the module + * loader, and the public-API builder. Those subsystems historically shared + * the container object and migrate out separately; until then they live here, + * individually marked @deprecated. + */ +export class Yok extends Injector implements IInjector { + /** + * @deprecated Escape hatch of the legacy require-time module map. + */ public overrideAlreadyRequiredModule: boolean = false; constructor() { + super(); this.register("injector", this); + // Each subsystem face resolves to the facade until it is physically + // extracted; extraction then swaps the provider without touching + // consumers of the token. + this.register([ + { provide: CommandRegistry, useValue: this }, + { provide: KeyCommandRegistry, useValue: this }, + { provide: ModuleRegistry, useValue: this }, + { provide: PublicApiBuilder, useValue: this }, + ]); } private COMMANDS_NAMESPACE: string = "commands"; + /** + * Parents whose dispatcher THIS instance synthesized. The require-ordering + * guard below must not fire for them: they exist because a child was + * registered, not because child requires ran out of order. + */ + private synthesizedParents = new Set(); private KEY_COMMANDS_NAMESPACE: string = "keyCommands"; - private modules: { - [name: string]: IDependency; - } = {}; - - private resolutionProgress: any = {}; private hierarchicalCommands: IDictionary = {}; + /** + * @deprecated Path-based command registration; slated for replacement by + * manifest-declared commands. + */ public requireCommand(names: any, file: string): void { forEachName(names, (commandName) => { const commands = commandName.split( @@ -76,7 +103,8 @@ export class Yok implements IInjector { if (commands.length > 1) { if ( _.startsWith(commands[1], "*") && - this.modules[this.createCommandName(commands[0])] + this.has(this.createCommandName(commands[0])) && + !this.synthesizedParents.has(commands[0]) ) { throw new Error( "Default commands should be required before child commands", @@ -96,30 +124,47 @@ export class Yok implements IInjector { if ( commands.length > 1 && - !this.modules[this.createCommandName(commands[0])] + !this.has(this.createCommandName(commands[0])) ) { this.require(this.createCommandName(commands[0]), file); if (commands[1] && !commandName.match(/\|\*/)) { this.require(this.createCommandName(commandName), file); } - } else { + } else if (!commandName.match(/\|\*/)) { + // Mirrors the default-command skip of the branch above: a default's + // own record comes from registerCommand, never from a require path. this.require(this.createCommandName(commandName), file); } }); } + /** + * @deprecated Use provideLazy() from lib/common/di (via `Yok.di`) — the same + * deferred loading, token-based. + */ public require(names: any, file: string): void { forEachName(names, (name) => this.requireOne(name, file)); } + /** + * @deprecated Key-command counterpart of requireCommand; replaced together + * with the command registry. + */ public requireKeyCommand(name: any, file: string): void { this.requireOne(this.createKeyCommandName(name), file); } + /** + * @deprecated Backing store of the require('nativescript') surface. + * Do not add new entries through it. + */ public publicApi: any = { __modules__: {}, }; + /** + * @deprecated Legacy public-API builder. + */ public requirePublic(names: any, file: string): void { forEachName(names, (name) => { this.requireOne(name, file); @@ -127,6 +172,9 @@ export class Yok implements IInjector { }); } + /** + * @deprecated Legacy public-API builder. + */ public requirePublicClass(names: any, file: string): void { forEachName(names, (name) => { this.requireOne(name, file); @@ -152,7 +200,7 @@ export class Yok implements IInjector { } private resolveInstance(name: string): any { - let classInstance = _.first(this.modules[name].instances); + let classInstance = this.peek(name); if (!classInstance) { classInstance = this.resolve(name); } @@ -162,22 +210,29 @@ export class Yok implements IInjector { private requireOne(name: string, file: string): void { const relativePath = path.join("../", file); - const dependency: IDependency = { - require: require("fs").existsSync( - path.join(__dirname, relativePath + ".js"), - ) - ? relativePath - : file, - shared: true, - }; - - if (!this.modules[name] || this.overrideAlreadyRequiredModule) { - this.modules[name] = dependency; + const dependencyPath = require("fs").existsSync( + path.join(__dirname, relativePath + ".js"), + ) + ? relativePath + : file; + + if (!this.has(name) || this.overrideAlreadyRequiredModule) { + // Yok replaced the whole record on an allowed re-require, dropping any + // resolver and cached instances with it — preserved via remove(). + this.remove(name); + this.register({ + provide: name, + useLazyRequire: () => require(dependencyPath), + }); } else { throw new Error(`module '${name}' require'd twice.`); } } + /** + * @deprecated Slated for replacement by defineCommand and manifest-declared + * commands. + */ public registerCommand(names: any, resolver: any): void { forEachName(names, (name) => { const commands = name.split(CommandsDelimiters.HierarchicalCommand); @@ -189,6 +244,9 @@ export class Yok implements IInjector { }); } + /** + * @deprecated Replaced together with the command registry. + */ public registerKeyCommand(name: IValidKeyName, resolver: IKeyCommand): void { this.register(this.createKeyCommandName(name), resolver); } @@ -204,6 +262,10 @@ export class Yok implements IInjector { return defaultCommand; } + /** + * @deprecated Hierarchical-routing internals of the legacy command registry; + * they move out of the container with the defineCommand work. + */ public buildHierarchicalCommand( parentCommandName: string, commandLineArguments: string[], @@ -243,7 +305,7 @@ export class Yok implements IInjector { .split(CommandsDelimiters.HierarchicalCommand) .map((command) => _.startsWith(command, CommandsDelimiters.DefaultCommandSymbol) - ? command.substr(1) + ? command.slice(1) : command, ), ); @@ -261,12 +323,13 @@ export class Yok implements IInjector { } private createHierarchicalCommand(name: string) { + this.synthesizedParents.add(name); const factory = () => { return { disableAnalytics: true, isHierarchicalCommand: true, execute: async (args: string[]): Promise => { - const commandsService = injector.resolve("commandsService"); + const commandsService = this.resolve("commandsService"); let commandName: string = null; const defaultCommand = this.getDefaultCommand(name, args); let commandArguments: ICommandArgument[] = []; @@ -320,7 +383,7 @@ export class Yok implements IInjector { }; }; - injector.registerCommand(name, factory); + this.registerCommand(name, factory); } private getHierarchicalCommandName( @@ -332,6 +395,10 @@ export class Yok implements IInjector { ); } + /** + * @deprecated Legacy command-registry routing. + * Side-effecting: fails with help output on a bad subcommand. + */ public async isValidHierarchicalCommand( commandName: string, commandArguments: string[], @@ -347,7 +414,7 @@ export class Yok implements IInjector { // In case buildHierarchicalCommand doesn't find a valid command // there isn't a valid command or default with those arguments - const errors = injector.resolve("errors"); + const errors = this.resolve("errors"); errors.failWithHelp(ERROR_NO_VALID_SUBCOMMAND_FORMAT, commandName); } @@ -358,6 +425,9 @@ export class Yok implements IInjector { return false; } + /** + * @deprecated Legacy command-registry routing. + */ public isDefaultCommand(commandName: string): boolean { return ( commandName.indexOf(CommandsDelimiters.DefaultCommandSymbol) > 0 && @@ -365,31 +435,44 @@ export class Yok implements IInjector { ); } - public register(name: string, resolver: any, shared?: boolean): void { - shared = shared === undefined ? true : shared; - trace("registered '%s'", name); - - const dependency: any = this.modules[name] || {}; - dependency.shared = shared; + /** + * @deprecated Legacy name-based registration. Use a Provider (the overload + * below) or provide(); a contract's token name keeps string spellings + * resolvable. + */ + public register(name: string, resolver: any, shared?: boolean): void; + public register(providers: Provider | Provider[]): void; + public register( + nameOrProviders: string | Provider | Provider[], + resolver?: any, + shared?: boolean, + ): void { + if (typeof nameOrProviders !== "string") { + super.register(nameOrProviders); + return; + } + shared = shared === undefined ? true : shared; if (_.isFunction(resolver)) { - dependency.resolver = resolver; + // Classes and factory functions alike: the legacy provider kind + // annotate()s the resolver and calls or news it by casing. + super.register({ + provide: nameOrProviders, + useLegacyClass: resolver, + shared, + }); } else { - dependency.instances = dependency.instances || []; - if (shared) { - dependency.instances[0] = resolver; - } else { - dependency.instances.push(resolver); - } + super.register({ provide: nameOrProviders, useValue: resolver, shared }); } - - this.modules[name] = dependency; } + /** + * @deprecated Legacy command-registry lookup. + */ public resolveCommand(name: string): ICommand { let command: ICommand; const commandModuleName = this.createCommandName(name); - if (!this.modules[commandModuleName]) { + if (!this.has(commandModuleName)) { return null; } command = this.resolve(commandModuleName); @@ -397,10 +480,13 @@ export class Yok implements IInjector { return command; } + /** + * @deprecated Legacy command-registry lookup. + */ public resolveKeyCommand(name: string): IKeyCommand { let command: IKeyCommand; const commandModuleName = this.createKeyCommandName(name); - if (!this.modules[commandModuleName]) { + if (!this.has(commandModuleName)) { return null; } @@ -409,12 +495,17 @@ export class Yok implements IInjector { return command; } + /** + * @deprecated Use inject(Token) in an injection context, or Injector.get / + * createInstance from lib/common/di (via `Yok.di`). + */ public resolve(param: any, ctorArguments?: IDictionary): any { if (_.isFunction(param)) { - return this.resolveConstructor(param, ctorArguments); - } else { - return this.resolveByName(param, ctorArguments); + // By-class resolution is transient and never retained — Yok did not + // track these instances for disposal either. + return this.createInstance(param, [], ctorArguments); } + return this.getWithLegacyArguments(param, ctorArguments); } /* Regex to match dynamic calls in the following format: @@ -423,11 +514,20 @@ export class Yok implements IInjector { #{moduleName.functionName(param1, param2)} - multiple parameters separated with comma are supported Check dynamicCall method for sample usage of this regular expression and see how to determine the passed parameters */ + /** + * @deprecated String-reflective help templating; removable only together with + * the help-template pipeline. Usage is + * runtime-traced via reportDeprecation. + */ public get dynamicCallRegex(): RegExp { return /#{([^.]+)\.([^}]+?)(\((.+)\))*}/; } + /** + * @deprecated See dynamicCallRegex. + */ public getDynamicCallData(call: string, args?: any[]): any { + reportDeprecation({ api: "injector.dynamicCall", detail: call }); const parsed = call.match(this.dynamicCallRegex); const module = this.resolve(parsed[1]); if (!args && parsed[3]) { @@ -437,6 +537,9 @@ export class Yok implements IInjector { return module[parsed[2]].apply(module, args); } + /** + * @deprecated See dynamicCallRegex. + */ public async dynamicCall(call: string, args?: any[]): Promise { const data = this.getDynamicCallData(call, args); @@ -447,93 +550,16 @@ export class Yok implements IInjector { return data; } - private resolveConstructor( - ctor: any, - ctorArguments?: { [key: string]: any }, - ): any { - annotate(ctor); - - const resolvedArgs = ctor.$inject.args.map((paramName: any) => { - if (ctorArguments && ctorArguments.hasOwnProperty(paramName)) { - return ctorArguments[paramName]; - } else { - return this.resolve(paramName); - } - }); - - const name = ctor.$inject.name; - if (name && name[0] === name[0].toUpperCase()) { - return new (ctor)(...resolvedArgs); - } else { - return ctor.apply(null, resolvedArgs); - } - } - - private resolveByName(name: string, ctorArguments?: IDictionary): any { - if (name[0] === "$") { - name = name.substr(1); - } - - if (this.resolutionProgress[name]) { - throw new Error(`Cyclic dependency detected on dependency '${name}'`); - } - this.resolutionProgress[name] = true; - - trace("resolving '%s'", name); - pushIndent(); - - let dependency: IDependency; - let instance: any; - try { - dependency = this.resolveDependency(name); - - if (!dependency) { - throw new Error("unable to resolve " + name); - } - - if ( - !dependency.instances || - !dependency.instances.length || - !dependency.shared - ) { - if (!dependency.resolver) { - throw new Error("no resolver registered for " + name); - } - - dependency.instances = dependency.instances || []; - - instance = this.resolveConstructor(dependency.resolver, ctorArguments); - dependency.instances.push(instance); - } else { - instance = _.first(dependency.instances); - } - } finally { - popIndent(); - delete this.resolutionProgress[name]; - } - - return instance; - } - - private resolveDependency(name: string): IDependency { - const module = this.modules[name]; - if (!module) { - throw new Error("unable to resolve " + name); - } - - if (module.require) { - require(module.require); - } - return module; - } - + /** + * @deprecated Legacy command-registry enumeration; feeds shell autocompletion + * and help, so the `|` encoding is user-visible. + */ public getRegisteredCommandsNames(includeDev: boolean): string[] { - const modulesNames: string[] = _.keys(this.modules); - const commandsNames: string[] = _.filter(modulesNames, (moduleName) => - _.startsWith(moduleName, `${this.COMMANDS_NAMESPACE}.`), + const commandsNames = this.getRegisteredNames( + `${this.COMMANDS_NAMESPACE}.`, ); let commands = _.map(commandsNames, (commandName: string) => - commandName.substr(this.COMMANDS_NAMESPACE.length + 1), + commandName.slice(this.COMMANDS_NAMESPACE.length + 1), ); if (!includeDev) { commands = _.reject(commands, (command) => _.startsWith(command, "dev-")); @@ -541,17 +567,22 @@ export class Yok implements IInjector { return commands; } + /** + * @deprecated Legacy command-registry enumeration. + */ public getRegisteredKeyCommandsNames(): string[] { - const modulesNames: string[] = _.keys(this.modules); - const commandsNames: string[] = _.filter(modulesNames, (moduleName) => - _.startsWith(moduleName, `${this.KEY_COMMANDS_NAMESPACE}.`), + const commandsNames = this.getRegisteredNames( + `${this.KEY_COMMANDS_NAMESPACE}.`, ); - let commands = _.map(commandsNames, (commandName: string) => - commandName.substr(this.KEY_COMMANDS_NAMESPACE.length + 1), + const commands = _.map(commandsNames, (commandName: string) => + commandName.slice(this.KEY_COMMANDS_NAMESPACE.length + 1), ); return commands; } + /** + * @deprecated Legacy command-registry routing. + */ public getChildrenCommandsNames(commandName: string): string[] { return this.hierarchicalCommands[commandName]; } @@ -564,24 +595,45 @@ export class Yok implements IInjector { return `${this.KEY_COMMANDS_NAMESPACE}.${name}`; } - public dispose(): void { - Object.keys(this.modules).forEach((moduleName) => { - const instances = this.modules[moduleName].instances; - _.forEach(instances, (instance) => { - if (instance && instance.dispose && instance !== this) { - instance.dispose(); - } - }); - }); + /** + * @deprecated Delegates to Injector.dispose (reverse instantiation order); + * new code disposes the di container directly. + */ + public dispose(exclude: any[] = []): void { + super.dispose([this, ...exclude]); } } -if (!(global).$injector) { - (global).$injector = new Yok(); - injector = (global).$injector; +// The global is the published legacy surface. It is an accessor pair so a +// direct `global.$injector = x` assignment — allowed for third parties — +// stays synchronized with the module binding that getInjector() and internal +// code read; a plain data property would silently fork the two. +injector = (global).$injector || new Yok(); +Object.defineProperty(global, "$injector", { + get: () => injector, + set: (value: IInjector) => { + injector = value; + }, + configurable: true, +}); + +/** + * Accessor for the process-wide facade, for code that cannot receive the + * injector through DI or a static import (import cycles, decorator bodies). + * Prefer inject(Injector) in an injection context; prefer a constructor + * dependency in services. Never read global.$injector directly — the global + * exists only as the published legacy surface for extensions and hooks. + */ +export function getInjector(): IInjector { + return injector; } +/** + * @deprecated Global-singleton wiring for the legacy facade; new code receives + * the container via inject(Injector) instead of a process-wide global. + */ export function setGlobalInjector(inj: IInjector): IInjector { - (global).$injector = injector = inj; + injector = inj; + (global).$injector = inj; return inj; } diff --git a/lib/contracts/doctor-service.ts b/lib/contracts/doctor-service.ts new file mode 100644 index 0000000000..9a64fbd7a4 --- /dev/null +++ b/lib/contracts/doctor-service.ts @@ -0,0 +1,38 @@ +import { Contract } from "../common/di/contract"; +import type { ISpawnResult } from "../common/declarations"; +import type { IOptions } from "../declarations"; + +/** + * Verifies the host OS configuration — the code behind `ns doctor`. + */ +@Contract({ name: "doctorService" }) +export abstract class DoctorService { + /** + * Verifies the host OS configuration and prints warnings to the users. + * @param configOptions Defines if the result should be tracked by Analytics. + */ + abstract printWarnings(configOptions?: { + trackResult?: boolean; + projectDir?: string; + runtimeVersion?: string; + options?: IOptions; + forceCheck?: boolean; + platform?: string; + }): Promise; + + /** Runs the setup script on the host machine. */ + abstract runSetupScript(): Promise; + + /** + * Checks whether the environment is properly configured for local builds. + */ + abstract canExecuteLocalBuild(configuration?: { + platform?: string; + projectDir?: string; + runtimeVersion?: string; + forceCheck?: boolean; + }): Promise; + + /** Checks and notifies users of deprecated short imports in their app. */ + abstract checkForDeprecatedShortImportsInAppDir(projectDir: string): void; +} diff --git a/lib/contracts/index.ts b/lib/contracts/index.ts new file mode 100644 index 0000000000..c13a9b9996 --- /dev/null +++ b/lib/contracts/index.ts @@ -0,0 +1,27 @@ +// The `nativescript/contracts` entry point (resolved via contracts/package.json +// — deliberately no `exports` map, so existing deep requires keep working). +// +// This module must stay side-effect-free: an extension's duplicated CLI copy +// may load it, and it must never boot a second runtime. In particular nothing +// here may import lib/common/yok (whose import creates global.$injector). + +export { + Contract, + getContractName, + CONTRACT_NAME, +} from "../common/di/contract"; +export type { IContractOptions } from "../common/di/contract"; +export { inject, runInInjectionContext } from "../common/di/inject"; +export { forwardRef, resolveForwardRef } from "../common/di/forward-ref"; +export { Injector } from "../common/di/injector"; +export type { InjectOptions } from "../common/di/injector"; +export { provide, provideLazy } from "../common/di/providers"; +export type { + Provider, + ProviderToken, + Type, + AbstractType, +} from "../common/di/providers"; + +export { DoctorService } from "./doctor-service"; +export { ProjectNameService } from "./project-name-service"; diff --git a/lib/contracts/project-name-service.ts b/lib/contracts/project-name-service.ts new file mode 100644 index 0000000000..84d4f9f9f9 --- /dev/null +++ b/lib/contracts/project-name-service.ts @@ -0,0 +1,13 @@ +import { Contract } from "../common/di/contract"; + +@Contract({ name: "projectNameService" }) +export abstract class ProjectNameService { + /** + * Ensures the passed project name is valid; prompts for action otherwise. + * @returns The selected name of the project. + */ + abstract ensureValidName( + projectName: string, + validateOptions?: { force: boolean }, + ): Promise; +} diff --git a/lib/services/doctor-service.ts b/lib/services/doctor-service.ts index 8196762464..4ff1d3e003 100644 --- a/lib/services/doctor-service.ts +++ b/lib/services/doctor-service.ts @@ -24,16 +24,19 @@ import { import { IJsonFileSettingsService } from "../common/definitions/json-file-settings-service"; import { IInjector } from "../common/definitions/yok"; import { injector } from "../common/yok"; +import { DoctorService as DoctorServiceContract } from "../contracts/doctor-service"; import { color } from "../color"; import { ITerminalSpinnerService } from "../definitions/terminal-spinner-service"; -export class DoctorService implements IDoctorService { +export class DoctorServiceImpl + implements IDoctorService, DoctorServiceContract +{ private static DarwinSetupScriptLocation = path.join( __dirname, "..", "..", "setup", - "mac-startup-shell-script.sh" + "mac-startup-shell-script.sh", ); private static WindowsSetupScriptExecutable = "powershell.exe"; private static WindowsSetupScriptArguments = [ @@ -50,7 +53,7 @@ export class DoctorService implements IDoctorService { private get jsonFileSettingsPath(): string { return path.join( this.$settingsService.getProfileDir(), - "doctor-cache.json" + "doctor-cache.json", ); } @@ -58,7 +61,7 @@ export class DoctorService implements IDoctorService { private get $jsonFileSettingsService(): IJsonFileSettingsService { return this.$injector.resolve( "jsonFileSettingsService", - { jsonFileSettingsPath: this.jsonFileSettingsPath } + { jsonFileSettingsPath: this.jsonFileSettingsPath }, ); } @@ -72,11 +75,11 @@ export class DoctorService implements IDoctorService { private $fs: IFileSystem, private $terminalSpinnerService: ITerminalSpinnerService, private $versionsService: IVersionsService, - private $settingsService: ISettingsService + private $settingsService: ISettingsService, ) {} public async printWarnings(configOptions?: { - trackResult: boolean; + trackResult?: boolean; projectDir?: string; runtimeVersion?: string; options?: IOptions; @@ -96,17 +99,17 @@ export class DoctorService implements IDoctorService { text: `Getting environment information ${EOL}`, }, () => - this.getInfos({ forceCheck: configOptions.forceCheck }, getInfosData) + this.getInfos({ forceCheck: configOptions.forceCheck }, getInfosData), ); const warnings = infos.filter( - (info) => info.type === constants.WARNING_TYPE_NAME + (info) => info.type === constants.WARNING_TYPE_NAME, ); const hasWarnings = warnings.length > 0; const hasAndroidWarnings = warnings.filter((warning) => - _.includes(warning.platforms, constants.ANDROID_PLATFORM_NAME) + _.includes(warning.platforms, constants.ANDROID_PLATFORM_NAME), ).length > 0; if (hasAndroidWarnings) { this.printPackageManagerTip(); @@ -126,18 +129,18 @@ export class DoctorService implements IDoctorService { this.$logger.info(color.bold("No issues were detected.")); await this.$jsonFileSettingsService.saveSetting( this.getKeyForConfiguration(getInfosData), - infos + infos, ); this.printInfosCore(infos); } try { await this.$versionsService.printVersionsInformation( - configOptions.platform + configOptions.platform, ); } catch (err) { this.$logger.error( - "Cannot get the latest versions information from npm. Please try again later." + "Cannot get the latest versions information from npm. Please try again later.", ); } @@ -146,7 +149,7 @@ export class DoctorService implements IDoctorService { await this.$injector .resolve( - "platformEnvironmentRequirements" + "platformEnvironmentRequirements", ) .checkEnvironmentRequirements({ platform: configOptions.platform, @@ -171,20 +174,21 @@ export class DoctorService implements IDoctorService { } this.$logger.info( - "Running the setup script to try and automatically configure your environment." + "Running the setup script to try and automatically configure your environment.", ); + let result: ISpawnResult; if (this.$hostInfo.isDarwin) { - await this.runSetupScriptCore( - DoctorService.DarwinSetupScriptLocation, - [] + result = await this.runSetupScriptCore( + DoctorServiceImpl.DarwinSetupScriptLocation, + [], ); } if (this.$hostInfo.isWindows) { - await this.runSetupScriptCore( - DoctorService.WindowsSetupScriptExecutable, - DoctorService.WindowsSetupScriptArguments + result = await this.runSetupScriptCore( + DoctorServiceImpl.WindowsSetupScriptExecutable, + DoctorServiceImpl.WindowsSetupScriptArguments, ); } @@ -192,6 +196,8 @@ export class DoctorService implements IDoctorService { action: TrackActionNames.RunSetupScript, additionalData: "Finished", }); + + return result; } public async canExecuteLocalBuild(configuration?: { @@ -200,6 +206,7 @@ export class DoctorService implements IDoctorService { runtimeVersion?: string; forceCheck?: boolean; }): Promise { + configuration = configuration || {}; await this.$analyticsService.trackEventActionInGoogleAnalytics({ action: TrackActionNames.CheckLocalBuildSetup, additionalData: "Starting", @@ -211,7 +218,7 @@ export class DoctorService implements IDoctorService { }; const infos = await this.getInfos( { forceCheck: configuration && configuration.forceCheck }, - sysInfoConfig + sysInfoConfig, ); const warnings = this.filterInfosByType(infos, constants.WARNING_TYPE_NAME); const hasWarnings = warnings.length > 0; @@ -228,7 +235,7 @@ export class DoctorService implements IDoctorService { infos.map((info) => this.$logger.trace(info.message)); await this.$jsonFileSettingsService.saveSetting( this.getKeyForConfiguration(sysInfoConfig), - infos + infos, ); } @@ -243,27 +250,26 @@ export class DoctorService implements IDoctorService { public checkForDeprecatedShortImportsInAppDir(projectDir: string): void { if (projectDir) { try { - const files = this.$projectDataService.getAppExecutableFiles( - projectDir - ); + const files = + this.$projectDataService.getAppExecutableFiles(projectDir); const shortImports = this.getDeprecatedShortImportsInFiles( files, - projectDir + projectDir, ); if (shortImports.length) { this.$logger.printMarkdown( - "Detected short imports in your application. Please note that `short imports are deprecated` since NativeScript 5.2.0. More information can be found in this blogpost https://www.nativescript.org/blog/say-goodbye-to-short-imports-in-nativescript" + "Detected short imports in your application. Please note that `short imports are deprecated` since NativeScript 5.2.0. More information can be found in this blogpost https://www.nativescript.org/blog/say-goodbye-to-short-imports-in-nativescript", ); shortImports.forEach((shortImport) => { this.$logger.printMarkdown( - `In file \`${shortImport.file}\` line \`${shortImport.line}\` is short import. Add \`tns-core-modules/\` in front of the required/imported module.` + `In file \`${shortImport.file}\` line \`${shortImport.line}\` is short import. Add \`tns-core-modules/\` in front of the required/imported module.`, ); }); } } catch (err) { this.$logger.trace( `Unable to validate if project has short imports. Error is`, - err + err, ); } } @@ -271,7 +277,7 @@ export class DoctorService implements IDoctorService { protected getDeprecatedShortImportsInFiles( files: string[], - projectDir: string + projectDir: string, ): { file: string; line: string }[] { const shortImportRegExp = this.getShortImportRegExp(projectDir); const shortImports: { file: string; line: string }[] = []; @@ -280,13 +286,13 @@ export class DoctorService implements IDoctorService { const fileContent = this.$fs.readText(file); const strippedComments = helpers.stripComments(fileContent); const linesToCheck = _.flatten( - strippedComments.split(/\r?\n/).map((line) => line.split(";")) + strippedComments.split(/\r?\n/).map((line) => line.split(";")), ); const linesWithRequireStatements = linesToCheck.filter( (line) => /\btns-core-modules\b/.exec(line) === null && - (/\bimport\b/.exec(line) || /\brequire\b/.exec(line)) + (/\bimport\b/.exec(line) || /\brequire\b/.exec(line)), ); for (const line of linesWithRequireStatements) { @@ -305,14 +311,14 @@ export class DoctorService implements IDoctorService { const pathToTnsCoreModules = path.join( projectDir, NODE_MODULES_FOLDER_NAME, - TNS_CORE_MODULES_NAME + TNS_CORE_MODULES_NAME, ); const coreModulesSubDirs = this.$fs .readDirectory(pathToTnsCoreModules) .filter((entry) => this.$fs .getFsStats(path.join(pathToTnsCoreModules, entry)) - .isDirectory() + .isDirectory(), ); const stringRegularExpressionsPerDir = coreModulesSubDirs.map((c) => { @@ -332,13 +338,13 @@ export class DoctorService implements IDoctorService { private async runSetupScriptCore( executablePath: string, - setupScriptArgs: string[] + setupScriptArgs: string[], ): Promise { return this.$childProcess.spawnFromEvent( executablePath, setupScriptArgs, "close", - { stdio: "inherit" } + { stdio: "inherit" }, ); } @@ -346,12 +352,12 @@ export class DoctorService implements IDoctorService { if (this.$hostInfo.isWindows) { this.$logger.info( "TIP: To avoid setting up the necessary environment variables, you can use the chocolatey package manager to install the Android SDK and its dependencies." + - EOL + EOL, ); } else if (this.$hostInfo.isDarwin) { this.$logger.info( "TIP: To avoid setting up the necessary environment variables, you can use the Homebrew package manager to install the Android SDK and its dependencies." + - EOL + EOL, ); } } @@ -390,20 +396,20 @@ export class DoctorService implements IDoctorService { private filterInfosByType( infos: NativeScriptDoctor.IInfo[], - type: string + type: string, ): NativeScriptDoctor.IInfo[] { return infos.filter((info) => info.type === type); } private getKeyForConfiguration( - sysInfoConfig?: NativeScriptDoctor.ISysInfoConfig + sysInfoConfig?: NativeScriptDoctor.ISysInfoConfig, ): string { const nativeScriptData = sysInfoConfig && sysInfoConfig.projectDir && JSON.stringify( this.$fs.readJson(path.join(sysInfoConfig.projectDir, "package.json")) - .nativescript + .nativescript, ); const delimiter = "__"; const key = [ @@ -426,7 +432,7 @@ export class DoctorService implements IDoctorService { private async getInfos( cacheConfig: { forceCheck: boolean }, - sysInfoConfig?: NativeScriptDoctor.ISysInfoConfig + sysInfoConfig?: NativeScriptDoctor.ISysInfoConfig, ): Promise { const key = this.getKeyForConfiguration(sysInfoConfig); @@ -434,13 +440,13 @@ export class DoctorService implements IDoctorService { ? null : await this.$jsonFileSettingsService.getSettingValue< NativeScriptDoctor.IInfo[] - >(key); + >(key); this.$logger.trace( `getInfos cacheConfig options:`, cacheConfig, " current info from cache: ", - infosFromCache + infosFromCache, ); const infos = infosFromCache || (await doctor.getInfos(sysInfoConfig)); @@ -448,4 +454,4 @@ export class DoctorService implements IDoctorService { return infos; } } -injector.register("doctorService", DoctorService); +injector.register("doctorService", DoctorServiceImpl); diff --git a/lib/services/extensibility-service.ts b/lib/services/extensibility-service.ts index eff1fb0f0a..4129c2b9d6 100644 --- a/lib/services/extensibility-service.ts +++ b/lib/services/extensibility-service.ts @@ -3,6 +3,7 @@ import * as _ from "lodash"; import { cache } from "../common/decorators"; import * as constants from "../constants"; import { createRegExp, regExpEscape } from "../common/helpers"; +import { reportDeprecation } from "../common/deprecation"; import { INodePackageManager, INpmsSingleResultData } from "../declarations"; import { IFileSystem, @@ -144,6 +145,11 @@ export class ExtensibilityService implements IExtensibilityService { await this.assertExtensionIsInstalled(extensionName); const pathToExtension = this.getPathToExtension(extensionName); + reportDeprecation({ + api: "extensions.require-time-registration", + detail: extensionName, + logger: this.$logger, + }); this.$requireService.require(pathToExtension); return this.getInstalledExtensionData(extensionName); } catch (error) { diff --git a/lib/services/project-name-service.ts b/lib/services/project-name-service.ts index 5b595d207e..882988d241 100644 --- a/lib/services/project-name-service.ts +++ b/lib/services/project-name-service.ts @@ -3,18 +3,21 @@ import { IProjectNameService } from "../declarations"; import { IErrors } from "../common/declarations"; import * as _ from "lodash"; import { injector } from "../common/yok"; +import { ProjectNameService as ProjectNameServiceContract } from "../contracts/project-name-service"; -export class ProjectNameService implements IProjectNameService { +export class ProjectNameServiceImpl + implements IProjectNameService, ProjectNameServiceContract +{ constructor( private $projectNameValidator: IProjectNameValidator, private $errors: IErrors, private $logger: ILogger, - private $prompter: IPrompter + private $prompter: IPrompter, ) {} public async ensureValidName( projectName: string, - validateOptions?: { force: boolean } + validateOptions?: { force: boolean }, ): Promise { if (validateOptions && validateOptions.force) { return projectName; @@ -24,7 +27,7 @@ export class ProjectNameService implements IProjectNameService { return await this.promptForNewName( "The project name is invalid.", projectName, - validateOptions + validateOptions, ); } @@ -33,28 +36,28 @@ export class ProjectNameService implements IProjectNameService { if (!this.checkIfNameStartsWithLetter(projectName)) { if (!userCanInteract) { this.$errors.fail( - "The project name does not start with letter and will fail to build for Android. If You want to create project with this name add --force to the create command." + "The project name does not start with letter and will fail to build for Android. If You want to create project with this name add --force to the create command.", ); } return await this.promptForNewName( "The project name does not start with letter and will fail to build for Android.", projectName, - validateOptions + validateOptions, ); } if (projectName.toUpperCase() === "APP") { if (!userCanInteract) { this.$errors.fail( - "You cannot build applications named 'app' in Xcode. Consider creating a project with different name. If You want to create project with this name add --force to the create command." + "You cannot build applications named 'app' in Xcode. Consider creating a project with different name. If You want to create project with this name add --force to the create command.", ); } return await this.promptForNewName( "You cannot build applications named 'app' in Xcode. Consider creating a project with different name.", projectName, - validateOptions + validateOptions, ); } @@ -69,27 +72,27 @@ export class ProjectNameService implements IProjectNameService { private async promptForNewName( warningMessage: string, projectName: string, - validateOptions?: { force: boolean } + validateOptions?: { force: boolean }, ): Promise { if (await this.promptForForceNameConfirm(warningMessage)) { return projectName; } const newProjectName = await this.$prompter.getString( - "Enter the new project name:" + "Enter the new project name:", ); return await this.ensureValidName(newProjectName, validateOptions); } private async promptForForceNameConfirm( - warningMessage: string + warningMessage: string, ): Promise { this.$logger.warn(warningMessage); return await this.$prompter.confirm( - "Do you want to create the project with this name?" + "Do you want to create the project with this name?", ); } } -injector.register("projectNameService", ProjectNameService); +injector.register("projectNameService", ProjectNameServiceImpl); diff --git a/scripts/copy-assets.js b/scripts/copy-assets.js index b9d7ae0ac2..74af541285 100644 --- a/scripts/copy-assets.js +++ b/scripts/copy-assets.js @@ -14,7 +14,15 @@ const release = process.argv.includes("--release"); // packed, so they have to be mirrored into dist for `npm pack` to bundle them const BUNDLED = ["universal-analytics", "debug", "ms", "uuid"]; -const SIBLING_DIRS = ["resources", "docs", "config", "vendor", "bin", "setup"]; +const SIBLING_DIRS = [ + "resources", + "docs", + "config", + "vendor", + "bin", + "setup", + "contracts", +]; // npm picks README/LICENSE/CHANGELOG up from the directory being packed, so // they have to exist inside dist or they silently drop out of the tarball const ROOT_FILES = [ @@ -37,7 +45,8 @@ function isExcluded(relPath) { return false; } return RELEASE_EXCLUDES.some( - (excluded) => relPath === excluded || relPath.startsWith(excluded + path.sep) + (excluded) => + relPath === excluded || relPath.startsWith(excluded + path.sep), ); } @@ -97,7 +106,7 @@ copyTree( "lib", (relPath) => (!relPath.endsWith(".ts") || relPath.endsWith(".d.ts")) && - !isCompilerOutput(relPath) + !isCompilerOutput(relPath), ); for (const dir of SIBLING_DIRS) { @@ -121,7 +130,7 @@ writeManifest(); function writeManifest() { const pkg = JSON.parse( - fs.readFileSync(path.join(rootDir, "package.json"), "utf8") + fs.readFileSync(path.join(rootDir, "package.json"), "utf8"), ); // dist is the package root once published, so entrypoints lose the dist/ @@ -140,10 +149,10 @@ function writeManifest() { fs.writeFileSync( path.join(distDir, "package.json"), - JSON.stringify(pkg, null, 2) + "\n" + JSON.stringify(pkg, null, 2) + "\n", ); } console.log( - `assets: ${copied} copied, ${skipped} up to date${release ? " (release)" : ""}` + `assets: ${copied} copied, ${skipped} up to date${release ? " (release)" : ""}`, ); diff --git a/test/compat/injector-facade-surface.ts b/test/compat/injector-facade-surface.ts new file mode 100644 index 0000000000..96f4e3c522 --- /dev/null +++ b/test/compat/injector-facade-surface.ts @@ -0,0 +1,131 @@ +import { assert } from "chai"; +import { Yok, getInjector } from "../../lib/common/yok"; +import { Injector, inject, runInInjectionContext } from "../../lib/common/di"; +import { + CommandRegistry, + KeyCommandRegistry, + ModuleRegistry, + PublicApiBuilder, +} from "../../lib/common/contracts"; + +// Pins the externally reachable injector surface: every IInjector member +// (lib/common/definitions/yok.d.ts) plus dispose, subclassability, the +// injector self-registration, and the global assignment. The Yok facade must +// keep all of these behaving through the DI migration. + +const FACADE_METHODS = [ + "require", + "requirePublic", + "requirePublicClass", + "requireCommand", + "requireKeyCommand", + "resolve", + "resolveCommand", + "resolveKeyCommand", + "register", + "registerCommand", + "registerKeyCommand", + "getRegisteredCommandsNames", + "getRegisteredKeyCommandsNames", + "dynamicCall", + "getDynamicCallData", + "isDefaultCommand", + "isValidHierarchicalCommand", + "getChildrenCommandsNames", + "buildHierarchicalCommand", + "dispose", +]; + +describe("injector facade surface", () => { + it("exposes every IInjector member", () => { + const inj: any = new Yok(); + for (const member of FACADE_METHODS) { + assert.isFunction(inj[member], `missing facade method: ${member}`); + } + assert.instanceOf(inj.dynamicCallRegex, RegExp); + assert.isObject(inj.publicApi); + assert.isObject(inj.publicApi.__modules__); + assert.isBoolean(inj.overrideAlreadyRequiredModule); + }); + + it("registers itself under 'injector', resolvable with and without the $ prefix", () => { + const inj = new Yok(); + assert.strictEqual(inj.resolve("injector"), inj); + assert.strictEqual(inj.resolve("$injector"), inj); + }); + + it("remains subclassable (the InjectorStub pattern in test/stubs.ts)", () => { + class SubInjector extends Yok {} + const sub = new SubInjector(); + sub.register("subclassed", { value: 42 }); + assert.equal(sub.resolve("subclassed").value, 42); + assert.strictEqual(sub.resolve("injector"), sub); + }); + + it("keeps getInjector() synchronized with a direct global.$injector assignment", () => { + const previous = getInjector(); + const fresh = new Yok(); + + (global).$injector = fresh; + try { + assert.strictEqual(getInjector(), fresh); + } finally { + (global).$injector = previous; + } + assert.strictEqual(getInjector(), previous); + }); + + it("assigns the process-wide global.$injector", () => { + assert.isOk((global).$injector); + for (const member of FACADE_METHODS) { + assert.isFunction( + (global).$injector[member], + `global.$injector missing: ${member}`, + ); + } + }); + + it("IS an Injector: instanceof holds and the new API works on the facade directly", () => { + const inj = new Yok(); + assert.instanceOf(inj, Injector); + + // Provider-form registration dispatches to the container... + inj.register({ provide: "viaProvider", useValue: { tag: 1 } }); + assert.equal(inj.get("viaProvider").tag, 1); + // ...while string-form registration keeps legacy semantics. + inj.register("viaLegacy", { tag: 2 }); + assert.equal(inj.resolve("viaLegacy").tag, 2); + assert.strictEqual(inj.get("viaLegacy"), inj.resolve("viaLegacy")); + + // One identity: the injection context IS the facade. + runInInjectionContext(inj, () => { + assert.strictEqual(inject(Injector), inj); + }); + }); + + it("registers its subsystem faces as tokens that resolve to the facade", () => { + const inj = new Yok(); + + for (const token of [ + CommandRegistry, + KeyCommandRegistry, + ModuleRegistry, + PublicApiBuilder, + ]) { + assert.strictEqual(inj.get(token), inj); + } + assert.strictEqual(inj.resolve("commandRegistry"), inj); + + runInInjectionContext(inj, () => { + assert.strictEqual(inject(CommandRegistry), inj); + }); + }); + + it("calls lowercase/anonymous resolvers as factories instead of new-ing them", () => { + const inj = new Yok(); + inj.register("factoryMade", function () { + return { madeByFactory: true }; + }); + assert.isTrue(inj.resolve("factoryMade").madeByFactory); + }); +}); diff --git a/test/compat/legacy-extension.ts b/test/compat/legacy-extension.ts new file mode 100644 index 0000000000..42acdbd0da --- /dev/null +++ b/test/compat/legacy-extension.ts @@ -0,0 +1,110 @@ +import { assert } from "chai"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { ICliGlobal } from "../../lib/common/definitions/cli-global"; + +// Pins the published extension contract: an extension is require()d and +// registers its contributions by mutating global.$injector — commands via +// requireCommand/registerCommand (lazy, path-based), services via register. +// extending-cli.md advertises this surface. + +const cliGlobal = (global); + +describe("legacy extension contract", () => { + let extDir: string; + let capture: any; + + beforeEach(() => { + extDir = fs.mkdtempSync(path.join(os.tmpdir(), "ns-compat-ext-")); + capture = (global).__extCapture = {}; + }); + + afterEach(() => { + fs.rmSync(extDir, { recursive: true, force: true }); + delete (global).__extCapture; + }); + + it("an unmodified extension contributes services and hierarchical commands via global.$injector", async () => { + const commandPath = path.join(extDir, "meow-command"); + fs.writeFileSync( + commandPath + ".js", + `class MeowPurrCommand { + constructor($extCompatService) { + this.extCompatService = $extCompatService; + this.allowedParameters = []; + } + async execute(args) { + global.__extCapture.executedWith = args; + global.__extCapture.service = this.extCompatService; + } + } + global.$injector.registerCommand("meowcompat|purr", MeowPurrCommand);`, + ); + fs.writeFileSync( + path.join(extDir, "main.js"), + `global.$injector.register("extCompatService", { name: "ext-compat-service" }); + global.$injector.requireCommand("meowcompat|purr", ${JSON.stringify( + commandPath, + )});`, + ); + + require(path.join(extDir, "main.js")); + + // Registration is lazy: the command module must not load until resolved. + assert.isUndefined(capture.executedWith); + + // Services registered by the extension resolve under both spellings. + const service = cliGlobal.$injector.resolve("extCompatService"); + assert.strictEqual( + cliGlobal.$injector.resolve("$extCompatService"), + service, + ); + assert.equal(service.name, "ext-compat-service"); + + // The command participates in the hierarchical router. + assert.include( + cliGlobal.$injector.getRegisteredCommandsNames(false), + "meowcompat|purr", + ); + assert.include( + cliGlobal.$injector.getChildrenCommandsNames("meowcompat"), + "purr", + ); + const built = cliGlobal.$injector.buildHierarchicalCommand("meowcompat", [ + "purr", + "extra-arg", + ]); + assert.equal(built.commandName, "meowcompat|purr"); + assert.deepEqual(built.remainingArguments, ["extra-arg"]); + + // The leaf command resolves, gets its DI-injected service, and executes. + const command = cliGlobal.$injector.resolveCommand("meowcompat|purr"); + assert.isOk(command); + await command.execute(["fluffy"]); + assert.deepEqual(capture.executedWith, ["fluffy"]); + assert.equal(capture.service.name, "ext-compat-service"); + + // registerCommand on a hierarchical name synthesized a parent dispatcher. + const parent = cliGlobal.$injector.resolveCommand("meowcompat"); + assert.isTrue(parent.isHierarchicalCommand); + }); + + it("claiming an already-required command name throws unless overrideAlreadyRequiredModule is set", () => { + const firstPath = path.join(extDir, "first"); + fs.writeFileSync(firstPath + ".js", `module.exports = {};`); + + cliGlobal.$injector.requireCommand("conflictcompat", firstPath); + assert.throws( + () => cliGlobal.$injector.requireCommand("conflictcompat", firstPath), + /require'd twice/, + ); + + cliGlobal.$injector.overrideAlreadyRequiredModule = true; + try { + cliGlobal.$injector.requireCommand("conflictcompat", firstPath); + } finally { + cliGlobal.$injector.overrideAlreadyRequiredModule = false; + } + }); +}); diff --git a/test/compat/legacy-hooks.ts b/test/compat/legacy-hooks.ts new file mode 100644 index 0000000000..ed8c1d9f66 --- /dev/null +++ b/test/compat/legacy-hooks.ts @@ -0,0 +1,345 @@ +import { assert } from "chai"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { Yok, getInjector, setGlobalInjector } from "../../lib/common/yok"; +import { HooksService } from "../../lib/common/services/hooks-service"; +import { hook } from "../../lib/common/helpers"; +import { IInjector } from "../../lib/common/definitions/yok"; +import { IHooksService } from "../../lib/common/declarations"; +import { LoggerStub, ErrorsStub } from "../stubs"; + +// Pins the published third-party hook contract: hooks are plain JS files whose +// exported function is resolved by its own parameter names (`$logger`, +// `hookArgs`, ...). Payload shapes and influence channels covered here are the +// compatibility bar for any DI changes. + +function createTestInjector(projectDir: string): IInjector { + const testInjector = new Yok(); + testInjector.register("logger", LoggerStub); + testInjector.register("errors", ErrorsStub); + testInjector.register("fs", { + exists: (p: string) => fs.existsSync(p), + getFsStats: (p: string) => fs.statSync(p), + readDirectory: (p: string) => fs.readdirSync(p), + readText: (p: string) => fs.readFileSync(p, "utf8"), + }); + testInjector.register("childProcess", {}); + testInjector.register("config", { DISABLE_HOOKS: false }); + testInjector.register("staticConfig", { + CLIENT_NAME: "tns", + version: "0.0.0", + }); + testInjector.register("projectHelper", { projectDir }); + testInjector.register("options", { hooks: true }); + testInjector.register("performanceService", { + now: () => 0, + processExecutionData: () => { + /* not measured here */ + }, + }); + testInjector.register("projectConfigService", { + getValue: (_key: string, defaultValue: any) => defaultValue, + }); + testInjector.register("projectData", { fromContainer: true }); + testInjector.register("hooksService", HooksService); + return testInjector; +} + +function writeHook(projectDir: string, hookName: string, source: string): void { + const hooksDir = path.join(projectDir, "hooks"); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, `${hookName}.js`), source); +} + +describe("legacy hook contract", () => { + let projectDir: string; + let testInjector: IInjector; + let capture: any; + + const hooksService = (): IHooksService => + testInjector.resolve("hooksService"); + const logger = (): LoggerStub => testInjector.resolve("logger"); + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ns-compat-hooks-")); + testInjector = createTestInjector(projectDir); + capture = (global).__hookCapture = {}; + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + delete (global).__hookCapture; + }); + + it("injects services by $-prefixed parameter name and passes hookArgs by identity", async () => { + writeHook( + projectDir, + "before-case1", + `module.exports = function ($logger, $injector, hookArgs) { + global.__hookCapture.logger = $logger; + global.__hookCapture.injector = $injector; + global.__hookCapture.hookArgs = hookArgs; + };`, + ); + + const payload = { anything: 1 }; + await hooksService().executeBeforeHooks("case1", { hookArgs: payload }); + + assert.strictEqual(capture.logger, testInjector.resolve("logger")); + assert.strictEqual(capture.injector, testInjector); + assert.strictEqual(capture.hookArgs, payload); + assert.include(logger().traceOutput, "hooks.param-name-signature"); + }); + + it("derives the hook name from the pre-| part of hierarchical command names", async () => { + writeHook( + projectDir, + "before-case2", + `module.exports = function (hookArgs) { global.__hookCapture.ran = true; };`, + ); + + await hooksService().executeBeforeHooks("case2|android", { + hookArgs: {}, + }); + + assert.isTrue(capture.ran); + }); + + it("promotes hookArgs.projectData so it shadows the container's projectData under both spellings", async () => { + writeHook( + projectDir, + "after-case3", + `module.exports = function ($projectData, projectData) { + global.__hookCapture.dollar = $projectData; + global.__hookCapture.plain = projectData; + };`, + ); + + const payloadProjectData = { projectDir, fromPayload: true }; + await hooksService().executeAfterHooks("case3", { + hookArgs: { projectData: payloadProjectData }, + }); + + assert.strictEqual(capture.dollar, payloadProjectData); + assert.strictEqual(capture.plain, payloadProjectData); + }); + + it("lets a hook mutate the payload in a way the caller observes (before-build-task-args channel)", async () => { + writeHook( + projectDir, + "before-build-task-args", + `module.exports = function (hookArgs) { hookArgs.args.push("--offline"); };`, + ); + + const args = ["assembleDebug"]; + await hooksService().executeBeforeHooks("build-task-args", { + hookArgs: { args }, + }); + + assert.deepEqual(args, ["assembleDebug", "--offline"]); + // A hookArgs-only signature is the recommended pattern and must not be + // reported as param-name injection. + assert.notInclude(logger().traceOutput, "hooks.param-name-signature"); + }); + + it("treats a rejection carrying stopExecution + errorAsWarning as a warning, not a failure", async () => { + writeHook( + projectDir, + "before-case5", + `module.exports = async function () { + const err = new Error("abort-as-warning"); + err.stopExecution = false; + err.errorAsWarning = true; + throw err; + };`, + ); + + await hooksService().executeBeforeHooks("case5"); + + assert.include(logger().warnOutput, "abort-as-warning"); + }); + + it("fails command execution when a hook rejects without errorAsWarning", async () => { + writeHook( + projectDir, + "before-case6", + `module.exports = async function () { throw new Error("hard-abort"); };`, + ); + + await assert.isRejected( + hooksService().executeBeforeHooks("case6"), + /hard-abort/, + ); + }); + + it("runs hooks with no payload at all (every command name is a hook point)", async () => { + writeHook( + projectDir, + "before-case7", + `module.exports = function ($logger) { global.__hookCapture.ran = true; };`, + ); + + await hooksService().executeBeforeHooks("case7"); + + assert.isTrue(capture.ran); + }); + + it("skips (with a warning) a hook whose parameters name unwrapped payload keys", async () => { + // after-watchAction-style payloads pass keys at the top level with no + // hookArgs wrapper. validateHookArguments only consults the container, so + // a hook naming such a key is skipped as invalid — today's behavior, which + // the migration must preserve exactly. + writeHook( + projectDir, + "after-case8", + `module.exports = function (liveSyncResultInfo) { global.__hookCapture.ran = true; };`, + ); + + await hooksService().executeAfterHooks("case8", { + liveSyncResultInfo: { fake: true }, + }); + + assert.isUndefined(capture.ran); + assert.include(logger().warnOutput, "invalid arguments"); + }); + + it("folds a function returned by a before-hook into a middleware chain around the @hook-decorated method", async () => { + writeHook( + projectDir, + "before-case9", + `module.exports = function (hookArgs) { + return function (args, originalMethod) { + global.__hookCapture.middlewareArgs = args.slice(); + return originalMethod.apply(null, args).then(function (result) { + return "wrapped(" + result + ")"; + }); + }; + };`, + ); + + class Subject { + constructor(public $hooksService: IHooksService) {} + + @hook("case9") + async doWork(input: string): Promise { + (global).__hookCapture.originalRan = true; + return "original:" + input; + } + } + + const subject = testInjector.resolve(Subject); + const result = await subject.doWork("x"); + + assert.equal(result, "wrapped(original:x)"); + assert.isTrue(capture.originalRan); + assert.deepEqual(capture.middlewareArgs, ["x"]); + }); + + it("lets a middleware short-circuit so the original method never runs", async () => { + writeHook( + projectDir, + "before-case10", + `module.exports = function () { + return function (args, originalMethod) { + return "short-circuited"; + }; + };`, + ); + + class Subject { + constructor(public $hooksService: IHooksService) {} + + @hook("case10") + async doWork(): Promise { + (global).__hookCapture.originalRan = true; + return "original"; + } + } + + const subject = testInjector.resolve(Subject); + const result = await subject.doWork(); + + assert.equal(result, "short-circuited"); + assert.isUndefined(capture.originalRan); + }); + + it("runs hook bodies in an injection context, so inject() resolves services", async () => { + const diPath = require.resolve("../../lib/common/di"); + writeHook( + projectDir, + "before-case-inject", + `const { inject, Injector } = require(${JSON.stringify(diPath)}); + module.exports = function (hookArgs) { + global.__hookCapture.logger = inject("logger"); + global.__hookCapture.container = inject(Injector); + global.__hookCapture.hookArgs = hookArgs; + };`, + ); + + const payload = { sample: true }; + await hooksService().executeBeforeHooks("case-inject", { + hookArgs: payload, + }); + + assert.strictEqual(capture.logger, testInjector.resolve("logger")); + assert.strictEqual(capture.container, testInjector); + assert.strictEqual(capture.hookArgs, payload); + }); + + it("@hook falls back to the global injector when the class has neither $hooksService nor $injector", async () => { + writeHook( + projectDir, + "before-case11", + `module.exports = function () { global.__hookCapture.ran = true; };`, + ); + + class Subject { + @hook("case11") + async doWork(): Promise { + return "ok"; + } + } + + const previousInjector = getInjector(); + setGlobalInjector(testInjector); + try { + const result = await new Subject().doWork(); + assert.equal(result, "ok"); + assert.isTrue(capture.ran); + } finally { + setGlobalInjector(previousInjector); + } + }); + + it("@hook prefers the instance's $injector over the global injector", async () => { + writeHook( + projectDir, + "before-case12", + `module.exports = function () { global.__hookCapture.ran = true; };`, + ); + + class Subject { + public $injector = testInjector; + + @hook("case12") + async doWork(): Promise { + return "ok"; + } + } + + const previousInjector = getInjector(); + setGlobalInjector({ + resolve: () => { + throw new Error("the process-wide injector must be the last resort"); + }, + }); + try { + const result = await new Subject().doWork(); + assert.equal(result, "ok"); + assert.isTrue(capture.ran); + } finally { + setGlobalInjector(previousInjector); + } + }); +}); diff --git a/test/contracts.ts b/test/contracts.ts new file mode 100644 index 0000000000..c3536f7a4f --- /dev/null +++ b/test/contracts.ts @@ -0,0 +1,31 @@ +import { assert } from "chai"; +import { getContractName, Injector, provide } from "../lib/common/di"; +import { DoctorService, ProjectNameService } from "../lib/contracts"; +import type { ISpawnResult } from "../lib/common/declarations"; + +describe("contracts tranche", () => { + it("carries decorator-set token names matching the Yok-era registrations", () => { + assert.equal(getContractName(DoctorService), "doctorService"); + assert.equal(getContractName(ProjectNameService), "projectNameService"); + }); + + it("resolves via the class, the legacy name, and the $-spelling to one instance", () => { + class StubDoctorService extends DoctorService { + async printWarnings(): Promise {} + async runSetupScript(): Promise { + return {}; + } + async canExecuteLocalBuild(): Promise { + return true; + } + checkForDeprecatedShortImportsInAppDir(): void {} + } + + const injector = new Injector([provide(DoctorService, StubDoctorService)]); + + const byClass = injector.get(DoctorService); + assert.instanceOf(byClass, StubDoctorService); + assert.strictEqual(injector.get("doctorService"), byClass); + assert.strictEqual(injector.get("$doctorService"), byClass); + }); +}); diff --git a/test/deprecation.ts b/test/deprecation.ts new file mode 100644 index 0000000000..8de2df0592 --- /dev/null +++ b/test/deprecation.ts @@ -0,0 +1,101 @@ +import { assert } from "chai"; +import { Yok, getInjector, setGlobalInjector } from "../lib/common/yok"; +import { + reportDeprecation, + clearReportedDeprecations, +} from "../lib/common/deprecation"; +import { LoggerStub } from "./stubs"; + +describe("deprecation tracer", () => { + let logger: LoggerStub; + let originalEnv: string | undefined; + + beforeEach(() => { + logger = new LoggerStub(); + clearReportedDeprecations(); + originalEnv = process.env.NS_DEPRECATIONS; + delete process.env.NS_DEPRECATIONS; + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.NS_DEPRECATIONS; + } else { + process.env.NS_DEPRECATIONS = originalEnv; + } + }); + + it("logs at trace level by default and reports once per api+detail", () => { + reportDeprecation({ api: "test.api", detail: "site-1", logger }); + reportDeprecation({ api: "test.api", detail: "site-1", logger }); + + const occurrences = logger.traceOutput.split("test.api").length - 1; + assert.equal(occurrences, 1); + assert.equal(logger.warnOutput, ""); + + reportDeprecation({ api: "test.api", detail: "site-2", logger }); + assert.include(logger.traceOutput, "site-2"); + }); + + it("escalates to warn with NS_DEPRECATIONS=warn", () => { + process.env.NS_DEPRECATIONS = "warn"; + + reportDeprecation({ api: "test.warn", logger }); + + assert.include(logger.warnOutput, "test.warn"); + assert.equal(logger.traceOutput, ""); + }); + + it("throws with NS_DEPRECATIONS=error — on every call, not just the first", () => { + process.env.NS_DEPRECATIONS = "error"; + + assert.throws( + () => reportDeprecation({ api: "test.error", logger }), + /test\.error/, + ); + assert.throws( + () => reportDeprecation({ api: "test.error", logger }), + /test\.error/, + ); + }); + + it("falls back to the process-wide injector's logger when none is passed", () => { + const previousInjector = getInjector(); + const freshInjector = new Yok(); + const freshLogger = new LoggerStub(); + freshInjector.register("logger", freshLogger); + setGlobalInjector(freshInjector); + + try { + reportDeprecation({ api: "test.global-logger" }); + assert.include(freshLogger.traceOutput, "test.global-logger"); + } finally { + setGlobalInjector(previousInjector); + } + }); + + it("drops the report silently when no logger is resolvable", () => { + const previousInjector = getInjector(); + setGlobalInjector(new Yok()); + + try { + assert.doesNotThrow(() => reportDeprecation({ api: "test.no-logger" })); + } finally { + setGlobalInjector(previousInjector); + } + }); + + it("still delivers a report that was previously dropped for lack of a logger", () => { + const previousInjector = getInjector(); + setGlobalInjector(new Yok()); + try { + reportDeprecation({ api: "test.redeliver" }); + } finally { + setGlobalInjector(previousInjector); + } + + reportDeprecation({ api: "test.redeliver", logger }); + + assert.include(logger.traceOutput, "test.redeliver"); + }); +}); diff --git a/test/di.ts b/test/di.ts new file mode 100644 index 0000000000..5739a52b7c --- /dev/null +++ b/test/di.ts @@ -0,0 +1,463 @@ +import { assert } from "chai"; +import { + Injector, + inject, + runInInjectionContext, + Contract, + provide, + forwardRef, +} from "../lib/common/di"; + +@Contract({ name: "diTestGreeter" }) +abstract class Greeter { + abstract greet(): string; +} + +class GreeterImpl extends Greeter { + greet(): string { + return "hello"; + } +} + +@Contract({ name: "diTestDevice" }) +abstract class Device { + abstract id: string; +} + +describe("di: tokens and resolution", () => { + it("resolves one singleton via the class, the name, and the $-prefixed name", () => { + const injector = new Injector([provide(Greeter, GreeterImpl)]); + + const byClass = injector.get(Greeter); + assert.instanceOf(byClass, GreeterImpl); + assert.strictEqual(injector.get("diTestGreeter"), byClass); + assert.strictEqual(injector.get("$diTestGreeter"), byClass); + }); + + it("does not treat an implementation class as a token via its inherited contract name", () => { + const injector = new Injector([provide(Greeter, GreeterImpl)]); + + assert.throws(() => injector.get(GreeterImpl), /unable to resolve/); + }); + + it("throws on a duplicate contract name at declaration time", () => { + assert.throws(() => { + @Contract({ name: "diTestGreeter" }) + abstract class Duplicate {} + void Duplicate; + }, /already used/); + }); + + it("resolves a duplicated contract copy (same name, different class object) to the same provider", () => { + const injector = new Injector([provide(Greeter, GreeterImpl)]); + const original = injector.get(Greeter); + + // Simulates what a duplicated CLI copy's decorator does: same Symbol.for + // key, its own class object, its own registry. + abstract class DuplicatedCopy {} + Object.defineProperty( + DuplicatedCopy, + Symbol.for("nativescript:di:contractName"), + { value: "diTestGreeter" }, + ); + + assert.strictEqual(injector.get(DuplicatedCopy), original); + }); +}); + +describe("di: lazy providers", () => { + it("does not invoke the loader until first get()", () => { + let loads = 0; + const injector = new Injector([ + { + provide: "lazyThing", + useLazyClass: () => { + loads++; + return GreeterImpl; + }, + }, + ]); + + assert.equal(loads, 0); + const first = injector.get("lazyThing"); + assert.equal(loads, 1); + assert.strictEqual(injector.get("lazyThing"), first); + assert.equal(loads, 1); + }); + + it("consumes a pending side-effect loader once, expecting it to register the resolver", () => { + let loads = 0; + const injector = new Injector(); + injector.register({ + provide: "lazyRequired", + useLazyRequire: () => { + loads++; + injector.register({ + provide: "lazyRequired", + useLegacyClass: class LazyRequiredThing {}, + }); + }, + }); + + const instance = injector.get("lazyRequired"); + assert.isOk(instance); + assert.equal(loads, 1); + assert.strictEqual(injector.get("lazyRequired"), instance); + assert.equal(loads, 1); + }); +}); + +describe("di: per-level lookup order", () => { + it("a child's string-keyed override shadows the parent for class-token consumers", () => { + const root = new Injector([ + { provide: Device, useValue: { id: "shared-singleton" } }, + ]); + const child = root.createChild([ + { provide: "diTestDevice", useValue: { id: "per-call" } }, + ]); + + // The class key misses in the child; the name fallback must hit the + // child's entry BEFORE lookup delegates to the parent — otherwise the + // per-call override is silently skipped. + assert.equal(child.get(Device).id, "per-call"); + assert.equal(child.get("diTestDevice").id, "per-call"); + assert.equal(root.get(Device).id, "shared-singleton"); + }); +}); + +describe("di: child scopes", () => { + it("hydrated children see the payload, fall back for services, and stay isolated", () => { + const loggerValue = { log: true }; + const payloadA = { args: ["a"] }; + const payloadB = { args: ["b"] }; + + const root = new Injector([{ provide: "logger", useValue: loggerValue }]); + const childA = root.createChild([ + { provide: "hookArgs", useValue: payloadA }, + ]); + const childB = root.createChild([ + { provide: "hookArgs", useValue: payloadB }, + ]); + + assert.strictEqual(childA.get("hookArgs"), payloadA); + assert.strictEqual(childA.get("$hookArgs"), payloadA); + assert.strictEqual(childB.get("hookArgs"), payloadB); + assert.strictEqual(childA.get("logger"), loggerValue); + assert.throws(() => root.get("hookArgs"), /unable to resolve/); + }); + + it("inject(Injector) returns the nearest injector", () => { + const root = new Injector(); + const child = root.createChild(); + + runInInjectionContext(child, () => { + assert.strictEqual(inject(Injector), child); + }); + runInInjectionContext(root, () => { + assert.strictEqual(inject(Injector), root); + }); + }); +}); + +describe("di: forwardRef", () => { + it("defers a token reference from provider-literal creation to registration", () => { + let LateToken: any; + // The literal is built while the binding is still unassigned — the thunk + // is only read when the injector processes the provider. + const providers = [ + { provide: forwardRef(() => LateToken), useClass: GreeterImpl }, + ]; + LateToken = Greeter; + + const injector = new Injector(providers); + + const instance = injector.get(Greeter); + assert.instanceOf(instance, GreeterImpl); + assert.strictEqual(injector.get("diTestGreeter"), instance); + }); + + it("resolves forwardRef tokens at lookup and inside inject()", () => { + const injector = new Injector([provide(Greeter, GreeterImpl)]); + const direct = injector.get(Greeter); + + assert.strictEqual(injector.get(forwardRef(() => Greeter)), direct); + runInInjectionContext(injector, () => { + assert.strictEqual(inject(forwardRef(() => Greeter)), direct); + }); + }); +}); + +describe("di: inject options", () => { + it("optional resolves to null for an unknown token, and normally for a known one", () => { + const injector = new Injector([provide(Greeter, GreeterImpl)]); + + assert.isNull(injector.get("nothing-here", { optional: true })); + assert.instanceOf(injector.get(Greeter, { optional: true }), GreeterImpl); + + runInInjectionContext(injector, () => { + assert.isNull(inject("nothing-here", { optional: true })); + }); + }); + + it("optional does not swallow a found-but-misconfigured record", () => { + const injector = new Injector([ + { provide: "brokenLiteral", useValue: {}, shared: false }, + ]); + + assert.throws( + () => injector.get("brokenLiteral", { optional: true }), + /no resolver registered/, + ); + }); + + it("skipSelf escapes a child scope's shadowing entry", () => { + const root = new Injector([ + { provide: "logger", useValue: { from: "root" } }, + ]); + const child = root.createChild([ + { provide: "logger", useValue: { from: "payload" } }, + ]); + + assert.equal(child.get("logger").from, "payload"); + assert.equal(child.get("logger", { skipSelf: true }).from, "root"); + // On the root there is no parent to skip to. + assert.isNull(root.get("logger", { skipSelf: true, optional: true })); + }); + + it("self refuses parent fallthrough", () => { + const root = new Injector([{ provide: "rootOnly", useValue: { tag: 1 } }]); + const child = root.createChild([ + { provide: "childOnly", useValue: { tag: 2 } }, + ]); + + assert.equal(child.get("childOnly", { self: true }).tag, 2); + assert.throws( + () => child.get("rootOnly", { self: true }), + /unable to resolve/, + ); + assert.isNull(child.get("rootOnly", { self: true, optional: true })); + }); + + it("rejects combining self and skipSelf", () => { + const injector = new Injector(); + assert.throws( + () => injector.get("anything", { self: true, skipSelf: true }), + /cannot combine self and skipSelf/, + ); + }); +}); + +describe("di: cycles", () => { + it("reports the full resolution path", () => { + class CycleA { + constructor(public $cycleB: any) {} + } + class CycleB { + constructor(public $cycleA: any) {} + } + const injector = new Injector([ + { provide: "cycleA", useLegacyClass: CycleA }, + { provide: "cycleB", useLegacyClass: CycleB }, + ]); + + assert.throws( + () => injector.get("cycleA"), + /Cyclic dependency detected on dependency 'cycleA'.*cycleA -> cycleB -> cycleA/, + ); + }); +}); + +describe("di: transients and disposal", () => { + it("shared:false constructs per resolution, retains every instance, and disposes in reverse order", () => { + const disposed: number[] = []; + let seq = 0; + const injector = new Injector([ + { + provide: "transientThing", + shared: false, + useFactory: () => { + const id = ++seq; + return { + id, + dispose: () => disposed.push(id), + }; + }, + }, + ]); + + const first = injector.get("transientThing"); + const second = injector.get("transientThing"); + assert.notStrictEqual(first, second); + + injector.dispose(); + assert.deepEqual(disposed, [2, 1]); + }); + + it("disposes shared singletons in reverse instantiation order", () => { + const disposed: string[] = []; + const injector = new Injector([ + { + provide: "firstService", + useFactory: () => ({ dispose: () => disposed.push("first") }), + }, + { + provide: "secondService", + useFactory: () => ({ dispose: () => disposed.push("second") }), + }, + ]); + + injector.get("firstService"); + injector.get("secondService"); + injector.dispose(); + + assert.deepEqual(disposed, ["second", "first"]); + }); +}); + +describe("di: createInstance", () => { + class MidDep { + constructor(public $leafDep: any) {} + } + + class EntryConsumer { + constructor( + public $midDep: any, + public $leafDep: any, + ) {} + } + + it("resolves annotated $-params, with per-call providers shadowing one level only", () => { + const root = new Injector([ + { provide: "leafDep", useValue: { tag: "root-leaf" } }, + { provide: "midDep", useLegacyClass: MidDep }, + ]); + + const instance = root.createInstance(EntryConsumer, [ + { provide: "leafDep", useValue: { tag: "override-leaf" } }, + ]); + + assert.equal(instance.$leafDep.tag, "override-leaf"); + // The nested dependency is constructed by its owning injector, so the + // per-call override must not leak into it — Yok's bag never propagated. + assert.equal(instance.$midDep.$leafDep.tag, "root-leaf"); + }); + + it("applies a raw ctorArguments bag with own-key semantics and no $ normalization", () => { + const root = new Injector([ + { provide: "leafDep", useValue: { tag: "root-leaf" } }, + { provide: "midDep", useLegacyClass: MidDep }, + ]); + const fakeMid = { fake: "mid" }; + + const instance = root.createInstance(EntryConsumer, [], { + $midDep: fakeMid, + }); + + assert.strictEqual(instance.$midDep, fakeMid); + assert.equal(instance.$leafDep.tag, "root-leaf"); + }); + + it("invokes lowercase resolvers as factories instead of new-ing them", () => { + const injector = new Injector([ + { + provide: "factoryMade", + useLegacyClass: function makeThing() { + return { viaFactory: true }; + }, + }, + ]); + + assert.isTrue(injector.get("factoryMade").viaFactory); + }); +}); + +describe("di: inject()", () => { + it("works in field initializers during construction", () => { + class UsesInject { + public greeter = inject(Greeter); + public injector = inject(Injector); + } + const injector = new Injector([ + provide(Greeter, GreeterImpl), + { provide: "usesInject", useClass: UsesInject }, + ]); + + const instance = injector.get("usesInject"); + assert.instanceOf(instance.greeter, GreeterImpl); + assert.strictEqual(instance.injector, injector); + }); + + it("throws outside an injection context", () => { + assert.throws(() => inject(Greeter), /injection context/); + }); + + it("restores the previous context, including across nesting", () => { + const outer = new Injector(); + const inner = new Injector(); + + runInInjectionContext(outer, () => { + runInInjectionContext(inner, () => { + assert.strictEqual(inject(Injector), inner); + }); + assert.strictEqual(inject(Injector), outer); + }); + assert.throws(() => inject(Injector), /injection context/); + }); +}); + +describe("di: register semantics", () => { + it("a non-shared object literal has no resolver — Yok quirk preserved", () => { + const injector = new Injector([ + { provide: "literalTransient", useValue: {}, shared: false }, + ]); + + assert.throws( + () => injector.get("literalTransient"), + /no resolver registered/, + ); + }); + + it("re-registering a shared value replaces the cached instance", () => { + const injector = new Injector([{ provide: "config", useValue: { v: 1 } }]); + assert.equal(injector.get("config").v, 1); + + injector.register({ provide: "config", useValue: { v: 2 } }); + assert.equal(injector.get("config").v, 2); + }); + + it("re-registering a resolver keeps an already-cached instance", () => { + const injector = new Injector([ + { provide: "svc", useFactory: () => ({ v: 1 }) }, + ]); + const first = injector.get("svc"); + + injector.register({ provide: "svc", useFactory: () => ({ v: 2 }) }); + assert.strictEqual(injector.get("svc"), first); + }); + + it("a contract registration joins an existing record under the same name", () => { + const injector = new Injector([ + { provide: "diTestGreeter", useValue: { preexisting: true } }, + ]); + const cached = injector.get("diTestGreeter"); + + injector.register(provide(Greeter, GreeterImpl)); + + // Mutate-not-replace: the class key now aliases the same record, whose + // cached instance wins over the newly registered resolver. + assert.strictEqual(injector.get(Greeter), cached); + }); + + it("enumerates registered names by prefix", () => { + const injector = new Injector([ + { provide: "commands.build", useValue: {} }, + { provide: "commands.run", useValue: {} }, + { provide: "unrelated", useValue: {} }, + ]); + + assert.sameMembers(injector.getRegisteredNames("commands."), [ + "commands.build", + "commands.run", + ]); + }); +}); diff --git a/test/project-name-service.ts b/test/project-name-service.ts index 0e62fa70c3..10ec8b10ed 100644 --- a/test/project-name-service.ts +++ b/test/project-name-service.ts @@ -1,5 +1,5 @@ import { Yok } from "../lib/common/yok"; -import { ProjectNameService } from "../lib/services/project-name-service"; +import { ProjectNameServiceImpl as ProjectNameService } from "../lib/services/project-name-service"; import { assert } from "chai"; import { ErrorsStub, LoggerStub } from "./stubs"; import { IProjectNameService } from "../lib/declarations"; diff --git a/test/services/doctor-service.ts b/test/services/doctor-service.ts index 3c87443ff8..862480c02c 100644 --- a/test/services/doctor-service.ts +++ b/test/services/doctor-service.ts @@ -1,4 +1,4 @@ -import { DoctorService } from "../../lib/services/doctor-service"; +import { DoctorServiceImpl as DoctorService } from "../../lib/services/doctor-service"; import { Yok } from "../../lib/common/yok"; import { LoggerStub, FileSystemStub } from "../stubs"; import { assert } from "chai"; @@ -44,7 +44,7 @@ class DoctorServiceInheritor extends DoctorService { $fs: IFileSystem, $terminalSpinnerService: ITerminalSpinnerService, $versionsService: IVersionsService, - $settingsService: ISettingsService + $settingsService: ISettingsService, ) { super( $analyticsService, @@ -56,13 +56,13 @@ class DoctorServiceInheritor extends DoctorService { $fs, $terminalSpinnerService, $versionsService, - $settingsService + $settingsService, ); } public getDeprecatedShortImportsInFiles( files: string[], - projectDir: string + projectDir: string, ): { file: string; line: string }[] { return super.getDeprecatedShortImportsInFiles(files, projectDir); } @@ -81,10 +81,10 @@ describe("doctorService", () => { testInjector.register("terminalSpinnerService", { execute: ( spinnerOptions: ITerminalSpinnerOptions, - action: () => Promise + action: () => Promise, ): Promise => action(), createSpinner: ( - spinnerOptions?: ITerminalSpinnerOptions + spinnerOptions?: ITerminalSpinnerOptions, ): ITerminalSpinner => { text: "", @@ -99,17 +99,17 @@ describe("doctorService", () => { testInjector.register("jsonFileSettingsService", { getSettingValue: async ( settingName: string, - cacheOpts?: ICacheTimeoutOpts + cacheOpts?: ICacheTimeoutOpts, ): Promise => undefined, saveSetting: async ( key: string, value: any, - cacheOpts?: IUseCacheOpts + cacheOpts?: IUseCacheOpts, ): Promise => undefined, }); testInjector.register("platformEnvironmentRequirements", { checkEnvironmentRequirements: async ( - input: ICheckEnvironmentRequirementsInput + input: ICheckEnvironmentRequirementsInput, ): Promise => {}, }); @@ -343,8 +343,7 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl { file: "file1", line: 'const application = require("application")' }, { file: "file1", - line: - 'you should import some long words here require("application") module and other words here`)', + line: 'you should import some long words here require("application") module and other words here`)', }, ], }, @@ -352,9 +351,8 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl it("getDeprecatedShortImportsInFiles returns correct results", () => { const testInjector = createTestInjector(); - const doctorService = testInjector.resolve( - "doctorService" - ); + const doctorService = + testInjector.resolve("doctorService"); const fs = testInjector.resolve("fs"); fs.getFsStats = (file) => { @@ -372,7 +370,7 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl const shortImports = doctorService.getDeprecatedShortImportsInFiles( _.keys(filesContents), - "projectDir" + "projectDir", ); assert.deepStrictEqual(shortImports, expectedShortImports); }); @@ -431,9 +429,8 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl const nsDoctorStub = sandbox.stub(nativescriptDoctor.doctor, "getInfos"); nsDoctorStub.returns(successGetInfosResult); const testInjector = createTestInjector(); - const doctorService = testInjector.resolve( - "doctorService" - ); + const doctorService = + testInjector.resolve("doctorService"); const logger = testInjector.resolve("logger"); await doctorService.printWarnings(); assert.isTrue(logger.output.indexOf("No issues were detected.") !== -1); @@ -443,15 +440,14 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl const nsDoctorStub = sandbox.stub(nativescriptDoctor.doctor, "getInfos"); nsDoctorStub.returns(failedGetInfosResult); const testInjector = createTestInjector(); - const doctorService = testInjector.resolve( - "doctorService" - ); + const doctorService = + testInjector.resolve("doctorService"); const logger = testInjector.resolve("logger"); await doctorService.printWarnings(); assert.isTrue( logger.output.indexOf( - "There seem to be issues with your configuration." - ) !== -1 + "There seem to be issues with your configuration.", + ) !== -1, ); }); @@ -459,26 +455,26 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl const nsDoctorStub = sandbox.stub(nativescriptDoctor.doctor, "getInfos"); nsDoctorStub.throws( new Error( - "We should not call @nativescript/doctor package when we have results in the file." - ) + "We should not call @nativescript/doctor package when we have results in the file.", + ), ); const testInjector = createTestInjector(); - const doctorService = testInjector.resolve( - "doctorService" - ); - const jsonFileSettingsService = testInjector.resolve< - IJsonFileSettingsService - >("jsonFileSettingsService"); + const doctorService = + testInjector.resolve("doctorService"); + const jsonFileSettingsService = + testInjector.resolve( + "jsonFileSettingsService", + ); jsonFileSettingsService.getSettingValue = async ( settingName: string, - cacheOpts?: ICacheTimeoutOpts + cacheOpts?: ICacheTimeoutOpts, ): Promise => successGetInfosResult; let saveSettingValue: any = null; jsonFileSettingsService.saveSetting = async ( key: string, value: any, - cacheOpts?: IUseCacheOpts + cacheOpts?: IUseCacheOpts, ): Promise => (saveSettingValue = value); const logger = testInjector.resolve("logger"); await doctorService.printWarnings(); @@ -491,17 +487,17 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl nsDoctorStub.returns(successGetInfosResult); const testInjector = createTestInjector(); - const doctorService = testInjector.resolve( - "doctorService" - ); - const jsonFileSettingsService = testInjector.resolve< - IJsonFileSettingsService - >("jsonFileSettingsService"); + const doctorService = + testInjector.resolve("doctorService"); + const jsonFileSettingsService = + testInjector.resolve( + "jsonFileSettingsService", + ); let saveSettingValue: any = null; jsonFileSettingsService.saveSetting = async ( key: string, value: any, - cacheOpts?: IUseCacheOpts + cacheOpts?: IUseCacheOpts, ): Promise => (saveSettingValue = value); const logger = testInjector.resolve("logger"); await doctorService.printWarnings(); @@ -514,17 +510,17 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl nsDoctorStub.returns(successGetInfosResult); const testInjector = createTestInjector(); - const doctorService = testInjector.resolve( - "doctorService" - ); - const jsonFileSettingsService = testInjector.resolve< - IJsonFileSettingsService - >("jsonFileSettingsService"); + const doctorService = + testInjector.resolve("doctorService"); + const jsonFileSettingsService = + testInjector.resolve( + "jsonFileSettingsService", + ); let saveSettingValue: any = null; let isGetSettingValueCalled = false; jsonFileSettingsService.getSettingValue = async ( settingName: string, - cacheOpts?: ICacheTimeoutOpts + cacheOpts?: ICacheTimeoutOpts, ): Promise => { isGetSettingValueCalled = true; return null; @@ -532,7 +528,7 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl jsonFileSettingsService.saveSetting = async ( key: string, value: any, - cacheOpts?: IUseCacheOpts + cacheOpts?: IUseCacheOpts, ): Promise => (saveSettingValue = value); const logger = testInjector.resolve("logger"); await doctorService.printWarnings({ forceCheck: true }); @@ -541,7 +537,7 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl assert.isTrue(nsDoctorStub.calledOnce); assert.isFalse( isGetSettingValueCalled, - "When forceCheck is passed, we should not read the cache file." + "When forceCheck is passed, we should not read the cache file.", ); }); @@ -549,9 +545,8 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl const nsDoctorStub = sandbox.stub(nativescriptDoctor.doctor, "getInfos"); nsDoctorStub.returns(failedGetInfosResult); const testInjector = createTestInjector(); - const doctorService = testInjector.resolve( - "doctorService" - ); + const doctorService = + testInjector.resolve("doctorService"); const fs = testInjector.resolve("fs"); let deletedPath = ""; fs.deleteFile = (filePath: string): void => { @@ -561,8 +556,8 @@ const Observable = require("tns-core-modules-widgets/data/observable").Observabl await doctorService.printWarnings(); assert.isTrue( logger.output.indexOf( - "There seem to be issues with your configuration." - ) !== -1 + "There seem to be issues with your configuration.", + ) !== -1, ); assert.isTrue(deletedPath.indexOf("doctor-cache.json") !== -1); });