diff --git a/extending-cli.md b/extending-cli.md index 5ace718f18..fec73320ca 100644 --- a/extending-cli.md +++ b/extending-cli.md @@ -77,11 +77,58 @@ 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. +The CLI assumes that this is a CommonJS module and calls the hook it exports — either a hook definition (see below) or a plain 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. +Export a hook definition built with `defineHook`. It takes the hook point in the usual naming convention (`before-prepare`, `after-watch`) and a handler that receives a context object. + +```JavaScript +const { defineHook, inject, DoctorService } = require("nativescript/contracts"); + +module.exports = defineHook("before-prepare", async (ctx) => { + const doctorService = inject(DoctorService); + await doctorService.canExecuteLocalBuild(); +}); +``` + +Services come from `inject()` — the same API used everywhere else (see [dependency-injection.md](dependency-injection.md)): + +* `inject()` is valid in the synchronous part of the handler — 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. +* 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 { defineHook, inject, DoctorService } from "nativescript/contracts"`. An `.mjs` hook can `export default defineHook(...)`. + +`ctx.payload` holds the parameters of the CLI operation being hooked; its shape depends on the hook point. It is the CLI's own object, so mutating it influences the operation: + +```JavaScript +module.exports = defineHook("before-build-task-args", (ctx) => { + ctx.payload.args.push("--offline"); +}); +``` + +`ctx.wrap(middleware)` puts a middleware around the hooked method. The middleware receives the method's arguments and a `next` callback; call `next` to continue, or return without calling it to short-circuit the method entirely. Register it from a `before-` hook. + +```JavaScript +module.exports = defineHook("before-prepare", (ctx) => { + ctx.wrap(async (args, next) => { + const result = await next(...args); + return result; + }); +}); +``` + +`ctx.abort(message)` stops the hook and fails the command. Pass `{ asWarning: true }` to print the message as a warning and let the command continue instead. + +```JavaScript +module.exports = defineHook("before-prepare", (ctx) => { + ctx.abort("Nothing to prepare.", { asWarning: true }); +}); +``` + +### Plain function hooks + +Exporting a plain function is still supported. It runs in an injection context too, so `inject()` works the same way; declare a `hookArgs` parameter if you need the payload. ```JavaScript const { inject, DoctorService } = require("nativescript/contracts"); @@ -92,12 +139,6 @@ module.exports = function (hookArgs) { }; ``` -* `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 The hook must return a Promise. If the hook succeeds, it must fullfil the promise, but the fullfilment value is ignored. @@ -110,6 +151,10 @@ Member | Type | Description If these two members are not set, the CLI prints the returned error colored as fatal error and stops executing the current command. +A plain-function hook can also return a function, which the CLI folds into a middleware chain around the hooked method. + +With `defineHook` neither convention is needed: `ctx.abort` replaces throwing an error carrying `stopExecution`/`errorAsWarning`, and `ctx.wrap` replaces returning a function. + ## 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`). diff --git a/lib/common/define-hook.ts b/lib/common/define-hook.ts new file mode 100644 index 0000000000..e497cf6663 --- /dev/null +++ b/lib/common/define-hook.ts @@ -0,0 +1,111 @@ +/** + * The typed hook-authoring API. Kept import-free so that a hook (or an + * extension carrying its own copy of the CLI) can load it without booting a + * second runtime — importing lib/common/yok creates global.$injector. + */ + +/** + * `Symbol.for` rather than a module-local symbol: an extension may resolve a + * duplicated copy of the CLI from its own node_modules, and the running CLI + * still has to recognize definitions minted by that copy. + */ +export const HOOK_DEFINITION_MARKER = Symbol.for( + "nativescript:cli:hookDefinition", +); + +/** + * Wraps the method the hook point decorates. `next` continues the chain — call + * it with `args` to run the original, or skip it to short-circuit. + */ +export type HookMiddleware = ( + args: any[], + next: (...args: any[]) => any, +) => any; + +export interface IHookContext { + /** + * The payload of the operation being hooked. Its shape depends on the hook + * point, and it is the caller's own object: mutating it is a supported + * channel for influencing the operation. + */ + payload: any; + + /** Registers a middleware around the method this hook point decorates. */ + wrap(middleware: HookMiddleware): void; + + /** + * Stops the hook. With `asWarning`, the CLI logs the message and continues + * the command; otherwise the command fails. + */ + abort(message: string, opts?: { asWarning?: boolean }): never; +} + +export type HookHandler = (ctx: IHookContext) => void | Promise; + +export interface IHookDefinition { + /** Hook point, in the hyphen convention: `before-prepare`, `after-watch`. */ + readonly name: string; + readonly handler: HookHandler; +} + +export interface IHookInvocation { + context: IHookContext; + /** Populated by `ctx.wrap()` while the handler runs. */ + middlewares: HookMiddleware[]; +} + +export function defineHook( + name: string, + handler: HookHandler, +): IHookDefinition { + const definition: IHookDefinition = { name, handler }; + Object.defineProperty(definition, HOOK_DEFINITION_MARKER, { value: true }); + return definition; +} + +export function isHookDefinition(value: any): boolean { + return ( + !!value && + (typeof value === "object" || typeof value === "function") && + value[HOOK_DEFINITION_MARKER] === true && + typeof value.handler === "function" + ); +} + +/** + * Derives the context from the raw hook argument bag: the `hookArgs` wrapper + * when the hook point supplies one, the bag itself for hook points that pass + * their keys at the top level, and nothing when there is no payload. + */ +export function createHookInvocation(hookArguments: any): IHookInvocation { + const middlewares: HookMiddleware[] = []; + const context: IHookContext = { + payload: derivePayload(hookArguments), + wrap(middleware: HookMiddleware): void { + middlewares.push(middleware); + }, + abort(message: string, opts?: { asWarning?: boolean }): never { + const error: any = new Error(message); + if (opts && opts.asWarning) { + // The pair the hooks service checks for to downgrade a rejection. + error.stopExecution = false; + error.errorAsWarning = true; + } + throw error; + }, + }; + + return { context, middlewares }; +} + +function derivePayload(hookArguments: any): any { + if (!hookArguments || typeof hookArguments !== "object") { + return undefined; + } + + if ("hookArgs" in hookArguments) { + return hookArguments["hookArgs"]; + } + + return Object.keys(hookArguments).length ? hookArguments : undefined; +} diff --git a/lib/common/services/hooks-service.ts b/lib/common/services/hooks-service.ts index a9cada85c9..ddccfd2ef5 100644 --- a/lib/common/services/hooks-service.ts +++ b/lib/common/services/hooks-service.ts @@ -3,6 +3,9 @@ import * as util from "util"; import * as _ from "lodash"; import { annotate, getValueFromNestedObject } from "../helpers"; import { reportDeprecation } from "../deprecation"; +import { createHookInvocation, isHookDefinition } from "../define-hook"; +import type { HookMiddleware, IHookDefinition } from "../define-hook"; +import { runInInjectionContext } from "../di/inject"; import { AnalyticsEventLabelDelimiter } from "../../constants"; import { IOptions, IPerformanceService } from "../../declarations"; import { @@ -216,73 +219,89 @@ export class HooksService implements IHooksService { hookEntryPoint = require(hook.fullPath); } - this.$logger.trace(`Validating ${hookName} arguments.`); - - const invalidArguments = this.validateHookArguments( - hookEntryPoint, - hook.fullPath, - ); + // Covers both a `.mjs` default export and tsc's CommonJS emit of + // `export default`, whose value lands under `.default`. + const definitionCandidate = + (hookEntryPoint && hookEntryPoint.default) ?? hookEntryPoint; + + if (isHookDefinition(definitionCandidate)) { + result = await this.executeHookDefinition( + definitionCandidate, + hookName, + hook, + hookArguments, + ); + } else { + this.$logger.trace(`Validating ${hookName} arguments.`); - if (invalidArguments.length) { - this.$logger.warn( - `${ - hook.fullPath - } will NOT be executed because it has invalid arguments - ${color.grey( - invalidArguments.join(", "), - )}.`, + const invalidArguments = this.validateHookArguments( + hookEntryPoint, + hook.fullPath, ); - return; - } - // HACK for backwards compatibility: - // In case $projectData wasn't resolved by the time we got here (most likely we got here without running a command but through a service directly) - // then it is probably passed as a hookArg - // if that is the case then pass it directly to the hook instead of trying to resolve $projectData via injector - // This helps make hooks stateless - const projectDataHookArg = - hookArguments["hookArgs"] && hookArguments["hookArgs"]["projectData"]; - if (projectDataHookArg) { - hookArguments["projectData"] = hookArguments["$projectData"] = - projectDataHookArg; - } + if (invalidArguments.length) { + this.$logger.warn( + `${ + hook.fullPath + } will NOT be executed because it has invalid arguments - ${color.grey( + invalidArguments.join(", "), + )}.`, + ); + return; + } - // 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, - }); - } + // HACK for backwards compatibility: + // In case $projectData wasn't resolved by the time we got here (most likely we got here without running a command but through a service directly) + // then it is probably passed as a hookArg + // if that is the case then pass it directly to the hook instead of trying to resolve $projectData via injector + // This helps make hooks stateless + const projectDataHookArg = + hookArguments["hookArgs"] && hookArguments["hookArgs"]["projectData"]; + if (projectDataHookArg) { + hookArguments["projectData"] = hookArguments["$projectData"] = + projectDataHookArg; + } - const maybePromise = this.$injector.resolve( - hookEntryPoint, - hookArguments, - ); - if (maybePromise) { - this.$logger.trace("Hook promises to signal completion"); - try { - result = await maybePromise; - } catch (err) { - if ( - err && - _.isBoolean(err.stopExecution) && - err.errorAsWarning === true - ) { - this.$logger.warn(err.message || err); - } else { - // Print the actual error with its callstack, so it is easy to find out which hooks is causing troubles. - this.$logger.error(err); - throw err || new Error(`Failed to execute hook: ${hook.fullPath}.`); - } + // 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, + }); } - this.$logger.trace("Hook completed"); + const maybePromise = this.$injector.resolve( + hookEntryPoint, + hookArguments, + ); + if (maybePromise) { + this.$logger.trace("Hook promises to signal completion"); + try { + result = await maybePromise; + } catch (err) { + if ( + err && + _.isBoolean(err.stopExecution) && + err.errorAsWarning === true + ) { + this.$logger.warn(err.message || err); + } else { + // Print the actual error with its callstack, so it is easy to find out which hooks is causing troubles. + this.$logger.error(err); + throw ( + err || new Error(`Failed to execute hook: ${hook.fullPath}.`) + ); + } + } + + this.$logger.trace("Hook completed"); + } } } else { const environment = this.prepareEnvironment(hook.fullPath); @@ -321,6 +340,43 @@ export class HooksService implements IHooksService { return result; } + private async executeHookDefinition( + definition: IHookDefinition, + hookName: string, + hook: IHook, + hookArguments: IDictionary, + ): Promise { + if (definition.name !== hookName) { + this.$logger.trace( + `Hook ${hook.fullPath} defines "${definition.name}" but its file name places it at the "${hookName}" hook point; running it there.`, + ); + } + + const { context, middlewares } = createHookInvocation(hookArguments); + + try { + await runInInjectionContext(this.$injector, () => + definition.handler(context), + ); + } catch (err) { + if ( + err && + _.isBoolean(err.stopExecution) && + err.errorAsWarning === true + ) { + this.$logger.warn(err.message || err); + } else { + // Print the actual error with its callstack, so it is easy to find out which hooks is causing troubles. + this.$logger.error(err); + throw err || new Error(`Failed to execute hook: ${hook.fullPath}.`); + } + } + + this.$logger.trace("Hook completed"); + + return middlewares.length ? middlewares : undefined; + } + private async executeHooksInDirectory( directoryPath: string, hookName: string, @@ -344,7 +400,10 @@ export class HooksService implements IHooksService { } } - return results; + // executeHooks flattens the per-directory results exactly once, so a hook + // returning several middlewares must contribute them individually or they + // stay nested one level too deep for decorateMethod's function filter. + return _.flatten(results); } private getCustomHooksByName(hookName: string): IHook[] { diff --git a/lib/contracts/index.ts b/lib/contracts/index.ts index c13a9b9996..c65b022042 100644 --- a/lib/contracts/index.ts +++ b/lib/contracts/index.ts @@ -25,3 +25,11 @@ export type { export { DoctorService } from "./doctor-service"; export { ProjectNameService } from "./project-name-service"; + +export { defineHook, isHookDefinition } from "../common/define-hook"; +export type { + IHookContext, + IHookDefinition, + HookHandler, + HookMiddleware, +} from "../common/define-hook"; diff --git a/test/define-hook.ts b/test/define-hook.ts new file mode 100644 index 0000000000..d3030f6b96 --- /dev/null +++ b/test/define-hook.ts @@ -0,0 +1,327 @@ +import { assert } from "chai"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { Yok } 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"; + +// Hook fixtures load the API the way a real hook does — through the published +// `nativescript/contracts` entry point — so the marker symbol, the context +// shape and the hooks-service integration are exercised end to end. +const apiPath = require.resolve("../lib/contracts"); + +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, + extension = ".js", +): string { + const hooksDir = path.join(projectDir, "hooks"); + fs.mkdirSync(hooksDir, { recursive: true }); + const fullPath = path.join(hooksDir, `${hookName}${extension}`); + fs.writeFileSync(fullPath, source); + return fullPath; +} + +function writeHookInDirectory( + projectDir: string, + hookName: string, + fileName: string, + source: string, +): string { + const hooksDir = path.join(projectDir, "hooks", hookName); + fs.mkdirSync(hooksDir, { recursive: true }); + const fullPath = path.join(hooksDir, fileName); + fs.writeFileSync(fullPath, source); + return fullPath; +} + +describe("defineHook", () => { + 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-define-hook-")); + testInjector = createTestInjector(projectDir); + capture = (global).__hookCapture = {}; + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + delete (global).__hookCapture; + }); + + it("passes the hookArgs value as the payload, by identity and mutable in place", async () => { + writeHook( + projectDir, + "before-case1", + `const { defineHook } = require(${JSON.stringify(apiPath)}); + module.exports = defineHook("before-case1", async (ctx) => { + global.__hookCapture.payload = ctx.payload; + ctx.payload.args.push("--offline"); + });`, + ); + + const args = ["assembleDebug"]; + const payload = { args }; + await hooksService().executeBeforeHooks("case1", { hookArgs: payload }); + + assert.strictEqual(capture.payload, payload); + assert.deepEqual(args, ["assembleDebug", "--offline"]); + }); + + it("passes the top-level bag as the payload when there is no hookArgs wrapper", async () => { + writeHook( + projectDir, + "after-case2", + `const { defineHook } = require(${JSON.stringify(apiPath)}); + module.exports = defineHook("after-case2", (ctx) => { + global.__hookCapture.payload = ctx.payload; + });`, + ); + + const liveSyncResultInfo = { fake: true }; + await hooksService().executeAfterHooks("case2", { liveSyncResultInfo }); + + assert.strictEqual(capture.payload.liveSyncResultInfo, liveSyncResultInfo); + }); + + it("leaves the payload undefined for a hook point with no arguments", async () => { + writeHook( + projectDir, + "before-case3", + `const { defineHook } = require(${JSON.stringify(apiPath)}); + module.exports = defineHook("before-case3", (ctx) => { + global.__hookCapture.ran = true; + global.__hookCapture.payload = ctx.payload; + });`, + ); + + await hooksService().executeBeforeHooks("case3"); + + assert.isTrue(capture.ran); + assert.isUndefined(capture.payload); + }); + + it("runs the handler in an injection context, so inject() resolves by token and by name", async () => { + writeHook( + projectDir, + "before-case4", + `const { defineHook, inject, Injector } = require(${JSON.stringify(apiPath)}); + module.exports = defineHook("before-case4", async (ctx) => { + global.__hookCapture.container = inject(Injector); + global.__hookCapture.logger = inject("logger"); + });`, + ); + + await hooksService().executeBeforeHooks("case4"); + + assert.strictEqual(capture.container, testInjector); + assert.strictEqual(capture.logger, testInjector.resolve("logger")); + }); + + it("folds a wrap() middleware into the chain around the @hook-decorated method", async () => { + writeHook( + projectDir, + "before-case5", + `const { defineHook } = require(${JSON.stringify(apiPath)}); + module.exports = defineHook("before-case5", (ctx) => { + ctx.wrap(function (args, next) { + global.__hookCapture.middlewareArgs = args.slice(); + return next.apply(null, args).then(function (result) { + return "wrapped(" + result + ")"; + }); + }); + });`, + ); + + class Subject { + constructor(public $hooksService: IHooksService) {} + + @hook("case5") + 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 wrap() middleware short-circuit the decorated method", async () => { + writeHook( + projectDir, + "before-case6", + `const { defineHook } = require(${JSON.stringify(apiPath)}); + module.exports = defineHook("before-case6", (ctx) => { + ctx.wrap(function () { + return "short-circuited"; + }); + });`, + ); + + class Subject { + constructor(public $hooksService: IHooksService) {} + + @hook("case6") + 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("logs a warning and continues when the handler aborts with asWarning", async () => { + writeHook( + projectDir, + "before-case7", + `const { defineHook } = require(${JSON.stringify(apiPath)}); + module.exports = defineHook("before-case7", async (ctx) => { + ctx.abort("soft-abort", { asWarning: true }); + });`, + ); + + await hooksService().executeBeforeHooks("case7"); + + assert.include(logger().warnOutput, "soft-abort"); + }); + + it("fails the command when the handler aborts without asWarning", async () => { + writeHook( + projectDir, + "before-case8", + `const { defineHook } = require(${JSON.stringify(apiPath)}); + module.exports = defineHook("before-case8", async (ctx) => { + ctx.abort("hard-abort"); + });`, + ); + + await assert.isRejected( + hooksService().executeBeforeHooks("case8"), + /hard-abort/, + ); + }); + + it("keeps a legacy param-name hook on the old path, and never reports a definition hook", async () => { + const legacyPath = writeHookInDirectory( + projectDir, + "before-case9", + "legacy.js", + `module.exports = function ($logger) { + global.__hookCapture.legacyRan = true; + };`, + ); + const definitionPath = writeHookInDirectory( + projectDir, + "before-case9", + "modern.js", + `const { defineHook } = require(${JSON.stringify(apiPath)}); + module.exports = defineHook("before-case9", () => { + global.__hookCapture.definitionRan = true; + });`, + ); + + await hooksService().executeBeforeHooks("case9"); + + assert.isTrue(capture.legacyRan); + assert.isTrue(capture.definitionRan); + + const deprecationReports = logger() + .traceOutput.split("\n") + .filter((line) => line.indexOf("hooks.param-name-signature") !== -1); + assert.isTrue( + deprecationReports.some((line) => line.indexOf(legacyPath) !== -1), + ); + assert.isFalse( + deprecationReports.some((line) => line.indexOf(definitionPath) !== -1), + ); + }); + + it("recognizes a definition default-exported from an .mjs hook", async () => { + writeHook( + projectDir, + "before-case10", + `import { createRequire } from "module"; + const require = createRequire(import.meta.url); + const { defineHook } = require(${JSON.stringify(apiPath)}); + export default defineHook("before-case10", (ctx) => { + global.__hookCapture.payload = ctx.payload; + });`, + ".mjs", + ); + + const payload = { fromMjs: true }; + await hooksService().executeBeforeHooks("case10", { hookArgs: payload }); + + assert.strictEqual(capture.payload, payload); + }); + + it("runs a definition whose name differs from the hook point and traces the mismatch", async () => { + writeHook( + projectDir, + "before-case11", + `const { defineHook } = require(${JSON.stringify(apiPath)}); + module.exports = defineHook("before-something-else", () => { + global.__hookCapture.ran = true; + });`, + ); + + await hooksService().executeBeforeHooks("case11"); + + assert.isTrue(capture.ran); + assert.include(logger().traceOutput, `defines "before-something-else"`); + assert.include(logger().traceOutput, `"before-case11" hook point`); + }); +});