diff --git a/.agents/skills/incremental-build/architecture.md b/.agents/skills/incremental-build/architecture.md index cc8305d9ef3..c8347ac857d 100644 --- a/.agents/skills/incremental-build/architecture.md +++ b/.agents/skills/incremental-build/architecture.md @@ -47,8 +47,8 @@ Use this table to locate source files. ALWAYS read the relevant source file befo | `drainSubscriptions` | `lib/build/helpers/watchSubscriptions.js` | Unsubscribes a list of subscriptions in parallel (`Promise.allSettled`), returns the failures. Used by both watchers' `destroy()` and BuildServer's recovery re-subscribe | | `fileWatcher` | `lib/build/helpers/fileWatcher.js` | Watcher-backend facade. Exposes a `subscribe()` matching `@parcel/watcher`'s contract and picks a backend once per process: `UI5_WATCH_MODE=polling\|native` forces the choice, otherwise it auto-detects containers (`/.dockerenv`, `/run/.containerenv`, PID 1 cgroup) and uses polling there. Also falls back to polling if the native `@parcel/watcher` binding cannot load. `UI5_WATCH_MODE=off` disables watching entirely: `subscribe()` returns an inert subscription (callback never invoked, `unsubscribe` a no-op) so no backend loads, for CI and other environments where sources do not change while the server runs. The memoized decision is a mode string exposed via `shouldUsePolling()` and `isWatchingDisabled()`. All three watchers subscribe through this facade rather than importing `@parcel/watcher` directly | | `pollingWatcher` | `lib/build/helpers/pollingWatcher.js` | Pure-JS polling backend. Walks the tree and diffs an `{mtimeMs, size}` snapshot every 250 ms (`DEFAULT_POLL_INTERVAL_MS`), emitting the same `{type, path}` events as the native backend. Needed on bind-mounted container volumes where inotify misses writes made from outside the container | -| `TaskRunner` | `lib/build/TaskRunner.js` | Task composition, execution loop, abort handling. Hands every task a plain `MonitoredTaskUtil`; for a step-based task it calls the task's step factory once per build at plan time to discover step names for `setTasks`, keeps the returned step array on the task, and reuses it to drive the `StepRunner` with per-stage hooks (`#createStepStageHooks`: `prepareStage`/`getPreviousInvocationData`/`createStageContext`/`recordStage`), and reports the task skipped/executed from the runner's `anyStepExecuted`. A legacy task runs a plain body with no step runner | -| `StepRunner` | `lib/build/helpers/StepRunner.js` | Per-task driver behind the step-factory build API, driving **one pipeline stage per step** (Phase B). Runs scalar and map steps in array order; per step it calls `prepareStage` (switch + cache verdict), runs/restores the step's invocations against a fresh per-stage recording context and per-step `MonitoredTaskUtil`, threads `needs` returns between steps, folds the stage's own keys' reads/inputs (`#foldStageKeys`, including cached keys) and derives the stage's stale outputs (`#computeStaleOutputs`), then calls `recordStage`. No cross-step fold. See "Step-Based Build Tasks" | +| `TaskRunner` | `lib/build/TaskRunner.js` | Task composition, execution loop, abort handling. Hands every task a plain `MonitoredTaskUtil`; for a step-based task it reads the memoized `descriptor.steps` (resolved by `TaskDefinitions`) at plan time to discover step names for `setTasks`, keeps the frozen step array on the task, and reuses it to drive the `StepRunner` with per-stage hooks (`#createStepStageHooks`: `prepareStage`/`getPreviousInvocationData`/`createStageContext`/`recordStage`), and reports the task skipped/executed from the runner's `anyStepExecuted`. A task with a `descriptor.task` (or a `taskFunction` definition override) runs a plain body with no step runner | +| `StepRunner` | `lib/build/helpers/StepRunner.js` | Per-task driver behind the descriptor's `steps`, driving **one pipeline stage per step** (Phase B). Also exports `validateSteps` and `validateDescriptor` (the task-descriptor validator). Runs scalar and map steps in array order; per step it calls `prepareStage` (switch + cache verdict), runs/restores the step's invocations against a fresh per-stage recording context and per-step `MonitoredTaskUtil`, threads `needs` returns between steps, folds the stage's own keys' reads/inputs (`#foldStageKeys`, including cached keys) and derives the stage's stale outputs (`#computeStaleOutputs`), then calls `recordStage`. No cross-step fold. See "Step-Based Build Tasks" | | `Cache` enum | `lib/build/cache/Cache.js` | Cache mode constants: `Default`, `Force`, `ReadOnly`, `Off` (CLI `--cache` option) | | `ProjectBuildCache` | `lib/build/cache/ProjectBuildCache.js` | Cache orchestration per project: index management, stage lookup, result recording. The unit of caching is a **stage**: `#stageCaches` is keyed by stage id (a legacy task's `task/{taskName}`, or a step-based task's `task/{taskName}::step/{stepName}` per step); `prepareStageExecutionAndValidateCache(taskName, stepName?)` and `recordStageResult({taskName, …, stepName?})` operate per stage; `setTasks([{taskName, stepNames?}])` creates one stage per step | | `BuildStageCache` | `lib/build/cache/BuildStageCache.js` | Per-stage resource request tracking and index management (one instance per stage, keyed in `ProjectBuildCache` by stage id). Also holds the stage's `TaskInputSet` (non-resource inputs) and exposes `getInputSignature(resolveValue)` | @@ -268,7 +268,7 @@ The cache uses content-based signatures at multiple levels: | Level | What it captures | Where computed | |-------|-----------------|----------------| -| **Build signature** | `getBaseSignature()` (`BUILD_SIG_VERSION` + build config), combined by `getProjectSignature()` with the aggregated task signatures, `project.getId()`, project config, the `@ui5/project` version (`getPackageVersion("@ui5/project")`), and the effective `@ui5/builder` / `@ui5/fs` versions (`taskRepository.getVersions()`). Task signatures come from `TaskDefinitions.getBuildSignatures()`, which calls each task's `determineBuildSignature()` (falling back to a hash of the task options/configuration). | `getBuildSignature.js`, `TaskDefinitions.getBuildSignatures()`, `ProjectBuildContext.create()` | +| **Build signature** | `getBaseSignature()` (`BUILD_SIG_VERSION` + build config), combined by `getProjectSignature()` with the aggregated task signatures, `project.getId()`, project config, the `@ui5/project` version (`getPackageVersion("@ui5/project")`), and the effective `@ui5/builder` / `@ui5/fs` versions (`taskRepository.getVersions()`). Task signatures come from `TaskDefinitions.getBuildSignatures()`, which resolves each task's descriptor (once per build, memoized) and calls its `determineBuildSignature()` (falling back to a hash of the task options/configuration). | `getBuildSignature.js`, `TaskDefinitions.getBuildSignatures()`, `ProjectBuildContext.create()` | | **Source signature** | Merkle root of all source resources | `ResourceIndex` (source index) | | **Stage signature** | An explicit four-component tuple `projectIndexSignature-dependencyIndexSignature-inputSignature-rootSignature`, joined by `-` (each component a SHA-256 hex digest, so the separator never occurs inside a component and the split is lossless). The four dimensions (project resources, dependency resources, non-resource **input signature**, root resources) are independent slots rather than folded into two, so a delta pairs a changed project or dependency signature with the current input and root signatures with no reverse mapping. The join/split primitives live in `cache/stageSignature.js`; `BuildStageCache.getStageSignatures()` composes the exact-match candidates. One stage per step for a step-based task, one per legacy task | `ProjectBuildCache.prepareStageExecutionAndValidateCache()`, `BuildStageCache.getStageSignatures()` | | **Result signature** | A four-component tuple `sourceSignature-combinedDependencySignature-aggregatedInputSignature-aggregatedRootSignature`. The dependency component is a cartesian product over per-stage candidate dependency signatures; the aggregated input and root signatures are single current values (constants across the product). Both the candidate list and the stored signature derive the per-stage dependency list from the single stage order (`#stageOrder`), so the store and lookup sides cover the same stages in the same order (a declared stage missing a signature throws rather than silently never matching). Grows by one factor per stage, bounded because only dependency-reading stages with a delta contribute a factor >1 | `ProjectBuildCache.#getResultStageSignature()` / `#getPossibleResultStageSignatures()` | @@ -316,9 +316,9 @@ Two step shapes, run in array order by `StepRunner.runSteps()`: A scalar step is a one-key group (a single implicit invocation); a map step is a multi-key group. Both go through the same per-key machinery within their stage, so delta selection, stale-output derivation, and per-stage request/input folding are uniform across shapes. -**`needs` wiring.** A step lists earlier step names in `needs`; those steps' returns arrive as `needs.` in its context (both `keys` and `each` for a map step). A step may reference only earlier steps, so array order is always a valid execution order. One `needs` object is shared by a step's `keys` enumerator and all of its invocations, and is frozen (shallowly): an invocation assigning to `needs.` would otherwise leak into its siblings and into the `needsInputs` recorded for whichever invocation ran next, making a delta build's per-invocation selection depend on execution order. `@ui5/builder`'s standalone `runSteps` freezes it too, so a step behaves the same under both runners. Because steps share `needs` state in memory, `StepRunner` remains the per-task driver that runs all of a task's steps in order — it just drives one stage per step (see below) instead of folding them into one. +**`needs` wiring.** A step lists earlier step names in `needs`; those steps' returns arrive as `needs.` in its context (both `keys` and `each` for a map step). A step may reference only earlier steps, so array order is always a valid execution order. One `needs` object is shared by a step's `keys` enumerator and all of its invocations, and is frozen (shallowly): an invocation assigning to `needs.` would otherwise leak into its siblings and into the `needsInputs` recorded for whichever invocation ran next, making a delta build's per-invocation selection depend on execution order. `@ui5/builder`'s standalone `runTask` freezes it too, so a step behaves the same under both runners. Because steps share `needs` state in memory, `StepRunner` remains the per-task driver that runs all of a task's steps in order — it just drives one stage per step (see below) instead of folding them into one. -**Declaration validation.** The whole step list is validated **before any stage is created**, by `validateSteps(steps, {taskName?})` (exported from `StepRunner.js`). `TaskRunner` calls it at discovery, right after the factory returns and before it derives the step names for `setTasks`: a stage id is `task/{taskName}::step/{stepName}`, so a duplicate step name would create two stages sharing one id — the error must be raised before `setTasks`, not when the step later runs. `StepRunner.runSteps()` calls it again at its entry, so a directly or standalone instantiated runner validates identically. The rules are static over the descriptors (no reader, taskUtil, options or cache state): the factory must return an array; each element must be an object with a non-empty string `name`; names must be unique; a step is either scalar (`run`) or map (`keys` **and** `each`), never both nor neither (a half-defined map names the missing half); `needs`, when present, must be an array of strings naming only **earlier** steps (so a forward or self reference throws, which is what makes a `needs` cycle impossible). Scope is `@ui5/project`; `@ui5/builder`'s standalone `runSteps` does not yet share this validator. +**Declaration validation.** The whole step list is validated **before any stage is created**, by `validateSteps(steps, {taskName?})` (exported from `StepRunner.js`). `TaskRunner` calls it at discovery, right after the factory returns and before it derives the step names for `setTasks`: a stage id is `task/{taskName}::step/{stepName}`, so a duplicate step name would create two stages sharing one id — the error must be raised before `setTasks`, not when the step later runs. `StepRunner.runSteps()` calls it again at its entry, so a directly or standalone instantiated runner validates identically. The rules are static over the descriptors (no reader, taskUtil, options or cache state): the factory must return an array; each element must be an object with a non-empty string `name`; names must be unique; a step is either scalar (`run`) or map (`keys` **and** `each`), never both nor neither (a half-defined map names the missing half); `needs`, when present, must be an array of strings naming only **earlier** steps (so a forward or self reference throws, which is what makes a `needs` cycle impossible). Scope is `@ui5/project`; `@ui5/builder`'s standalone `runTask` does not yet share this validator. **Authoring a step-based task (the DSL).** A task module default-exports the `build(options)` factory and returns the step array. The reference shape is `generateThemeDesignerResources` (`packages/builder/lib/tasks/generateThemeDesignerResources.js`), a scalar producer feeding a scalar consumer and a map step: @@ -367,11 +367,13 @@ Two DSL rules make the per-step recording honest, and both are visible here. The **Returns.** A step may return resources (stored in the CAS by integrity, as before) or a JSON-serializable value (persisted inline with the invocation's invocation data). Either is injected into a consumer via `needs.`, and the return's signature (resource integrities, or the serialized value) folds into the consumer's per-invocation selection so a changed producer return re-runs the consumer. A map producer's return signature is order-independent: it hashes a `keyId -> signature` map sorted by `keyId`, not a positional list in `keys()` order, so a key reordering that changes nothing semantically (an adapter change, filesystem ordering, a reader-collection reshuffle) does not move the signature and re-run every consumer for nothing. A step returning nothing has a `null` return descriptor. A returned value that is neither resources nor JSON-serializable throws. -**The `stepBased` opt-in.** A task is step-based only when it declares it; absent, the default export is a legacy task body and runs unchanged. A standard task sets `stepBased: true` in its build-definition entry (`definitions/*.js`), read by `_addTask`. A custom task declares a static `stepBased` export on its task module, surfaced by `Task#getStepBased` and honored from Specification Version 5.0 (`_addCustomTask` computes `specVersion.gte("5.0") && (await getStepBased()) === true`); a `stepBased` export below 5.0 is ignored and the task runs as a legacy body. There is no shape-sniffing: both legacy and factory are default-export functions, disambiguated by the flag. +**The task descriptor (step-based vs legacy).** From Specification Version 5.0 (and for every built-in task), a task module's default export is a factory `(options) => descriptor`. The descriptor carries the whole task contract in one object: exactly one of `steps` (the step-based path) or `task` (a legacy single-stage body), plus the optional `determineRequiredDependencies` / `determineBuildSignature` callbacks and an optional declared `specVersion`. A task is step-based when its descriptor has a `steps` array. This replaced the former scattered opt-in (a static `stepBased` export on a custom task module, or a `stepBased: true` entry in `definitions/*.js`) and the former top-level named-export callbacks. The descriptor is validated by `validateDescriptor` (exported from `StepRunner.js`, next to `validateSteps`): exactly one of `steps`/`task`, callbacks must be functions, a declared `specVersion` must equal the task extension's configured Specification Version. For a **custom** task extension below 5.0 the gate is a hard version boundary with no shape-sniffing: the default export is the legacy task body invoked directly (any value it returns is ignored), so there is no descriptor and none of its callbacks apply. **This `specVersion` is the custom task extension's own (from its `ui5.yaml`), unrelated to the Specification Version of the project being built; built-in tasks carry the descriptor shape unconditionally and their project-specVersion gating stays in `definitions/*.js`.** Note the cache-internal `stepBased` flag (the persisted `stages` array `[stageId, stepBased?1:0]`, `BuildStageCache`), which means "this stage ran the step runner", is a different thing and is **retained** — only the task-API opt-in was removed. -**Driving one stage per step.** Before the build, `TaskRunner.runTasks` calls each step-based task's factory once (pure over `options`) to discover its step names and hands `ProjectBuildCache.setTasks` a `[{taskName, stepNames?}]` list, which creates one stage per step in step order. The returned step array is kept on the task (frozen, so a custom task cannot mutate the shared value) and reused when the step runs, so the factory is called once per build rather than once to discover and once to execute. One call is the single source of truth: discovery and execution cannot drive different stages, so no runtime check is needed to reconcile two factory calls. The `options` object is completed at registration time (`_addTask` fills in `projectName`, `projectNamespace`) and is fixed for a `TaskRunner`'s lifetime: a changed `ui5.yaml` task configuration rebuilds the whole serving stack through `Supervisor.reinitialize`, giving a fresh `TaskRunner`, while a plain source-change rebuild reuses the `TaskRunner` and re-derives the step array. Re-deriving per build keeps the steps in step with the options without any explicit invalidation. A factory body that reads the environment or the clock untracked is still impure, but a single call means its one output drives both the stage list and execution consistently. For each step, the `StepRunner` drives that step's own stage via hooks the `TaskRunner` supplies (`#createStepStageHooks`): `prepareStage(stepName)` switches the project to the step's stage and returns its cache verdict (`prepareStageExecutionAndValidateCache(taskName, stepName)`); `createStageContext()` builds fresh monitored `workspace`/`dependencies` readers and a `MonitoredTaskUtil` bound to that stage (so the reads reflect the cumulative output of all earlier stages with this stage's writer on top); `recordStage(stepName, …)` records the step's stage (`recordStageResult({taskName, …, stepName})`). A fully-cached stage (`prepareStage` returns `true`) does not run: its return is rebuilt from the persisted per-key data (in key order) so later steps' `needs` resolve, and its tag operations are replayed. A `needs` return is excluded from the stage signature, so a full-hit consumer whose producer re-ran with a changed return would otherwise serve stale output. The `StepRunner` checks this on the full-hit path (`#needsReturnChanged`) and, when it fires, reopens the stage with a fresh writer and re-runs it as a full execution (`reopenStage` returns a falsy verdict, not a delta). Reopen installs a fresh empty writable stage, so pruning the re-run to only the affected invocations would need re-seeding it with the cached outputs first, so the whole stage re-runs instead. No shipped multi-step task pays for this: only `generateThemeDesignerResources` wires `needs`, and a changed `scan` return regenerates its themes anyway. The verdict (`true` / delta object / falsy) is read three ways in `runSteps`. An explicit verdict shape would read more clearly but is deferred, since that shape is shared with the stage-signature code. A task is reported to the `ProjectBuildLogger` as skipped (`skipTask`) when every step was served from cache, else as executed. Its skip verdict is only known once every stage has been driven, so it cannot be announced up front like a legacy task: the `StepRunner` instead calls `notifyStepExecution` from the first stage that stops being a pure cache hit, before that stage runs anything, and `TaskRunner.#createTaskExecutionReport` turns that into `startTask`. `task-start` therefore precedes the work it announces, and the matching `endTask` (with the union of the stages' written paths) follows it, so a `project-build-status` consumer sees the task running while it runs. A step-based task that returns **no** steps still has one stage (`task/{taskName}`) that is prepared and recorded (empty), so its empty result caches and a later build reports it skipped. +**One factory call, memoized on `TaskDefinitions`.** The descriptor is resolved once per build and shared by the two phases that need it. `TaskDefinitions.getBuildSignatures()` (which needs `determineBuildSignature`) runs inside `ProjectBuildContext.create()`, before the `ProjectBuildCache` and `TaskRunner` exist; `TaskRunner` (which needs `steps`/`task`/`determineRequiredDependencies`) runs later off the **same** `TaskDefinitions` instance. For a built-in, `TaskDefinitions.#resolveStandardDescriptor` calls the factory from `taskRepository.getTask(name)` and caches it on the standard-task map entry (`getStandardDescriptor` exposes it to `TaskRunner`); a skipped (`skip:true`) or `taskFunction`-overridden task has no descriptor and falls back to hashing its options. For a custom task, `Task#getDescriptor(taskName, options)` memoizes per project and runtime `taskName`: two projects that use the same shared extension instance receive descriptors created with their own options, while a `--N`-suffixed second configuration within one project also gets its own descriptor. `TaskDefinitions` completes `projectName`/`projectNamespace` into each standard task's options **before** the first factory call, so a factory that branches on `projectNamespace` at factory time (`generateThemeDesignerResources` emits its `libraryTheming` step only for a namespace) produces the same descriptor at signature time and at execution. -**Standalone (uncached) runner.** `@ui5/builder` exposes `runSteps(factory, {workspace, dependencies, taskUtil, options})` (`lib/tasks/runSteps.js`) for standalone task invocation with no build cache: it runs every step in order, fans out each `keys` set, threads `needs` in memory, and buffers map writes in key order, without delta selection, persistence, or tag replay. It is an independent implementation (it does not use the `@ui5/project` `StepRunner`); the full cached engine stays in `@ui5/project` because `@ui5/builder` cannot import it. The two runners stay separate but share one definition of the write-buffer contract: the same-path guard (with its user-visible error message) and the key-order flush live in `@ui5/fs/internal/stepWriteBuffer` (`assertDistinctWrite`, `flushWriteBuffer`), reached by both because both already depend on `@ui5/fs`. The two writers differ only in which method they override: `@ui5/project`'s `RecordingReaderWriter` overrides `_write` (it also records writes for the cache, and the base `write` has already defaulted options), while `@ui5/builder`'s `BufferedWriter` overrides the public `write` to preserve the caller's exact arguments; both buffer `{resource, args, index}` entries and flush them identically. +**Driving one stage per step.** Before the build, `TaskRunner.runTasks` reads each step-based task's `descriptor.steps` (already resolved by `TaskDefinitions`) and hands `ProjectBuildCache.setTasks` a `[{taskName, stepNames?}]` list, which creates one stage per step in step order. The step array is kept on the task (frozen, so a custom task cannot mutate the shared value) and reused when the step runs. One memoized descriptor is the single source of truth: discovery and execution cannot drive different stages, so no runtime check is needed to reconcile two factory calls. The `options` object is completed at registration time (`TaskDefinitions`/`_addTask` fill in `projectName`, `projectNamespace`) and is fixed for a `TaskRunner`'s lifetime: a changed `ui5.yaml` task configuration rebuilds the whole serving stack through `Supervisor.reinitialize`, giving a fresh `TaskRunner`, while a plain source-change rebuild reuses the `TaskRunner` and re-reads the frozen step array. For each step, the `StepRunner` drives that step's own stage via hooks the `TaskRunner` supplies (`#createStepStageHooks`): `prepareStage(stepName)` switches the project to the step's stage and returns its cache verdict (`prepareStageExecutionAndValidateCache(taskName, stepName)`); `createStageContext()` builds fresh monitored `workspace`/`dependencies` readers and a `MonitoredTaskUtil` bound to that stage (so the reads reflect the cumulative output of all earlier stages with this stage's writer on top); `recordStage(stepName, …)` records the step's stage (`recordStageResult({taskName, …, stepName})`). A fully-cached stage (`prepareStage` returns `true`) does not run: its return is rebuilt from the persisted per-key data (in key order) so later steps' `needs` resolve, and its tag operations are replayed. A `needs` return is excluded from the stage signature, so a full-hit consumer whose producer re-ran with a changed return would otherwise serve stale output. The `StepRunner` checks this on the full-hit path (`#needsReturnChanged`) and, when it fires, reopens the stage with a fresh writer and re-runs it as a full execution (`reopenStage` returns a falsy verdict, not a delta). Reopen installs a fresh empty writable stage, so pruning the re-run to only the affected units would need re-seeding it with the cached outputs first, so the whole stage re-runs instead. No shipped multi-step task pays for this: only `generateThemeDesignerResources` wires `needs`, and a changed `scan` return regenerates its themes anyway. The verdict (`true` / delta object / falsy) is read three ways in `runSteps`. An explicit verdict shape would read more clearly but is deferred, since that shape is shared with the stage-signature code. A task is reported to the `ProjectBuildLogger` as skipped (`skipTask`) when every step was served from cache, else as executed. Its skip verdict is only known once every stage has been driven, so it cannot be announced up front like a legacy task: the `StepRunner` instead calls `notifyStepExecution` from the first stage that stops being a pure cache hit, before that stage runs anything, and `TaskRunner.#createTaskExecutionReport` turns that into `startTask`. `task-start` therefore precedes the work it announces, and the matching `endTask` (with the union of the stages' written paths) follows it, so a `project-build-status` consumer sees the task running while it runs. A step-based task that returns **no** steps still has one stage (`task/{taskName}`) that is prepared and recorded (empty), so its empty result caches and a later build reports it skipped. + +**Standalone (uncached) runner.** `@ui5/builder` exposes `runTask(factory, {workspace, dependencies, taskUtil, options})` (`lib/tasks/runTask.js`) for standalone task invocation with no build cache: it calls the factory, reads `descriptor.steps` (throwing if the descriptor has no `steps` array), runs every step in order, fans out each `keys` set, threads `needs` in memory, and buffers map writes in key order, without delta selection, persistence, or tag replay. It is an independent implementation (it does not use the `@ui5/project` `StepRunner`); the full cached engine stays in `@ui5/project` because `@ui5/builder` cannot import it. The two runners stay separate but share one definition of the write-buffer contract: the same-path guard (with its user-visible error message) and the key-order flush live in `@ui5/fs/internal/stepWriteBuffer` (`assertDistinctWrite`, `flushWriteBuffer`), reached by both because both already depend on `@ui5/fs`. The two writers differ only in which method they override: `@ui5/project`'s `RecordingReaderWriter` overrides `_write` (it also records writes for the cache, and the base `write` has already defaulted options), while `@ui5/builder`'s `BufferedWriter` overrides the public `write` to preserve the caller's exact arguments; both buffer `{resource, args, index}` entries and flush them identically. Per invocation within a stage, the callback receives its own recording readers (`{workspace, dependencies}`) that delegate to the stage's monitored readers and additionally attribute each read to the invocation, plus a per-invocation `MonitoredTaskUtil` wrapping the stage's one. The per-invocation monitor attributes the invocation's non-resource inputs (`getEnv`, `getTime`, `getProject(name).getVersion()`, `isRootProject`, `getDependencies`) and its `getTag`/`setTag`/`clearTag` operations to the invocation, while reads still delegate through the stage monitor so a full build's stage-level recording stays the union that keys the stage. Key identity (`StepRunner.#keyId`) is content-based: a resource key by its path and a content discriminator (path distinguishes resources that share content but produce different output; the discriminator makes a content change a new key, so the invocation re-runs and its previous output is dropped rather than served stale), a string key by its value. Compound keys are the caller's responsibility to express as a stable string. The discriminator is **tiered like `isResourceUnchanged`** (see §"HashTree") rather than always the SSRI integrity: `lastModified` + `size` when both are statically available (`getLastModified()` returns a number and `hasSize()` is true, so no content read), falling back to the integrity when either is missing — a memory-backed or generated resource with no `lastModified`, or one whose size is not statically known. A resource restored from a stage cache carries its `integrity`, so the fallback `getIntegrity()` resolves without reading content and that path stays cheap. This avoids hashing every enumerated key's full content before delta selection; it is why `#resolveEntries` is cheap for a broad `workspace.byGlob` step (`replaceCopyright`, `replaceVersion`) on a stale-cache build (see performance-investigation.md §12). The residual staleness risk is identical to `isResourceUnchanged`'s and accepted for the same reason: a content change that preserves **both** `lastModified` and `size` keeps the same key and does not re-run the invocation — a real edit moves mtime, so the gap is only for mtime-preserving replacements (`cp -p`, `tar -x`, atomic rename) that also hold size constant, and the changed-path delta does not independently cover this case because it is derived through the same tiered comparison. Because the stat tier and the integrity tier use distinct prefixes (`\0m…\0s…` vs `\0i…`), a key never aliases across the two tiers. @@ -639,7 +641,7 @@ Stage metadata stored on disk includes: 3. **Abort/retry**: File changes abort running builds; projects re-queued automatically 4. **Structural sharing**: Derived hash trees share unchanged subtrees, reducing memory 5. **Content-addressed storage**: Resources deduplicated via integrity hashes in custom CAS (synchronous path resolution, gzip-compressed) -6. **Differential caching**: Stages track resource requests; delta builds only re-process changed resources. A task participates by being step-based (the `stepBased` flag, see "Step-Based Build Tasks"), which promotes each step to its own stage; this replaced the older `changedProjectResourcePaths` parameter +6. **Differential caching**: Stages track resource requests; delta builds only re-process changed resources. A task participates by being step-based (its descriptor returns a `steps` array, see "Step-Based Build Tasks"), which promotes each step to its own stage; this replaced the older `changedProjectResourcePaths` parameter 7. **Tag propagation**: Resource tags flow through stages via cached tag operations, included in hash signatures 8. **Two-tier cache**: Fast in-memory StageCache + persistent filesystem cache via CacheManager 9. **Two-phase invalidation**: Changes queued via `projectSourcesChanged()` / `dependencyResourcesChanged()` (state -> `REQUIRES_UPDATE`), applied only during `#flushPendingChanges()` at next build start. "Definitely invalidated" only after content comparison confirms actual differences. diff --git a/internal/documentation/docs/pages/extensibility/CustomTasks.md b/internal/documentation/docs/pages/extensibility/CustomTasks.md index 444a149e25b..f58fb64bccb 100644 --- a/internal/documentation/docs/pages/extensibility/CustomTasks.md +++ b/internal/documentation/docs/pages/extensibility/CustomTasks.md @@ -105,11 +105,30 @@ task: ## Task Implementation -A custom task implementation needs to return a function with the following signature: +From [Specification Version](../Configuration.md#specification-versions) 5.0, a custom task module's default export is a **factory function** that receives the task `options` and returns a **descriptor** object. The descriptor declares the task's work and its optional callbacks in one place: + +* `task` — a traditional task body (see the signature below), **or** +* `steps` — an array of steps for a [cache-aware task](#cache-aware-tasks) (exactly one of `task` or `steps` must be defined) +* `requiresDependencies` — optional; whether the task needs access to the resources of the project's dependencies. When `false` (the default), the `dependencies` reader is not provided +* `determineRequiredDependencies` — optional, see [Required Dependencies](#required-dependencies) +* `determineBuildSignature` — optional callback contributing to the project's build signature +* `specVersion` — optional; if set, it must equal the task extension's configured Specification Version + +The factory receives only the `options` object (never readers or a `taskUtil`), so it must be pure: it may branch on `options` and precompute values, but it must not read or write resources. Those happen in the `task` body (or in the step callbacks). + +::: info +Below Specification Version 5.0, the default export **is** the task body itself (the signature shown below), and the callbacks are top-level named exports. The factory-and-descriptor model, cache-aware `steps`, and the `determineBuildSignature` callback are available from Specification Version 5.0. +::: ::: code-group ```js [ESM] +export default function(options) { + return { + task: myTask + }; +}; + /** * Custom task API * @@ -142,12 +161,18 @@ A custom task implementation needs to return a function with the following signa * @returns {Promise} * Promise resolving once the task has finished */ -export default async function({dependencies, log, options, taskUtil, workspace}) { +async function myTask({dependencies, log, options, taskUtil, workspace}) { // [...] }; ``` ```js [CommonJS] +module.exports = function(options) { + return { + task: myTask + }; +}; + /** * Custom task API * @@ -180,7 +205,7 @@ export default async function({dependencies, log, options, taskUtil, workspace}) * @returns {Promise} * Promise resolving once the task has finished */ -module.exports = async function({dependencies, log, options, taskUtil, workspace}) { +async function myTask({dependencies, log, options, taskUtil, workspace}) { // [...] }; ``` @@ -193,13 +218,16 @@ This functionality has been added with UI5 CLI [`v3.0.0`](https://github.com/SAP ::: -Custom tasks can export an optional callback function `determineRequiredDependencies` to control which dependency-resources are made available through the `dependencies`-reader that is provided to the task. By reducing the amount of required dependencies or by not requiring any, UI5 CLI might be able to build a project faster. +Custom tasks can declare an optional `determineRequiredDependencies` callback to control which dependency-resources are made available through the `dependencies`-reader that is provided to the task. By reducing the amount of required dependencies or by not requiring any, UI5 CLI might be able to build a project faster. + +From Specification Version 5.0 the callback is a property of the descriptor the task factory returns. Below 5.0 it is a top-level named export of the task module. Before executing a task, UI5 CLI will ensure that all required dependencies have been built. -If this callback is not provided, UI5 CLI will make an assumption as to whether the custom task requires access to any resources of dependencies based on the defined Specification Version of the custom task extension: +If this callback is not provided, UI5 CLI determines the required dependencies from the descriptor or the Specification Version of the custom task extension: -* **Specification Version 3.0 and later:** If no callback is provided, UI5 CLI assumes that no dependencies are required. In this case, the `dependencies` parameter will be omitted. +* **Specification Version 5.0 and later:** The descriptor's `requiresDependencies` property controls access. When it is `true`, UI5 CLI provides all dependencies. When it is `false` or omitted, UI5 CLI omits the `dependencies` parameter. +* **Specification Versions 3.0 through 4.0:** UI5 CLI assumes that no dependencies are required and omits the `dependencies` parameter. * **Specification Versions before 3.0:** If no callback is provided, UI5 CLI assumes that all dependencies are required. @@ -208,6 +236,13 @@ If this callback is not provided, UI5 CLI will make an assumption as to whether ::: code-group ```js [ESM] +export default function(options) { + return { + determineRequiredDependencies, + task + }; +}; + /** * Callback function to define the list of required dependencies * @@ -232,7 +267,7 @@ If this callback is not provided, UI5 CLI will make an assumption as to whether * UI5 CLI will ensure that those dependencies have been * built before executing the task. */ -export async function determineRequiredDependencies({availableDependencies, getDependencies, getProject, options}) { +async function determineRequiredDependencies({availableDependencies, getDependencies, getProject, options}) { // "availableDependencies" could look like this: Set(3) { "sap.ui.core", "sap.m", "my.lib" } // Reduce list of required dependencies: Do not require any UI5 framework projects @@ -247,6 +282,13 @@ export async function determineRequiredDependencies({availableDependencies, getD ``` ```js [CommonJS] +module.exports = function(options) { + return { + determineRequiredDependencies, + task + }; +}; + /** * Callback function to define the list of required dependencies * @@ -271,7 +313,7 @@ export async function determineRequiredDependencies({availableDependencies, getD * UI5 CLI will ensure that those dependencies have been * built before executing the task. */ -module.exports.determineRequiredDependencies = async function({availableDependencies, getDependencies, getProject, options}) { +async function determineRequiredDependencies({availableDependencies, getDependencies, getProject, options}) { // "availableDependencies" could look like this: Set(3) { "sap.ui.core", "sap.m", "my.lib" } // Reduce list of required dependencies: Do not require any UI5 framework projects @@ -288,28 +330,30 @@ module.exports.determineRequiredDependencies = async function({availableDependen ### "Cache-aware" Tasks -Due to UI5 Builder and UI5 Server supporting **build caches** of task data, custom tasks can opt into this behavior to improve performance. A cache-aware task is a **step-based task**: instead of a task body, it default-exports a factory `build(options)` and declares a static `stepBased` flag. The factory returns an array of steps that describe the work. The build cache tracks each step's inputs and, on a delta build, re-runs only the steps (and, within a step, only the keys) whose inputs changed, restoring the rest from cache. A task that is not step-based, runs on a Specification Version below 5.0, or has no cache available, processes all resources from scratch. +Due to UI5 Builder and UI5 Server supporting **build caches** of task data, custom tasks can opt into this behavior to improve performance. A cache-aware task is a **step-based task**: its descriptor returns a `steps` array instead of a `task` body. The steps describe the work. The build cache tracks each step's inputs and, on a delta build, re-runs only the steps (and, within a step, only the keys) whose inputs changed, restoring the rest from cache. A task that returns a `task` body, runs on a Specification Version below 5.0, or has no cache available, processes all resources from scratch. -Step-based custom tasks are available from Specification Version 5.0. A task opts in with a static `stepBased` export set to `true`: +Step-based custom tasks are available from Specification Version 5.0. A task opts in by returning a `steps` array (instead of `task`) from its descriptor: ::: code-group ```js [ESM] -export const stepBased = true; -export default function build(options) { - return [ /* steps */ ]; +export default function(options) { + return { + steps: [ /* steps */ ] + }; }; ``` ```js [CommonJS] -module.exports = function build(options) { - return [ /* steps */ ]; +module.exports = function(options) { + return { + steps: [ /* steps */ ] + }; }; -module.exports.stepBased = true; ``` ::: -The factory receives the task `options` only (the same `options` object the standard task function receives); it never receives readers or a `taskUtil`, so it cannot close over build state. It must be pure over `options`: it may branch on `options.projectNamespace`, precompute glob patterns, or include or omit steps, but it must not read or write resources. Every input a step reads arrives through the step's own callback arguments. +The factory receives the task `options` only (the same `options` object the task body receives); it never receives readers or a `taskUtil`, so it cannot close over build state. It must be pure over `options`: it may branch on `options.projectNamespace`, precompute glob patterns, or include or omit steps, but it must not read or write resources. Every input a step reads arrives through the step's own callback arguments. There are two kinds of steps, run in array order: @@ -322,7 +366,7 @@ A step may list earlier step names in `needs`; those steps' return values then a ::: info Best Practices for Cache-aware Tasks 1. **Keep tasks deterministic**: Given the same inputs, always produce the same outputs. -2. **Keep the factory pure**: The `build(options)` factory must only return step descriptors from `options`. Never read or write resources in the factory; do that in the step callbacks. +2. **Keep the factory pure**: The factory must only return the descriptor (with its `steps`) from `options`. Never read or write resources in the factory; do that in the step callbacks. 3. **Read and write only through the callback arguments**: Every input a step reads must arrive through its `workspace`, `dependencies`, `taskUtil`, or `needs` arguments, and every write must go through the step's own `workspace`. A read or write that bypasses these is not recorded, so the cache cannot track it and a delta build can serve stale output. 4. **Import heavy processors lazily**: UI5 CLI calls the factory on every build to discover the steps, even when every step is a cache hit. Import heavy modules inside the step callbacks (for example `const p = (await import("./processor.js")).default`), not at the top of the module, so a fully cached build does not load them. 5. **Split work carefully**: Only use a map step if each key can be processed independently. Two keys that run concurrently must not write the same path. @@ -351,9 +395,8 @@ const log = getLogger("builder:tasks:renderMarkdownFiles"); /* * Render all .md (Markdown) files in the project to HTML */ -export const stepBased = true; -export default function build(options) { - return [{ +export default function(options) { + return {steps: [{ name: "render", // One cached invocation per Markdown file, so a delta build re-renders only the files that changed. keys: async ({workspace}) => workspace.byGlob("**/*.md"), @@ -375,7 +418,7 @@ export default function build(options) { string: htmlString })); } - }]; + }]}; }; ``` @@ -388,8 +431,8 @@ const log = getLogger("builder:tasks:renderMarkdownFiles"); /* * Render all .md (Markdown) files in the project to HTML */ -module.exports = function build(options) { - return [{ +module.exports = function(options) { + return {steps: [{ name: "render", // One cached invocation per Markdown file, so a delta build re-renders only the files that changed. keys: async ({workspace}) => workspace.byGlob("**/*.md"), @@ -411,9 +454,8 @@ module.exports = function build(options) { string: htmlString })); } - }]; + }]}; }; -module.exports.stepBased = true; ``` ::: @@ -435,11 +477,15 @@ APIs to retrieve additional information about the project currently being built ```js [ESM] import path from "node:path"; +export default function(options) { + return {task}; +}; + /* * Compile a list of all licenses of the project's dependencies * and write it to "dependency-license-summary.json */ -export default async function({dependencies, log, options, taskUtil, workspace}) { +async function task({dependencies, log, options, taskUtil, workspace}) { const {createResource} = taskUtil.resourceFactory; const licenses = new Map(); const projectsVisited = new Set(); @@ -483,11 +529,15 @@ export default async function({dependencies, log, options, taskUtil, workspace}) ```js [CommonJS] const path = require("node:path"); +module.exports = function(options) { + return {task}; +}; + /* * Compile a list of all licenses of the project's dependencies * and write it to "dependency-license-summary.json" */ -module.exports = async function({dependencies, log, options, taskUtil, workspace}) { +async function task({dependencies, log, options, taskUtil, workspace}) { const {createResource} = taskUtil.resourceFactory; const licenses = new Map(); const projectsVisited = new Set(); diff --git a/packages/builder/lib/tasks/buildThemes.js b/packages/builder/lib/tasks/buildThemes.js index 2305440cd0d..7b9a1b64378 100644 --- a/packages/builder/lib/tasks/buildThemes.js +++ b/packages/builder/lib/tasks/buildThemes.js @@ -168,7 +168,7 @@ async function isThemeAvailable(themeResource, combo, {librariesPattern, themesP * A step-based task: the default export is a factory returning one map step with a key per theme's * library.source.less. A step probes only the gating marker and imports of its own theme, * so a delta build rebuilds only the affected theme and leaves the others served from cache. Standalone - * invocation runs every theme through @ui5/builder's runSteps. + * invocation runs every theme through @ui5/builder's runTask. * * @public * @function default @@ -180,12 +180,12 @@ async function isThemeAvailable(themeResource, combo, {librariesPattern, themesP * @param {string} [options.librariesPattern] Search pattern for .library files * @param {string} [options.themesPattern] Search pattern for sap.ui.core theme folders * @param {boolean} [options.compress=true] - * @returns {object[]} The task's build steps + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor with its build steps */ -export default function build({projectName, inputPattern, librariesPattern, themesPattern, compress}) { +export default function defineTask({projectName, inputPattern, librariesPattern, themesPattern, compress}) { compress = compress === undefined ? true : compress; - return [{ + const buildThemesStep = { name: "buildThemes", // One key per theme's library.source.less, so a delta build rebuilds only the affected theme. keys: async ({workspace}) => workspace.byGlob(inputPattern), @@ -204,5 +204,10 @@ export default function build({projectName, inputPattern, librariesPattern, them const processedResources = await buildThemeResources([themeResource], combo, compress, taskUtil); await Promise.all(processedResources.map((resource) => workspace.write(resource))); }, - }]; + }; + + return { + requiresDependencies: true, + steps: [buildThemesStep] + }; } diff --git a/packages/builder/lib/tasks/bundlers/generateBundle.js b/packages/builder/lib/tasks/bundlers/generateBundle.js index 12c2841c5fb..081787a1540 100644 --- a/packages/builder/lib/tasks/bundlers/generateBundle.js +++ b/packages/builder/lib/tasks/bundlers/generateBundle.js @@ -8,127 +8,127 @@ import ReaderCollectionPrioritized from "@ui5/fs/ReaderCollectionPrioritized"; * @module @ui5/builder/tasks/bundlers/generateBundle */ -/* eslint-disable max-len */ /** * Generates a bundle based on the given bundle definition * + * The default export is the task factory. It receives the task options and returns the task + * descriptor. The same options are passed to the task body at execution time. + * * @public * @function default * @static * - * @param {object} parameters Parameters - * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files - * @param {@ui5/fs/ReaderCollection} parameters.dependencies Collection to read dependency files - * @param {@ui5/project/build/helpers/TaskUtil|object} [parameters.taskUtil] TaskUtil - * @param {object} parameters.options Options - * @param {string} parameters.options.projectName Project name - * @param {module:@ui5/builder/processors/bundlers/moduleBundler~ModuleBundleDefinition} parameters.options.bundleDefinition Module + * @param {object} options Options + * @param {string} options.projectName Project name + * @param {module:@ui5/builder/processors/bundlers/moduleBundler~ModuleBundleDefinition} options.bundleDefinition Module * bundle definition - * @param {module:@ui5/builder/processors/bundlers/moduleBundler~ModuleBundleOptions} [parameters.options.bundleOptions] Module + * @param {module:@ui5/builder/processors/bundlers/moduleBundler~ModuleBundleOptions} [options.bundleOptions] Module * bundle options - * @returns {Promise} Promise resolving with undefined once data has been written + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor */ -/* eslint-enable max-len */ -export default async function({ - workspace, dependencies, taskUtil, options: {projectName, bundleDefinition, bundleOptions} -}) { - let combo = new ReaderCollectionPrioritized({ - name: `generateBundle - prioritize workspace over dependencies: ${projectName}`, - readers: [workspace, dependencies] - }); +export default function defineTask(options) { + return {requiresDependencies: true, task: async function({ + workspace, dependencies, taskUtil, options: {projectName, bundleDefinition, bundleOptions} + }) { + let combo = new ReaderCollectionPrioritized({ + name: `generateBundle - prioritize workspace over dependencies: ${projectName}`, + readers: [workspace, dependencies] + }); - const optimize = !bundleOptions || bundleOptions.optimize !== false; - if (taskUtil) { - /* Scenarios - 1. Optimize bundle with minification already done - Workspace: - * /resources/my/lib/Control.js [ui5:HasDebugVariant] - * /resources/my/lib/Control.js.map [ui5:HasDebugVariant] - * /resources/my/lib/Control-dbg.js [ui5:IsDebugVariant] + const optimize = !bundleOptions || bundleOptions.optimize !== false; + if (taskUtil) { + /* Scenarios + 1. Optimize bundle with minification already done + Workspace: + * /resources/my/lib/Control.js [ui5:HasDebugVariant] + * /resources/my/lib/Control.js.map [ui5:HasDebugVariant] + * /resources/my/lib/Control-dbg.js [ui5:IsDebugVariant] - Bundler input: - * /resources/my/lib/Control.js - * /resources/my/lib/Control.js.map + Bundler input: + * /resources/my/lib/Control.js + * /resources/my/lib/Control.js.map - => Filter out debug resources + => Filter out debug resources - 2. Optimize bundle with no minification - * /resources/my/lib/Control.js + 2. Optimize bundle with no minification + * /resources/my/lib/Control.js - => No action necessary + => No action necessary - 3. Debug-bundle with minification already done - Workspace: - * /resources/my/lib/Control.js [ui5:HasDebugVariant] - * /resources/my/lib/Control.js.map [ui5:HasDebugVariant] - * /resources/my/lib/Control-dbg.js [ui5:IsDebugVariant] + 3. Debug-bundle with minification already done + Workspace: + * /resources/my/lib/Control.js [ui5:HasDebugVariant] + * /resources/my/lib/Control.js.map [ui5:HasDebugVariant] + * /resources/my/lib/Control-dbg.js [ui5:IsDebugVariant] - Bundler input: - * /resources/my/lib/Control-dbg.js - * moduleNameMapping: [{"/resources/my/lib/Control-dbg.js": "my/lib/Control.js"}] + Bundler input: + * /resources/my/lib/Control-dbg.js + * moduleNameMapping: [{"/resources/my/lib/Control-dbg.js": "my/lib/Control.js"}] - => Filter out minified-resources (tagged as "HasDebugVariant", incl. source maps) and rename debug-files + => Filter out minified-resources (tagged as "HasDebugVariant", incl. source maps) and + rename debug-files - 4. Debug-bundle with no minification - * /resources/my/lib/Control.js + 4. Debug-bundle with no minification + * /resources/my/lib/Control.js - => No action necessary + => No action necessary - 5. Bundle with external input (optimize or not), e.g. TS-project - Workspace: - * /resources/my/lib/Control.ts - * /resources/my/lib/Control.js - * /resources/my/lib/Control.js.map + 5. Bundle with external input (optimize or not), e.g. TS-project + Workspace: + * /resources/my/lib/Control.ts + * /resources/my/lib/Control.js + * /resources/my/lib/Control.js.map - Bundler input: - * /resources/my/lib/Control.js - * /resources/my/lib/Control.js.map - */ + Bundler input: + * /resources/my/lib/Control.js + * /resources/my/lib/Control.js.map + */ - // Omit -dbg files for optimize bundles and vice versa - const filterTag = optimize ? - taskUtil.STANDARD_TAGS.IsDebugVariant : taskUtil.STANDARD_TAGS.HasDebugVariant; - combo = taskUtil.resourceFactory.createFilterReader({ - reader: combo, - callback: function(resource) { - return !taskUtil.getTag(resource, filterTag); - } - }); - } - const coreVersion = taskUtil?.getProject("sap.ui.core")?.getVersion(); - const allowStringBundling = taskUtil?.getProject().getSpecVersion().lt("4.0"); - return combo.byGlob("/resources/**/*.{js,json,xml,html,properties,library,js.map}").then((resources) => { - const options = { - bundleDefinition: applyDefaultsToBundleDefinition(bundleDefinition, taskUtil), - bundleOptions, - allowStringBundling - }; - if (!optimize && taskUtil) { - options.moduleNameMapping = createModuleNameMapping({resources, taskUtil}); - } - if (coreVersion) { - options.targetUi5CoreVersion = coreVersion; - } - return moduleBundler({options, resources}).then((bundles) => { - return Promise.all(bundles.map(({bundle, sourceMap} = {}) => { - if (!bundle) { - // Skip empty bundles - return; + // Omit -dbg files for optimize bundles and vice versa + const filterTag = optimize ? + taskUtil.STANDARD_TAGS.IsDebugVariant : taskUtil.STANDARD_TAGS.HasDebugVariant; + combo = taskUtil.resourceFactory.createFilterReader({ + reader: combo, + callback: function(resource) { + return !taskUtil.getTag(resource, filterTag); } - if (taskUtil) { - taskUtil.setTag(bundle, taskUtil.STANDARD_TAGS.IsBundle); + }); + } + const coreVersion = taskUtil?.getProject("sap.ui.core")?.getVersion(); + const allowStringBundling = taskUtil?.getProject().getSpecVersion().lt("4.0"); + return combo.byGlob("/resources/**/*.{js,json,xml,html,properties,library,js.map}").then((resources) => { + const options = { + bundleDefinition: applyDefaultsToBundleDefinition(bundleDefinition, taskUtil), + bundleOptions, + allowStringBundling + }; + if (!optimize && taskUtil) { + options.moduleNameMapping = createModuleNameMapping({resources, taskUtil}); + } + if (coreVersion) { + options.targetUi5CoreVersion = coreVersion; + } + return moduleBundler({options, resources}).then((bundles) => { + return Promise.all(bundles.map(({bundle, sourceMap} = {}) => { + if (!bundle) { + // Skip empty bundles + return; + } + if (taskUtil) { + taskUtil.setTag(bundle, taskUtil.STANDARD_TAGS.IsBundle); + if (sourceMap) { + // Clear tag that might have been set by the minify task, in cases where + // the bundle name is identical to a source file + taskUtil.clearTag(sourceMap, taskUtil.STANDARD_TAGS.OmitFromBuildResult); + } + } + const writes = [workspace.write(bundle)]; if (sourceMap) { - // Clear tag that might have been set by the minify task, in cases where - // the bundle name is identical to a source file - taskUtil.clearTag(sourceMap, taskUtil.STANDARD_TAGS.OmitFromBuildResult); + writes.push(workspace.write(sourceMap)); } - } - const writes = [workspace.write(bundle)]; - if (sourceMap) { - writes.push(workspace.write(sourceMap)); - } - return Promise.all(writes); - })); + return Promise.all(writes); + })); + }); }); - }); + }}; } diff --git a/packages/builder/lib/tasks/bundlers/generateComponentPreload.js b/packages/builder/lib/tasks/bundlers/generateComponentPreload.js index e5eff43a35e..8660a954758 100644 --- a/packages/builder/lib/tasks/bundlers/generateComponentPreload.js +++ b/packages/builder/lib/tasks/bundlers/generateComponentPreload.js @@ -11,12 +11,33 @@ import {negateFilters} from "../../lbt/resources/ResourceFilterList.js"; */ /** - * Task to for application bundling. + * Task for Component preload bundling. + * + * The default export is the task factory. It receives the task options and returns the task + * descriptor. The same options are passed to the task body at execution time. * * @public * @function default * @static * + * @param {object} options Options + * @param {string} options.projectName Project name + * @param {string[]} [options.excludes=[]] List of modules declared as glob patterns (resource name patterns) + * that should be excluded. + * A pattern ending with a slash '/' will, similarly to the use of a single '*' or double '**' asterisk, + * denote an arbitrary number of characters or folder names. + * Re-includes should be marked with a leading exclamation mark '!'. The order of filters is relevant; a later + * inclusion overrides an earlier exclusion, and vice versa. + * @param {string[]} [options.paths] Array of paths (or glob patterns) for component files + * @param {string[]} [options.namespaces] Array of component namespaces + * @param {string[]} [options.skipBundles] Names of bundles that should not be created + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor + */ +export default function defineTask(options) { + return {task}; +} + +/** * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files * @param {@ui5/project/build/helpers/TaskUtil|object} [parameters.taskUtil] TaskUtil @@ -31,9 +52,9 @@ import {negateFilters} from "../../lbt/resources/ResourceFilterList.js"; * @param {string[]} [parameters.options.paths] Array of paths (or glob patterns) for component files * @param {string[]} [parameters.options.namespaces] Array of component namespaces * @param {string[]} [parameters.options.skipBundles] Names of bundles that should not be created - * @returns {Promise} Promise resolving with undefined once data has been written + * @returns {Promise} Promise resolving once the component preload bundles have been written */ -export default async function({ +async function task({ workspace, taskUtil, options: {projectName, paths, namespaces, skipBundles = [], excludes = []} }) { let nonDbgWorkspace = workspace; @@ -106,8 +127,9 @@ export default async function({ allNamespaces.forEach((ns) => { if (ns !== namespace && ns.startsWith(`${namespace}/`)) { filters.push(`!${ns}/`); - // Explicitly exclude manifest.json files of subcomponents since the general exclude above this - // comment only applies to the configured default file types, which do not include ".json" + // Explicitly exclude manifest.json files of subcomponents since the general exclude + // above this comment only applies to the configured default file types, which do + // not include ".json" filters.push(`!${ns}/**/manifest.json`); } }); diff --git a/packages/builder/lib/tasks/bundlers/generateFlexChangesBundle.js b/packages/builder/lib/tasks/bundlers/generateFlexChangesBundle.js index 8c2f8b0feca..338a0568e32 100644 --- a/packages/builder/lib/tasks/bundlers/generateFlexChangesBundle.js +++ b/packages/builder/lib/tasks/bundlers/generateFlexChangesBundle.js @@ -8,7 +8,6 @@ import semver from "semver"; * @module @ui5/builder/tasks/bundlers/generateFlexChangesBundle */ -/* eslint "jsdoc/check-param-names": ["error", {"disableExtraPropertyReporting":true}] */ /** * Task to create changes-bundle.json file containing all changes stored in the /changes folder for easier consumption * at runtime. @@ -21,15 +20,24 @@ import semver from "semver"; * @function default * @static * + * @param {object} [options] Options + * @param {string} [options.projectNamespace] Project Namespace + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor + */ +export default function defineTask(options) { + return {task}; +} + +/** * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files * @param {@ui5/project/build/helpers/TaskUtil|object} [parameters.taskUtil] TaskUtil * @param {object} [parameters.options] Options * @param {string} [parameters.options.projectNamespace] Project Namespace - * @returns {Promise} Promise resolving with undefined once data has been written + * @returns {Promise} Promise resolving once the flex changes bundle has been written */ -export default async function({workspace, taskUtil, options = {}}) { - const namespace = options.projectNamespace; +async function task({workspace, taskUtil, options: {projectNamespace} = {}}) { + const namespace = projectNamespace; // Use the given namespace if available, otherwise use no namespace // (e.g. in case no manifest.json is present) @@ -116,7 +124,8 @@ export default async function({workspace, taskUtil, options = {}}) { if (allResources.length > 0) { const versionArray = await readManifestMinUI5Version(); const versions = versionArray.map((version) => semver.coerce(version)); - const versionsAllSuitableForFlexBundle = versions.every((version) => semver.compare(version, "1.73.0") >= 0); + const versionsAllSuitableForFlexBundle = + versions.every((version) => semver.compare(version, "1.73.0") >= 0); let hasFlexBundleVersion = false; let flexBundle = {}; if (versionsAllSuitableForFlexBundle) { diff --git a/packages/builder/lib/tasks/bundlers/generateLibraryPreload.js b/packages/builder/lib/tasks/bundlers/generateLibraryPreload.js index f310a65f4a4..7f318ae22d2 100644 --- a/packages/builder/lib/tasks/bundlers/generateLibraryPreload.js +++ b/packages/builder/lib/tasks/bundlers/generateLibraryPreload.js @@ -321,6 +321,22 @@ function getSapUiCoreBunDef(name, filters, preload) { * @function default * @static * + * @param {object} options Options + * @param {string} options.projectName Project name + * @param {string[]} [options.skipBundles] Names of bundles that should not be created + * @param {string[]} [options.excludes=[]] List of modules declared as glob patterns (resource name patterns) + * that should be excluded from the library-preload.js bundle. + * A pattern ending with a slash '/' will, similarly to the use of a single '*' or double '**' asterisk, + * denote an arbitrary number of characters or folder names. + * Re-includes should be marked with a leading exclamation mark '!'. The order of filters is relevant; a later + * inclusion overrides an earlier exclusion, and vice versa. + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor + */ +export default function defineTask(options) { + return {task}; +} + +/** * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files * @param {@ui5/project/build/helpers/TaskUtil} [parameters.taskUtil] TaskUtil @@ -333,9 +349,9 @@ function getSapUiCoreBunDef(name, filters, preload) { * denote an arbitrary number of characters or folder names. * Re-includes should be marked with a leading exclamation mark '!'. The order of filters is relevant; a later * inclusion overrides an earlier exclusion, and vice versa. - * @returns {Promise} Promise resolving with undefined once data has been written + * @returns {Promise} Promise resolving once the library preload bundles have been written */ -export default async function({workspace, taskUtil, options: {skipBundles = [], excludes = [], projectName}}) { +async function task({workspace, taskUtil, options: {skipBundles = [], excludes = [], projectName}}) { let nonDbgWorkspace = workspace; if (taskUtil) { nonDbgWorkspace = taskUtil.resourceFactory.createFilterReader({ @@ -522,7 +538,7 @@ export default async function({workspace, taskUtil, options: {skipBundles = [], skipIfEmpty: true } // Note: Although the bundle uses optimize=false, there is - // no moduleNameMapping needed, as support files are excluded from minification. + // no moduleNameMapping needed, as support files are excluded from minify. }, resources }) @@ -536,14 +552,14 @@ export default async function({workspace, taskUtil, options: {skipBundles = [], if (skipBundles.includes(`${libraryNamespace}/library-preload.js`) && !skipBundles.includes(`${libraryNamespace}/_library-content.js`)) { - // If the standard preload bundle is skipped, ensure to also skip the content bundle, + // If the standard preload bundle is skipped, also skip the content bundle, // since they depend on each other skipBundles.push(`${libraryNamespace}/_library-content.js`); } if (skipBundles.includes(`${libraryNamespace}/_library-content.js`) && !skipBundles.includes(`${libraryNamespace}/library-preload.js`)) { - // If the content bundle is skipped, the default preload bundle must be skipped as well + // If the content bundle is skipped, the default preload bundle must be skipped too throw new Error( `A custom bundle '${libraryNamespace}/_library-content.js' has been defined, ` + `but it also requires a corresponding custom bundle definition for ` + @@ -593,7 +609,7 @@ export default async function({workspace, taskUtil, options: {skipBundles = [], skipIfEmpty: true } // Note: Although the bundle uses optimize=false, there is - // no moduleNameMapping needed, as support files are excluded from minification. + // no moduleNameMapping needed, as support files are excluded from minify. }, resources }) diff --git a/packages/builder/lib/tasks/bundlers/generateStandaloneAppBundle.js b/packages/builder/lib/tasks/bundlers/generateStandaloneAppBundle.js index 93f5a1f92ef..34d83ea36ab 100644 --- a/packages/builder/lib/tasks/bundlers/generateStandaloneAppBundle.js +++ b/packages/builder/lib/tasks/bundlers/generateStandaloneAppBundle.js @@ -65,7 +65,6 @@ function getBundleDefinition(config) { * @module @ui5/builder/tasks/bundlers/generateStandaloneAppBundle */ -/* eslint "jsdoc/check-param-names": ["error", {"disableExtraPropertyReporting":true}] */ /** * Task for bundling standalone applications. * @@ -73,6 +72,19 @@ function getBundleDefinition(config) { * @function default * @static * + * @param {object} options Options + * @param {string} options.projectName Project name + * @param {string} [options.projectNamespace] Project namespace + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor + */ +export default function defineTask(options) { + return { + requiresDependencies: true, + task + }; +} + +/** * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files * @param {@ui5/fs/AbstractReader} parameters.dependencies Reader or Collection to read dependency files @@ -80,11 +92,10 @@ function getBundleDefinition(config) { * @param {object} parameters.options Options * @param {string} parameters.options.projectName Project name * @param {string} [parameters.options.projectNamespace] Project namespace - * @returns {Promise} Promise resolving with undefined once data has been written + * @returns {Promise} Promise resolving once the standalone app bundle has been written */ -export default async function({workspace, dependencies, taskUtil, options}) { - const {projectName} = options; - const namespace = options.projectNamespace; +async function task({workspace, dependencies, taskUtil, options: {projectName, projectNamespace}}) { + const namespace = projectNamespace; const coreVersion = taskUtil?.getProject("sap.ui.core")?.getVersion(); if (!namespace) { diff --git a/packages/builder/lib/tasks/enhanceManifest.js b/packages/builder/lib/tasks/enhanceManifest.js index 66ff1e34ee4..05d3530e826 100644 --- a/packages/builder/lib/tasks/enhanceManifest.js +++ b/packages/builder/lib/tasks/enhanceManifest.js @@ -22,10 +22,10 @@ import fsInterface from "@ui5/fs/fsInterface"; * * @param {object} options Options * @param {string} options.projectNamespace Namespace of the application - * @returns {object[]} The task's build steps + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor with its build steps */ -export default function build({projectNamespace}) { - return [{ +export default function defineTask({projectNamespace}) { + return {steps: [{ name: "enhanceManifest", // One key per manifest.json. The i18n bundle reads made by manifestEnhancer go through the step's // workspace and are thus tracked per step. @@ -43,5 +43,5 @@ export default function build({projectNamespace}) { await workspace.write(processed); } }, - }]; + }]}; } diff --git a/packages/builder/lib/tasks/escapeNonAsciiCharacters.js b/packages/builder/lib/tasks/escapeNonAsciiCharacters.js index bfe724bd575..6791f2334dd 100644 --- a/packages/builder/lib/tasks/escapeNonAsciiCharacters.js +++ b/packages/builder/lib/tasks/escapeNonAsciiCharacters.js @@ -20,9 +20,9 @@ import nonAsciiEscaper from "../processors/nonAsciiEscaper.js"; * @param {object} options Options * @param {string} options.pattern Glob pattern to locate the files to be processed * @param {string} options.encoding source file encoding either "UTF-8" or "ISO-8859-1" - * @returns {object[]} The task's build steps + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor with its build steps */ -export default function build({pattern, encoding}) { +export default function defineTask({pattern, encoding}) { if (!encoding) { throw new Error("[escapeNonAsciiCharacters] Mandatory option 'encoding' not provided"); } @@ -31,7 +31,7 @@ export default function build({pattern, encoding}) { encoding: nonAsciiEscaper.getEncodingFromAlias(encoding) }; - return [{ + return {steps: [{ name: "escapeNonAsciiCharacters", // One key per matched resource, so a delta build re-processes only the resources that changed. keys: async ({workspace}) => workspace.byGlob(pattern), @@ -41,5 +41,5 @@ export default function build({pattern, encoding}) { await workspace.write(processed); } }, - }]; + }]}; } diff --git a/packages/builder/lib/tasks/generateCachebusterInfo.js b/packages/builder/lib/tasks/generateCachebusterInfo.js index 76610f8cc8f..3902d7a0c5d 100644 --- a/packages/builder/lib/tasks/generateCachebusterInfo.js +++ b/packages/builder/lib/tasks/generateCachebusterInfo.js @@ -34,7 +34,6 @@ function getSigner(type) { } } -/* eslint "jsdoc/check-param-names": ["error", {"disableExtraPropertyReporting":true}] */ /** * Task to generate the application cachebuster info file. * @@ -42,19 +41,26 @@ function getSigner(type) { * @function default * @static * + * @param {object} options Options + * @param {string} options.projectNamespace Namespace of the application + * @param {string} [options.signatureType='time'] Type of signature to be used ('time' or 'hash') + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor + */ +export default function defineTask(options) { + return {task}; +} + +/** * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files * @param {object} parameters.options Options * @param {string} parameters.options.projectNamespace Namespace of the application * @param {string} [parameters.options.signatureType='time'] Type of signature to be used ('time' or 'hash') - * @returns {Promise} Promise resolving with undefined once data has been written + * @returns {Promise} Promise resolving once the cachebuster info has been written */ -export default function({workspace, options}) { - const {signatureType} = options; - const namespace = options.projectNamespace; - - const basePath = `/resources/${namespace}/`; - return workspace.byGlob(`/resources/${namespace}/**/*`) +function task({workspace, options: {projectNamespace, signatureType}}) { + const basePath = `/resources/${projectNamespace}/`; + return workspace.byGlob(`/resources/${projectNamespace}/**/*`) .then(async (resources) => { const cachebusterInfo = Object.create(null); const signer = getSigner(signatureType); @@ -71,7 +77,7 @@ export default function({workspace, options}) { cachebusterInfo[resourcePath] = await signer(resource); })); const cachebusterInfoResource = createResource({ - path: `/resources/${namespace}/sap-ui-cachebuster-info.json`, + path: `/resources/${projectNamespace}/sap-ui-cachebuster-info.json`, string: JSON.stringify(cachebusterInfo, null, 2) }); return workspace.write(cachebusterInfoResource); diff --git a/packages/builder/lib/tasks/generateLibraryManifest.js b/packages/builder/lib/tasks/generateLibraryManifest.js index 44c464e322b..06e4b1dd60a 100644 --- a/packages/builder/lib/tasks/generateLibraryManifest.js +++ b/packages/builder/lib/tasks/generateLibraryManifest.js @@ -15,21 +15,32 @@ import manifestCreator from "../processors/manifestCreator.js"; * @function default * @static * + * @param {object} options Options + * @param {string} options.projectName Project name + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor + */ +export default function defineTask(options) { + return {task}; +} + +/** * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files * @param {@ui5/project/build/helpers/TaskUtil|object} [parameters.taskUtil] TaskUtil * @param {object} parameters.options Options * @param {string} parameters.options.projectName Project name - * @returns {Promise} Promise resolving with undefined once data has been written + * @returns {Promise} Promise resolving once the library manifest has been written */ -export default function({workspace, taskUtil, options: {projectName}}) { +function task({workspace, taskUtil, options: {projectName}}) { // Note: // *.library files are needed to identify libraries // *.json files are needed to avoid overwriting them // *.js files are needed to identify nested components // *.less, *.css, *.theming and *.theme files are needed to identify supported themes // *.properties to identify existence of i18n bundles (e.g. messagebundle.properties) - return workspace.byGlob("/resources/**/*.{js,json,library,less,css,theming,theme,properties}").then((resources) => { + return workspace.byGlob( + "/resources/**/*.{js,json,library,less,css,theming,theme,properties}" + ).then((resources) => { // Find all libraries and create a manifest.json file return workspace.byGlob("/resources/**/.library").then((libraryIndicatorResources) => { if (libraryIndicatorResources.length < 1) { diff --git a/packages/builder/lib/tasks/generateResourcesJson.js b/packages/builder/lib/tasks/generateResourcesJson.js index aa612455252..de22875fad5 100644 --- a/packages/builder/lib/tasks/generateResourcesJson.js +++ b/packages/builder/lib/tasks/generateResourcesJson.js @@ -103,15 +103,27 @@ function getCreatorOptions(projectName) { * @function default * @static * + * @param {object} options Options + * @param {string} options.projectName Project name + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor + */ +export default function defineTask(options) { + return { + requiresDependencies: true, + task + }; +} + +/** * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files * @param {@ui5/fs/AbstractReader} parameters.dependencies Reader or Collection to read dependency files * @param {@ui5/project/build/helpers/TaskUtil|object} [parameters.taskUtil] TaskUtil * @param {object} parameters.options Options * @param {string} parameters.options.projectName Project name - * @returns {Promise} Promise resolving with undefined once data has been written + * @returns {Promise} Promise resolving once the resources.json has been written */ -export default async function({workspace, dependencies, taskUtil, options: {projectName}}) { +async function task({workspace, dependencies, taskUtil, options: {projectName}}) { let resources = await workspace.byGlob(["/resources/**/*"].concat(DEFAULT_EXCLUDES)); let dependencyResources = await dependencies.byGlob("/resources/**/*.{js,json,xml,html,properties,library,js.map}"); diff --git a/packages/builder/lib/tasks/generateThemeDesignerResources.js b/packages/builder/lib/tasks/generateThemeDesignerResources.js index 78443b3950b..1fc2a8ecda0 100644 --- a/packages/builder/lib/tasks/generateThemeDesignerResources.js +++ b/packages/builder/lib/tasks/generateThemeDesignerResources.js @@ -101,7 +101,7 @@ async function generateThemeDotTheming({workspace, combo, themeFolder}) { * library.less, reading the core .theming and the less imports through its own * combo so those reads are recorded per step. A delta build regenerates only the affected theme. The * later steps consume the scan result through needs, so they stay cached while a library has - * themes even as individual themes change. Standalone invocation runs every step through runSteps. + * themes even as individual themes change. Standalone invocation runs every step through runTask. * * @public * @function default @@ -112,16 +112,19 @@ async function generateThemeDotTheming({workspace, combo, themeFolder}) { * @param {string} options.version Project version * @param {string} [options.projectNamespace] If the project is of type library, provide its * namespace. Omit for type theme-library - * @returns {object[]} The task's build steps + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor with its build steps */ -export default function build(options) { +export default function defineTask(options) { const {projectName, version} = options; const namespace = options.projectNamespace; // Skip sap.ui.documentation since it is not intended to be available in SAP Theme Designer to create // custom themes if (namespace === "sap/ui/documentation") { - return []; + return { + requiresDependencies: true, + steps: [] + }; } let librarySourceLessPattern; @@ -217,5 +220,8 @@ export default function build(options) { }, }); - return steps; + return { + requiresDependencies: true, + steps + }; } diff --git a/packages/builder/lib/tasks/generateVersionInfo.js b/packages/builder/lib/tasks/generateVersionInfo.js index 8cb2f8f6cfb..827dd4493ec 100644 --- a/packages/builder/lib/tasks/generateVersionInfo.js +++ b/packages/builder/lib/tasks/generateVersionInfo.js @@ -14,15 +14,28 @@ const MANIFEST_JSON = "manifest.json"; * @function default * @static * + * @param {object} options Options + * @param {string} options.pattern Glob pattern for .library resources + * @param {object} options.rootProject Root project (used for its name and version) + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor + */ +export default function defineTask(options) { + return { + requiresDependencies: true, + task + }; +} + +/** * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files * @param {@ui5/fs/AbstractReader} parameters.dependencies Reader or Collection to read dependency files * @param {object} parameters.options Options * @param {string} parameters.options.pattern Glob pattern for .library resources - * @param {object} parameters.options.rootProject DuplexCollection to read and write files - * @returns {Promise} Promise resolving with undefined once data has been written + * @param {object} parameters.options.rootProject Root project (used for its name and version) + * @returns {Promise} Promise resolving once sap-ui-version.json has been written */ -export default async ({workspace, dependencies, options: {rootProject, pattern}}) => { +async function task({workspace, dependencies, options: {rootProject, pattern}}) { let resources = await dependencies.byGlob(pattern); resources = resources.filter((res) => res.getProject()?.getType() === "library"); @@ -55,4 +68,4 @@ export default async ({workspace, dependencies, options: {rootProject, pattern}} } }); return workspace.write(versionInfoResource); -}; +} diff --git a/packages/builder/lib/tasks/jsdoc/executeJsdocSdkTransformation.js b/packages/builder/lib/tasks/jsdoc/executeJsdocSdkTransformation.js index 595b52173c6..464ddb7b713 100644 --- a/packages/builder/lib/tasks/jsdoc/executeJsdocSdkTransformation.js +++ b/packages/builder/lib/tasks/jsdoc/executeJsdocSdkTransformation.js @@ -18,17 +18,28 @@ import sdkTransformer from "../../processors/jsdoc/sdkTransformer.js"; * @function default * @static * + * @param {object} options Options + * @param {string|Array} options.dotLibraryPattern Pattern to locate the .library resource to be processed + * @param {string} options.projectName Project name + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor + */ +export default function defineTask(options) { + return { + requiresDependencies: true, + task + }; +} + +/** * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files * @param {@ui5/fs/AbstractReader} parameters.dependencies Reader or Collection to read dependency files * @param {object} parameters.options Options * @param {string|Array} parameters.options.dotLibraryPattern Pattern to locate the .library resource to be processed * @param {string} parameters.options.projectName Project name - * @returns {Promise} Promise resolving with undefined once data has been written + * @returns {Promise} Promise resolving once the transformed api.json has been written */ -const executeJsdocSdkTransformation = async function( - {workspace, dependencies, options: {projectName, dotLibraryPattern}} = {} -) { +async function task({workspace, dependencies, options: {projectName, dotLibraryPattern}} = {}) { if (!projectName || !dotLibraryPattern) { throw new Error("[executeJsdocSdkTransformation]: One or more mandatory options not provided"); } @@ -79,6 +90,4 @@ const executeJsdocSdkTransformation = async function( await Promise.all(createdResources.map((resource) => { return workspace.write(resource); })); -}; - -export default executeJsdocSdkTransformation; +} diff --git a/packages/builder/lib/tasks/jsdoc/generateApiIndex.js b/packages/builder/lib/tasks/jsdoc/generateApiIndex.js index da742792cdf..e9c8a675730 100644 --- a/packages/builder/lib/tasks/jsdoc/generateApiIndex.js +++ b/packages/builder/lib/tasks/jsdoc/generateApiIndex.js @@ -16,18 +16,26 @@ import apiIndexGenerator from "../../processors/jsdoc/apiIndexGenerator.js"; * @function default * @static * + * @param {object} options Options + * @param {string} options.projectName Project name + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor + */ +export default function defineTask(options) { + return { + requiresDependencies: true, + task + }; +} + +/** * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files * @param {@ui5/fs/AbstractReader} parameters.dependencies Reader or Collection to read dependency files * @param {object} parameters.options Options * @param {string} parameters.options.projectName Project name - * @returns {Promise} Promise resolving with undefined once data has been written + * @returns {Promise} Promise resolving once the api-index.json resources have been written */ -export default async function({ - workspace, - dependencies, - options: {projectName} -}) { +async function task({workspace, dependencies, options: {projectName}}) { const combo = new ReaderCollectionPrioritized({ name: `generateApiIndex - workspace + dependencies: ${projectName}`, readers: [workspace, dependencies] diff --git a/packages/builder/lib/tasks/jsdoc/generateJsdoc.js b/packages/builder/lib/tasks/jsdoc/generateJsdoc.js index 938231b0603..108df0b81b3 100644 --- a/packages/builder/lib/tasks/jsdoc/generateJsdoc.js +++ b/packages/builder/lib/tasks/jsdoc/generateJsdoc.js @@ -27,68 +27,81 @@ import {createAdapter} from "@ui5/fs/resourceFactory"; /** * Task to execute a JSDoc build for UI5 projects * + * A factory returning the task descriptor. The factory assembles the glob patterns from + * options.jsdocExcludes and captures options.namespace and + * options.version, which the project definition supplies in place of the project-derived + * values that used to be wrapped around this task. The task body then uses these closure + * values; the per-run options.projectName is read from the task-run options. + * + * The factory stays pure and cheap: the heavy jsdocGenerator processor is only reached inside the + * task body, so calling the factory for the build signature loads no processor. + * * @public * @function default * @static * - * @param {object} parameters Parameters - * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files - * @param {@ui5/fs/AbstractReader} parameters.dependencies Reader or Collection to read dependency files - * @param {module:@ui5/builder/tasks/jsdoc/generateJsdoc~GenerateJsdocOptions} parameters.options Options - * @param {@ui5/project/build/helpers/TaskUtil|object} [parameters.taskUtil] TaskUtil - * @returns {Promise} Promise resolving with undefined once data has been written + * @param {object} options Options + * @param {string} options.namespace Namespace to build (e.g. some/project/name) + * @param {string} options.version Project version + * @param {string[]} [options.jsdocExcludes=[]] List of exclude patterns, relative to /resources/ + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor */ -export default async function generateJsdoc({ - taskUtil, - workspace, - dependencies, - options = {} -}) { - const {projectName, namespace, version, pattern} = - /** @type {module:@ui5/builder/tasks/jsdoc/generateJsdoc~GenerateJsdocOptions} */ (options); - - if (!projectName || !namespace || !version || !pattern) { - throw new Error("[generateJsdoc]: One or more mandatory options not provided"); - } +export default function defineTask(options) { + const {version, namespace, jsdocExcludes = []} = options; + const patterns = ["/resources/**/*.js", ...jsdocExcludes.map((p) => `!/resources/${p}`)]; - const {sourcePath: resourcePath, targetPath, tmpPath, cleanup} = - await utils.createTmpDirs(projectName); - - taskUtil?.registerCleanupTask(cleanup); - - const [writtenResourcesCount] = await Promise.all([ - utils.writeResourcesToDir({ - workspace, - pattern, - targetPath: resourcePath - }), - utils.writeDependencyApisToDir({ - dependencies, - targetPath: path.join(tmpPath, "dependency-apis") - }) - ]); - - if (writtenResourcesCount === 0) { - log.info(`Failed to find any input resources for project ${projectName} using pattern ` + - `${pattern}. Skipping JSDoc generation...`); - return; - } + const task = async function({taskUtil, workspace, dependencies, options = {}}) { + const {projectName} = options; + + if (!projectName || !namespace || !version || !patterns.length) { + throw new Error("[generateJsdoc]: One or more mandatory options not provided"); + } - const createdResources = await jsdocGenerator({ - sourcePath: resourcePath, - targetPath, - tmpPath, - options: { - projectName, - namespace, - version, - variants: ["apijson"] + const {sourcePath: resourcePath, targetPath, tmpPath, cleanup} = + await utils.createTmpDirs(projectName); + + taskUtil?.registerCleanupTask(cleanup); + + const [writtenResourcesCount] = await Promise.all([ + utils.writeResourcesToDir({ + workspace, + pattern: patterns, + targetPath: resourcePath + }), + utils.writeDependencyApisToDir({ + dependencies, + targetPath: path.join(tmpPath, "dependency-apis") + }) + ]); + + if (writtenResourcesCount === 0) { + log.info(`Failed to find any input resources for project ${projectName} using pattern ` + + `${patterns}. Skipping JSDoc generation...`); + return; } - }); - await Promise.all(createdResources.map((resource) => { - return workspace.write(resource); - })); + const createdResources = await jsdocGenerator({ + sourcePath: resourcePath, + targetPath, + tmpPath, + options: { + projectName, + namespace, + version, + variants: ["apijson"] + } + }); + + await Promise.all(createdResources.map((resource) => { + return workspace.write(resource); + })); + }; + + return { + requiresDependencies: true, + determineBuildSignature: async () => version, + task + }; } const utils = { @@ -191,5 +204,5 @@ const utils = { // Export utils for testing only /* istanbul ignore else */ if (process.env.NODE_ENV === "test") { - generateJsdoc._utils = utils; + defineTask._utils = utils; } diff --git a/packages/builder/lib/tasks/minify.js b/packages/builder/lib/tasks/minify.js index 52a8d4af427..e3167940d36 100644 --- a/packages/builder/lib/tasks/minify.js +++ b/packages/builder/lib/tasks/minify.js @@ -28,9 +28,9 @@ const log = getLogger("builder:tasks:minify"); * @param {boolean} [options.useInputSourceMaps=true] Whether to make use of any existing source * maps referenced in the resources to be minified. Use this option to preserve reference to the original * source files, such as TypeScript files, in the generated source map. - * @returns {object[]} The task's build steps + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor with its build steps */ -export default function build({pattern, omitSourceMapResources = false, useInputSourceMaps = true}) { +export default function defineTask({pattern, omitSourceMapResources = false, useInputSourceMaps = true}) { const minifierOptions = { addSourceMappingUrl: !omitSourceMapResources, readSourceMappingUrl: !!useInputSourceMaps, @@ -66,7 +66,7 @@ export default function build({pattern, omitSourceMapResources = false, useInput ]); }; - return [{ + return {steps: [{ name: "minify", // One key per matched resource, so a delta build re-minifies only the resources whose inputs // changed. The input source map is read through the step's workspace and thus tracked per step. @@ -118,5 +118,5 @@ export default function build({pattern, omitSourceMapResources = false, useInput }); await tagAndWrite(processed, workspace, taskUtil); }, - }]; + }]}; } diff --git a/packages/builder/lib/tasks/replaceBuildtime.js b/packages/builder/lib/tasks/replaceBuildtime.js index e8cad9174eb..66e573f6381 100644 --- a/packages/builder/lib/tasks/replaceBuildtime.js +++ b/packages/builder/lib/tasks/replaceBuildtime.js @@ -34,10 +34,10 @@ function formatTimestamp(date) { * * @param {object} options Options * @param {string} options.pattern Pattern to locate the files to be processed - * @returns {object[]} The task's build steps + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor with its build steps */ -export default function build({pattern}) { - return [{ +export default function defineTask({pattern}) { + return {steps: [{ name: "replaceBuildtime", // One key per matched resource, so a delta build re-processes only the resources that changed. keys: async ({workspace}) => workspace.byGlob(pattern), @@ -53,5 +53,5 @@ export default function build({pattern}) { await workspace.write(processed); } }, - }]; + }]}; } diff --git a/packages/builder/lib/tasks/replaceCopyright.js b/packages/builder/lib/tasks/replaceCopyright.js index a88d6ea313d..11017bd9291 100644 --- a/packages/builder/lib/tasks/replaceCopyright.js +++ b/packages/builder/lib/tasks/replaceCopyright.js @@ -31,16 +31,16 @@ import stringReplacer from "../processors/stringReplacer.js"; * @param {object} options Options * @param {string} options.copyright Replacement copyright * @param {string} options.pattern Pattern to locate the files to be processed - * @returns {object[]} The task's build steps + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor with its build steps */ -export default function build({copyright, pattern}) { +export default function defineTask({copyright, pattern}) { if (!copyright) { - return []; + return {steps: []}; } const replacePattern = /(?:\$\{copyright\}|@copyright@)/g; - return [{ + return {steps: [{ name: "replaceCopyright", // One key per matched resource, so a delta build re-processes only the resources that changed. keys: async ({workspace}) => workspace.byGlob(pattern), @@ -59,5 +59,5 @@ export default function build({copyright, pattern}) { await workspace.write(processed); } }, - }]; + }]}; } diff --git a/packages/builder/lib/tasks/replaceVersion.js b/packages/builder/lib/tasks/replaceVersion.js index 47655c86564..fecbcbc256f 100644 --- a/packages/builder/lib/tasks/replaceVersion.js +++ b/packages/builder/lib/tasks/replaceVersion.js @@ -20,15 +20,15 @@ import stringReplacer from "../processors/stringReplacer.js"; * @param {object} options Options * @param {string} options.pattern Pattern to locate the files to be processed * @param {string} options.version Replacement version - * @returns {object[]} The task's build steps + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor with its build steps */ -export default function build({pattern, version}) { +export default function defineTask({pattern, version}) { const replacerOptions = { pattern: /\$\{(?:project\.)?version\}/g, replacement: version }; - return [{ + return {steps: [{ name: "replaceVersion", // One key per matched resource, so a delta build re-processes only the resources that changed. keys: async ({workspace}) => workspace.byGlob(pattern), @@ -38,5 +38,5 @@ export default function build({pattern, version}) { await workspace.write(processed); } }, - }]; + }]}; } diff --git a/packages/builder/lib/tasks/runSteps.js b/packages/builder/lib/tasks/runTask.js similarity index 65% rename from packages/builder/lib/tasks/runSteps.js rename to packages/builder/lib/tasks/runTask.js index fd7134eedf0..952c7c05064 100644 --- a/packages/builder/lib/tasks/runSteps.js +++ b/packages/builder/lib/tasks/runTask.js @@ -58,9 +58,9 @@ class BufferedWriter extends AbstractReaderWriter { /** * Runs a step-based task's steps without a build cache. * - * A step-based task default-exports a factory build(options) => Step[]. This runner is the - * no-cache counterpart to the cached step runner in @ui5/project (which - * @ui5/builder cannot import, the dependency direction being + * A step-based task default-exports a factory defineTask(options) => descriptor whose descriptor + * carries a steps array. This runner is the no-cache counterpart to the cached step runner in + * @ui5/project (which @ui5/builder cannot import, the dependency direction being * @ui5/cli -> @ui5/project -> @ui5/builder): it runs every step in order, fans out every * map step's keys set, threads needs returns in memory, and buffers a * concurrent map step's writes so they flush in key order. It replaces the per-task batch fallback tasks @@ -70,8 +70,45 @@ class BufferedWriter extends AbstractReaderWriter { * absent here; every step runs. * * @public - * @module @ui5/builder/tasks/runSteps - * @param {Function} build Task factory build(options) => Step[] + * @module @ui5/builder/tasks/runTask + */ + +/** + * The descriptor a task factory returns from its default export, from + * [Specification Version](https://ui5.github.io/cli/stable/pages/Configuration/#specification-versions) 5.0. + * It carries the whole task in one object: the task body, its optional callbacks, and an optional declared + * Specification Version. + * + * The factory must stay pure and cheap. UI5 CLI calls it once per build, early also for the build signature, + * so it must not read or write resources, and it must not import heavy processors. The work happens in the + * task body or in the step callbacks. + * + * @public + * @typedef {object} TaskDescriptor + * @property {object[]} [steps] The build steps of a cache-aware, step-based task. Give exactly one of + * steps or task. A step is a scalar step {name, needs?, run} or a + * map step {name, needs?, sequential?, keys, each}; the build cache re-runs only the steps, + * and within a map step only the keys, whose inputs changed. + * @property {Function} [task] The task body async (parameters) => Promise<undefined> of a + * traditional task. Give exactly one of steps or task. + * @property {boolean} [requiresDependencies=false] Whether the task needs access to the resources of the + * project's dependencies. When false, the dependencies reader is not provided. + * @property {Function} [determineRequiredDependencies] Optional callback that reduces the set of dependencies + * made available through the dependencies reader. + * @property {Function} [determineBuildSignature] Optional callback that contributes to the project's build + * signature, so a change outside the project's own resources can invalidate the task's cached output. + * @property {string} [specVersion] Optional Specification Version. When set, it must be equal to the task + * extension's configured Specification Version. + */ + +/** + * Runs a step-based task's steps without a build cache. + * + * @public + * @function default + * @static + * @param {Function} defineTask Task factory defineTask(options) => descriptor whose descriptor + * carries a steps array * @param {object} parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace Workspace to read and write files * @param {@ui5/fs/AbstractReader} [parameters.dependencies] Reader to read dependency files @@ -79,8 +116,12 @@ class BufferedWriter extends AbstractReaderWriter { * @param {object} [parameters.options] Task options, passed to the factory and each step * @returns {Promise} Resolves once all steps have run and their writes are flushed */ -export default async function runSteps(build, {workspace, dependencies, taskUtil, options} = {}) { - const steps = await build(options); +export default async function runTask(defineTask, {workspace, dependencies, taskUtil, options} = {}) { + const {steps} = await defineTask(options); + if (!steps) { + throw new Error( + "runTask requires a step-based task whose factory returns a descriptor with a 'steps' array"); + } // Each step's return, so a later step's needs can consume it. A scalar step's return is its single // value; a map step's is the array of its per-key returns in key order. const returns = new Map(); diff --git a/packages/builder/lib/tasks/taskRepository.js b/packages/builder/lib/tasks/taskRepository.js index 9b7b968667a..4273e560eb9 100644 --- a/packages/builder/lib/tasks/taskRepository.js +++ b/packages/builder/lib/tasks/taskRepository.js @@ -43,7 +43,9 @@ const taskInfos = { * * @private * @typedef {object} @ui5/builder/tasks/taskRepository~TaskInfo - * @property {Function} task Task function + * @property {Function} task The task module's default-export factory (options) => descriptor. + * The returned descriptor carries the task's steps or task body plus the + * optional requiresDependencies and determineBuildSignature callbacks. */ /** diff --git a/packages/builder/lib/tasks/transformBootstrapHtml.js b/packages/builder/lib/tasks/transformBootstrapHtml.js index bd10a479bc7..d8a0b01d043 100644 --- a/packages/builder/lib/tasks/transformBootstrapHtml.js +++ b/packages/builder/lib/tasks/transformBootstrapHtml.js @@ -2,25 +2,28 @@ import {getLogger} from "@ui5/logger"; const log = getLogger("builder:tasks:transformBootstrapHtml"); import bootstrapHtmlTransformer from "../processors/bootstrapHtmlTransformer.js"; -/* eslint "jsdoc/check-param-names": ["error", {"disableExtraPropertyReporting":true}] */ /** * Task for transforming the application bootstrap HTML file. * * @module builder/tasks/transformBootstrapHtml + * @returns {module:@ui5/builder/tasks/runTask~TaskDescriptor} The task descriptor + */ +export default function defineTask() { + return {task}; +} + +/** * @param {object} parameters Parameters * @param {@ui5/fs/DuplexCollection} parameters.workspace DuplexCollection to read and write files * @param {object} parameters.options Options * @param {string} parameters.options.projectName Project name * @param {string} [parameters.options.projectNamespace] Project namespace - * @returns {Promise} Promise resolving with undefined once data has been written + * @returns {Promise} Promise resolving once the bootstrap HTML has been transformed */ -export default async function({workspace, options}) { - const {projectName} = options; - const namespace = options.projectNamespace; - +async function task({workspace, options: {projectName, projectNamespace}}) { let indexPath; - if (namespace) { - indexPath = `/resources/${namespace}/index.html`; + if (projectNamespace) { + indexPath = `/resources/${projectNamespace}/index.html`; } else { indexPath = "/index.html"; } diff --git a/packages/builder/test/lib/package-exports.js b/packages/builder/test/lib/package-exports.js index 3587a38aa7c..a721f495c40 100644 --- a/packages/builder/test/lib/package-exports.js +++ b/packages/builder/test/lib/package-exports.js @@ -48,6 +48,7 @@ test("check number of exports", (t) => { "tasks/replaceCopyright", "tasks/replaceVersion", "tasks/replaceBuildtime", + "tasks/runTask", "tasks/transformBootstrapHtml", "tasks/enhanceManifest", diff --git a/packages/builder/test/lib/tasks/buildThemes.integration.js b/packages/builder/test/lib/tasks/buildThemes.integration.js index 765c91d0f7f..6ac2b5645c7 100644 --- a/packages/builder/test/lib/tasks/buildThemes.integration.js +++ b/packages/builder/test/lib/tasks/buildThemes.integration.js @@ -1,6 +1,6 @@ import test from "ava"; import buildThemes from "../../../lib/tasks/buildThemes.js"; -import runSteps from "../../../lib/tasks/runSteps.js"; +import runTask from "../../../lib/tasks/runTask.js"; import {createAdapter, createResource} from "@ui5/fs/resourceFactory"; import DuplexCollection from "@ui5/fs/DuplexCollection"; @@ -46,7 +46,7 @@ test("integration: simple", async (t) => { string: content }); await reader.write(resource); - await runSteps(buildThemes, { + await runTask(buildThemes, { workspace: duplexCollection, dependencies: dependencies, options: { @@ -127,7 +127,7 @@ test("integration: imports", async (t) => { return reader.write(resource); })); - await runSteps(buildThemes, { + await runTask(buildThemes, { workspace: duplexCollection, dependencies: dependencies, options: { diff --git a/packages/builder/test/lib/tasks/buildThemes.js b/packages/builder/test/lib/tasks/buildThemes.js index f85cb49d4d6..4ef29dc91b3 100644 --- a/packages/builder/test/lib/tasks/buildThemes.js +++ b/packages/builder/test/lib/tasks/buildThemes.js @@ -2,7 +2,7 @@ import test from "ava"; import sinon from "sinon"; import esmock from "esmock"; import {deserializeResources} from "../../../lib/processors/themeBuilderWorker.js"; -import runSteps from "../../../lib/tasks/runSteps.js"; +import runTask from "../../../lib/tasks/runTask.js"; let buildThemes; test.before(async () => { @@ -63,7 +63,7 @@ test.serial("buildThemes", async (t) => { jsonParametersResource ]); - await runSteps(buildThemes, { + await runTask(buildThemes, { workspace, options: { projectName: "sap.ui.demo.app", @@ -116,7 +116,7 @@ test.serial("buildThemes (compress = false)", async (t) => { jsonParametersResource ]); - await runSteps(buildThemes, { + await runTask(buildThemes, { workspace, options: { projectName: "sap.ui.demo.app", @@ -191,7 +191,7 @@ test.serial("buildThemes (filtering libraries)", async (t) => { // One step per surviving theme; a fresh result per call so concurrent writes stay independent. t.context.themeBuilderStub.callsFake(() => [{}]); - await runSteps(buildThemes, { + await runTask(buildThemes, { workspace, options: { projectName: "sap.ui.test.lib1", @@ -267,7 +267,7 @@ test.serial("buildThemes (filtering themes)", async (t) => { // One step per surviving theme; a fresh result per call so concurrent writes stay independent. t.context.themeBuilderStub.callsFake(() => [{}]); - await runSteps(buildThemes, { + await runTask(buildThemes, { workspace, options: { projectName: "sap.ui.test.lib1", @@ -381,7 +381,7 @@ test.serial("buildThemes (filtering libraries + themes)", async (t) => { // One step per surviving theme; a fresh result per call so concurrent writes stay independent. t.context.themeBuilderStub.callsFake(() => [{}]); - await runSteps(buildThemes, { + await runTask(buildThemes, { workspace, options: { projectName: "sap.ui.test.lib1", @@ -452,7 +452,7 @@ test.serial("buildThemes (useWorkers = true)", async (t) => { jsonParametersResource ]); - await runSteps(buildThemes, { + await runTask(buildThemes, { workspace, taskUtil: taskUtilMock, options: { @@ -523,7 +523,7 @@ test.serial("buildThemes with taskUtil and unexpected termination of the workerp mkdir: (...args) => args[args.length - 1](null, {}), }); - await runSteps(buildThemes, { + await runTask(buildThemes, { workspace, taskUtil: taskUtilMock, options: { diff --git a/packages/builder/test/lib/tasks/bundlers/generateBundle.js b/packages/builder/test/lib/tasks/bundlers/generateBundle.js index 879e50f1062..0db51bbe876 100644 --- a/packages/builder/test/lib/tasks/bundlers/generateBundle.js +++ b/packages/builder/test/lib/tasks/bundlers/generateBundle.js @@ -88,7 +88,7 @@ test.serial("generateBundle: No taskUtil, no bundleOptions", async (t) => { // bundleDefinition can be empty here as the moduleBundler is mocked const bundleDefinition = {}; - await generateBundle({ + await generateBundle().task({ workspace, dependencies, options: { @@ -160,7 +160,7 @@ test.serial("generateBundle: No bundleOptions, with taskUtil", async (t) => { // bundleDefinition can be empty here as the moduleBundler is mocked const bundleDefinition = {}; - await generateBundle({ + await generateBundle().task({ workspace, dependencies, taskUtil, @@ -277,7 +277,7 @@ test.serial("generateBundle: No bundleOptions, with taskUtil and specVersion < 4 // bundleDefinition can be empty here as the moduleBundler is mocked const bundleDefinition = {}; - await generateBundle({ + await generateBundle().task({ workspace, dependencies, taskUtil, @@ -395,7 +395,7 @@ test.serial("generateBundle: bundleOptions: optimize=false, with taskUtil", asyn const bundleDefinition = {}; const bundleOptions = {optimize: false}; - await generateBundle({ + await generateBundle().task({ workspace, dependencies, taskUtil, @@ -522,7 +522,7 @@ test.serial("generateBundle: bundleOptions: sourceMap=false, with taskUtil", asy const bundleDefinition = {}; const bundleOptions = {sourceMap: false}; - await generateBundle({ + await generateBundle().task({ workspace, dependencies, taskUtil, @@ -621,7 +621,7 @@ test.serial("generateBundle: Empty bundle (skipIfEmpty=true)", async (t) => { const bundleDefinition = {}; const bundleOptions = {skipIfEmpty: true}; - await generateBundle({ + await generateBundle().task({ workspace, dependencies, taskUtil, @@ -703,7 +703,7 @@ test.serial("generateBundle: Throws error when non-debug name can't be resolved" taskUtil.getTag.returns(true); getNonDebugName.returns(false); - await t.throwsAsync(generateBundle({ + await t.throwsAsync(generateBundle().task({ workspace, dependencies, taskUtil, @@ -755,7 +755,7 @@ test.serial("generateBundle: No bundleOptions, with taskUtil, UI5 Version >= 2", // bundleDefinition can be empty here as the moduleBundler is mocked const bundleDefinition = {}; - await generateBundle({ + await generateBundle().task({ workspace, dependencies, taskUtil, diff --git a/packages/builder/test/lib/tasks/bundlers/generateComponentPreload.js b/packages/builder/test/lib/tasks/bundlers/generateComponentPreload.js index 6231d9adfe3..9338996187f 100644 --- a/packages/builder/test/lib/tasks/bundlers/generateComponentPreload.js +++ b/packages/builder/test/lib/tasks/bundlers/generateComponentPreload.js @@ -50,7 +50,7 @@ test.serial("generateComponentPreload - one namespace", async (t) => { } ]); - await generateComponentPreload({ + await generateComponentPreload().task({ workspace, dependencies, options: { @@ -162,7 +162,7 @@ test.serial("generateComponentPreload - one namespace, with taskUtil and specVer } }; - await generateComponentPreload({ + await generateComponentPreload().task({ workspace, dependencies, taskUtil, @@ -276,7 +276,7 @@ test.serial("generateComponentPreload - one namespace, with taskUtil and specVer } }; - await generateComponentPreload({ + await generateComponentPreload().task({ workspace, dependencies, taskUtil, @@ -368,7 +368,7 @@ test.serial("generateComponentPreload - one namespace - excludes", async (t) => } ]); - await generateComponentPreload({ + await generateComponentPreload().task({ workspace, dependencies, options: { @@ -485,7 +485,7 @@ test.serial("generateComponentPreload - one namespace - excludes w/o namespace", createFilterReader: () => workspace } }; - await generateComponentPreload({ + await generateComponentPreload().task({ workspace, dependencies, taskUtil, @@ -604,7 +604,7 @@ test.serial("generateComponentPreload - multiple namespaces - excludes", async ( } ]); - await generateComponentPreload({ + await generateComponentPreload().task({ workspace, dependencies, options: { @@ -754,7 +754,7 @@ test.serial("generateComponentPreload - one namespace - invalid exclude", async ]; byGlob.resolves(resources); - await generateComponentPreload({ + await generateComponentPreload().task({ workspace, dependencies, options: { @@ -792,7 +792,7 @@ test.serial("generateComponentPreload - nested namespaces - excludes", async (t) ]; byGlob.resolves(resources); - await generateComponentPreload({ + await generateComponentPreload().task({ workspace, dependencies, options: { diff --git a/packages/builder/test/lib/tasks/bundlers/generateFlexChangesBundle.js b/packages/builder/test/lib/tasks/bundlers/generateFlexChangesBundle.js index d50b5e18cb5..470a57552f4 100644 --- a/packages/builder/test/lib/tasks/bundlers/generateFlexChangesBundle.js +++ b/packages/builder/test/lib/tasks/bundlers/generateFlexChangesBundle.js @@ -87,7 +87,7 @@ function createStubWorkspace(changeList, manifest, { * @returns {Promise} Resolves when the task has finished */ function runTask(workspace, extra = {}) { - return generateFlexChangesBundle({ + return generateFlexChangesBundle().task({ workspace, taskUtil: false, options: {projectNamespace: "sap/ui/demo/app"}, diff --git a/packages/builder/test/lib/tasks/bundlers/generateLibraryPreload.integration.js b/packages/builder/test/lib/tasks/bundlers/generateLibraryPreload.integration.js index c265f581cea..38768724fd0 100644 --- a/packages/builder/test/lib/tasks/bundlers/generateLibraryPreload.integration.js +++ b/packages/builder/test/lib/tasks/bundlers/generateLibraryPreload.integration.js @@ -193,7 +193,7 @@ test.serial("integration: generateLibraryPreload", async (t) => { virBasePath: "/" }); - await generateLibraryPreload({ + await generateLibraryPreload().task({ workspace: duplexCollection, dependencies: dependencies, options: { @@ -259,7 +259,7 @@ test.serial("integration: generateLibraryPreload with designtime and support fil virBasePath: "/" }); - await generateLibraryPreload({ + await generateLibraryPreload().task({ workspace: duplexCollection, dependencies: dependencies, options: { diff --git a/packages/builder/test/lib/tasks/bundlers/generateLibraryPreload.js b/packages/builder/test/lib/tasks/bundlers/generateLibraryPreload.js index 7618a8c908f..4fdb2a47401 100644 --- a/packages/builder/test/lib/tasks/bundlers/generateLibraryPreload.js +++ b/packages/builder/test/lib/tasks/bundlers/generateLibraryPreload.js @@ -47,7 +47,7 @@ test.serial("generateLibraryPreload", async (t) => { {getPath: sinon.stub().returns("/resources/my/lib/.library")} ]); - await generateLibraryPreload({ + await generateLibraryPreload().task({ workspace, dependencies, options: { @@ -172,7 +172,7 @@ test.serial("generateLibraryPreload for sap.ui.core (w/o ui5loader.js)", async ( {getPath: sinon.stub().returns("/resources/sap/ui/core/.library")} ]); - await generateLibraryPreload({ + await generateLibraryPreload().task({ workspace, dependencies, options: { @@ -508,7 +508,7 @@ test.serial("generateLibraryPreload for sap.ui.core (/w ui5loader.js)", async (t createFilterReader: () => workspace } }; - await generateLibraryPreload({ + await generateLibraryPreload().task({ workspace, dependencies, taskUtil, @@ -934,7 +934,7 @@ test.serial("generateLibraryPreload for sap.ui.core with old specVersion defined taskUtil.getTag .withArgs(sinon.match.any, taskUtil.STANDARD_TAGS.HasDebugVariant) .returns(true); - await generateLibraryPreload({ + await generateLibraryPreload().task({ workspace, dependencies, taskUtil, @@ -1345,7 +1345,7 @@ test.serial("generateLibraryPreload for sap.ui.core with own bundle configuratio createFilterReader: () => workspace } }; - await generateLibraryPreload({ + await generateLibraryPreload().task({ workspace, dependencies, taskUtil, @@ -1536,7 +1536,7 @@ test.serial("generateLibraryPreload for sap.ui.core with own bundle configuratio taskUtil.getTag .withArgs(resources[0], taskUtil.STANDARD_TAGS.HasDebugVariant) .returns(true); - await generateLibraryPreload({ + await generateLibraryPreload().task({ workspace, dependencies, taskUtil, @@ -1725,7 +1725,7 @@ test.serial("Error: Failed to resolve non-debug name", async (t) => { .withArgs(resources[0], taskUtil.STANDARD_TAGS.IsDebugVariant) .returns(true); - await t.throwsAsync(generateLibraryPreload({ + await t.throwsAsync(generateLibraryPreload().task({ workspace, dependencies, taskUtil, @@ -1754,7 +1754,7 @@ test.serial("generateLibraryPreload with excludes", async (t) => { {getPath: sinon.stub().returns("/resources/my/lib/.library")} ]); - await generateLibraryPreload({ + await generateLibraryPreload().task({ workspace, dependencies, options: { @@ -1826,7 +1826,7 @@ test.serial("generateLibraryPreload with invalid excludes", async (t) => { {getPath: sinon.stub().returns("/resources/my/lib/.library")} ]); - await generateLibraryPreload({ + await generateLibraryPreload().task({ workspace, dependencies, options: { @@ -1926,7 +1926,7 @@ test.serial("generateLibraryPreload for sap.ui.core (/w ui5loader.js), UI5 Versi createFilterReader: () => workspace } }; - await generateLibraryPreload({ + await generateLibraryPreload().task({ workspace, dependencies, taskUtil, diff --git a/packages/builder/test/lib/tasks/bundlers/generateStandaloneAppBundle.js b/packages/builder/test/lib/tasks/bundlers/generateStandaloneAppBundle.js index 20bf7b73e47..f9e29551b58 100644 --- a/packages/builder/test/lib/tasks/bundlers/generateStandaloneAppBundle.js +++ b/packages/builder/test/lib/tasks/bundlers/generateStandaloneAppBundle.js @@ -71,7 +71,7 @@ test.serial("execute module bundler and write results", async (t) => { projectNamespace: "some/project/namespace" } }; - await generateStandaloneAppBundle(params); + await generateStandaloneAppBundle().task(params); t.is(t.context.moduleBundlerStub.callCount, 2); @@ -118,7 +118,7 @@ test.serial("execute module bundler and write results without namespace", async projectName: "some.project.name" } }; - await generateStandaloneAppBundle(params); + await generateStandaloneAppBundle().task(params); t.is(t.context.moduleBundlerStub.callCount, 2); @@ -165,7 +165,7 @@ test.serial("execute module bundler and write results in evo mode", async (t) => projectNamespace: "some/project/namespace" } }; - await generateStandaloneAppBundle(params); + await generateStandaloneAppBundle().task(params); t.is(t.context.moduleBundlerStub.callCount, 2); @@ -222,7 +222,7 @@ test.serial("execute module bundler with taskUtil", async (t) => { projectNamespace: "some/project/namespace" } }; - await generateStandaloneAppBundle(params); + await generateStandaloneAppBundle().task(params); t.is(taskUtil.getTag.callCount, 5, "TaskUtil#getTag got called six times"); @@ -419,7 +419,7 @@ test.serial("execute module bundler with taskUtil, UI5 Version >= 2", async (t) projectNamespace: "some/project/namespace" } }; - await generateStandaloneAppBundle(params); + await generateStandaloneAppBundle().task(params); t.is(taskUtil.getTag.callCount, 5, "TaskUtil#getTag got called six times"); @@ -590,7 +590,7 @@ test.serial("Error: Failed to resolve non-debug name", async (t) => { } }; - await t.throwsAsync(generateStandaloneAppBundle(params), { + await t.throwsAsync(generateStandaloneAppBundle().task(params), { message: "Failed to resolve non-debug name for /resources/ponyPath1.js" }); }); diff --git a/packages/builder/test/lib/tasks/enhanceManifest.js b/packages/builder/test/lib/tasks/enhanceManifest.js index 18c6edb6bb1..19f397885e2 100644 --- a/packages/builder/test/lib/tasks/enhanceManifest.js +++ b/packages/builder/test/lib/tasks/enhanceManifest.js @@ -1,5 +1,5 @@ import test from "ava"; -import runSteps from "../../../lib/tasks/runSteps.js"; +import runTask from "../../../lib/tasks/runTask.js"; import sinonGlobal from "sinon"; import esmock from "esmock"; import {createAdapter, createResource} from "@ui5/fs/resourceFactory"; @@ -72,7 +72,7 @@ test.serial("Transforms single manifest.json resource", async (t) => { t.context.manifestEnhancerStub.returns([resource]); - await runSteps(enhanceManifest, { + await runTask(enhanceManifest, { workspace, options: { projectNamespace: "sap/ui/demo/app" @@ -179,7 +179,7 @@ test.serial("Transforms all manifest.json resources", async (t) => { t.context.manifestEnhancerStub.callsFake(({resources}) => resources[0] === resourceReuseComp2 ? [] : [resources[0]]); - await runSteps(enhanceManifest, { + await runTask(enhanceManifest, { workspace, options: { projectNamespace: "sap/ui/demo/lib" @@ -279,7 +279,7 @@ test.serial("Transforms multiple manifest.json resources", async (t) => { // One step per manifest.json: each changed manifest is written back at its own path. t.context.manifestEnhancerStub.callsFake(({resources}) => [resources[0]]); - await runSteps(enhanceManifest, { + await runTask(enhanceManifest, { workspace, options: { projectNamespace: "sap/ui/demo/lib" @@ -323,7 +323,7 @@ test.serial("Should not rewrite the manifest.json if no changes were made", asyn t.context.manifestEnhancerStub.returns([]); - await runSteps(enhanceManifest, { + await runTask(enhanceManifest, { workspace, options: { projectNamespace: "sap/ui/demo/app" diff --git a/packages/builder/test/lib/tasks/escapeNonAsciiCharacters.js b/packages/builder/test/lib/tasks/escapeNonAsciiCharacters.js index 63453884a36..1197c1dbe69 100644 --- a/packages/builder/test/lib/tasks/escapeNonAsciiCharacters.js +++ b/packages/builder/test/lib/tasks/escapeNonAsciiCharacters.js @@ -1,5 +1,5 @@ import test from "ava"; -import runSteps from "../../../lib/tasks/runSteps.js"; +import runTask from "../../../lib/tasks/runTask.js"; import escapeNonAsciiCharacters from "../../../lib/tasks/escapeNonAsciiCharacters.js"; import {createAdapter, createResource} from "@ui5/fs/resourceFactory"; import DuplexCollection from "@ui5/fs/DuplexCollection"; @@ -33,7 +33,7 @@ city=Ort:`; }); await workspace.write(resource); - await runSteps(escapeNonAsciiCharacters, { + await runTask(escapeNonAsciiCharacters, { workspace, options: { encoding: "UTF-8", @@ -80,7 +80,7 @@ city=Ort:`; }); await workspace.write(resource); - await runSteps(escapeNonAsciiCharacters, { + await runTask(escapeNonAsciiCharacters, { workspace, options: { encoding: "ISO-8859-1", diff --git a/packages/builder/test/lib/tasks/generateLibraryManifest.js b/packages/builder/test/lib/tasks/generateLibraryManifest.js index bcb6a355d1e..625b1e12d81 100644 --- a/packages/builder/test/lib/tasks/generateLibraryManifest.js +++ b/packages/builder/test/lib/tasks/generateLibraryManifest.js @@ -17,7 +17,7 @@ async function assertCreatedManifest(t, oExpectedManifest) { await Promise.all(resources.map((resource) => workspace.write(resource))); - await generateLibraryManifest({ + await generateLibraryManifest().task({ workspace, taskUtil: { getProject: () => { diff --git a/packages/builder/test/lib/tasks/generateResourcesJson.js b/packages/builder/test/lib/tasks/generateResourcesJson.js index a679d3bbadf..adc613c6023 100644 --- a/packages/builder/test/lib/tasks/generateResourcesJson.js +++ b/packages/builder/test/lib/tasks/generateResourcesJson.js @@ -45,7 +45,7 @@ test.afterEach.always((t) => { test.serial("Missing 'dependencies' parameter", async (t) => { const {generateResourcesJson} = t.context; - await t.throwsAsync(generateResourcesJson({ + await t.throwsAsync(generateResourcesJson().task({ workspace: createWorkspace(), options: { projectName: "sap.ui.core" @@ -59,7 +59,7 @@ test.serial("Missing 'dependencies' parameter", async (t) => { test.serial("empty resources (sap.ui.core)", async (t) => { const {generateResourcesJson, resourceListCreatorStub} = t.context; - const result = await generateResourcesJson({ + const result = await generateResourcesJson().task({ workspace: createWorkspace(), dependencies: createDependencies(), options: { @@ -84,7 +84,7 @@ test.serial("empty resources (sap.ui.core)", async (t) => { test.serial("empty resources (my.lib)", async (t) => { const {generateResourcesJson} = t.context; - const result = await generateResourcesJson({ + const result = await generateResourcesJson().task({ workspace: createWorkspace(), dependencies: createDependencies(), options: { @@ -106,7 +106,7 @@ test.serial("empty resources (my.lib with dependencies)", async (t) => { byGlob: sinon.stub().resolves(dependencyResources) }; - const result = await generateResourcesJson({ + const result = await generateResourcesJson().task({ workspace: createWorkspace(), dependencies, options: { @@ -160,7 +160,7 @@ test.serial("Resources omitted from build result should be ignored", async (t) = .onCall(3).returns(true) // first dependencyResource should be filtered out .onCall(4).returns(false); - const result = await generateResourcesJson({ + const result = await generateResourcesJson().task({ workspace, dependencies, taskUtil, diff --git a/packages/builder/test/lib/tasks/generateThemeDesignerResources.js b/packages/builder/test/lib/tasks/generateThemeDesignerResources.js index 646b84dbdca..3cd73ff8b75 100644 --- a/packages/builder/test/lib/tasks/generateThemeDesignerResources.js +++ b/packages/builder/test/lib/tasks/generateThemeDesignerResources.js @@ -1,7 +1,7 @@ import test from "ava"; import sinonGlobal from "sinon"; import esmock from "esmock"; -import runSteps from "../../../lib/tasks/runSteps.js"; +import runTask from "../../../lib/tasks/runTask.js"; test.beforeEach(async (t) => { const sinon = t.context.sinon = sinonGlobal.createSandbox(); @@ -83,7 +83,7 @@ test.serial("generateThemeDesignerResources: Library", async (t) => { ]); libraryLessGeneratorStub.callsFake(async ({resources}) => [lessByTheme.get(resources[0])]); - await runSteps(generateThemeDesignerResources, { + await runTask(generateThemeDesignerResources, { workspace, dependencies, options: { @@ -188,7 +188,7 @@ test.serial("generateThemeDesignerResources: Library sap.ui.core", async (t) => libraryLessGeneratorStub.resolves([libraryLessResource]); - await runSteps(generateThemeDesignerResources, { + await runTask(generateThemeDesignerResources, { workspace, dependencies, options: { @@ -287,7 +287,7 @@ test.serial("generateThemeDesignerResources: Library sap.ui.core with existing l libraryLessGeneratorStub.resolves([libraryLessResource]); - await runSteps(generateThemeDesignerResources, { + await runTask(generateThemeDesignerResources, { workspace, dependencies, options: { @@ -378,7 +378,7 @@ test.serial("generateThemeDesignerResources: Library sap.ui.core without themes, libraryLessGeneratorStub.resolves([libraryLessResource]); - await runSteps(generateThemeDesignerResources, { + await runTask(generateThemeDesignerResources, { workspace, dependencies, options: { @@ -447,7 +447,7 @@ test.serial("generateThemeDesignerResources: Library sap.ui.core with existing i libraryLessGeneratorStub.resolves([libraryLessResource]); - await t.throwsAsync(runSteps(generateThemeDesignerResources, { + await t.throwsAsync(runTask(generateThemeDesignerResources, { workspace, dependencies, options: { @@ -480,7 +480,7 @@ test.serial("generateThemeDesignerResources: Library sap.ui.documentation is ski write: sinon.stub() }; - await runSteps(generateThemeDesignerResources, { + await runTask(generateThemeDesignerResources, { workspace: {}, dependencies: {}, options: { @@ -508,7 +508,7 @@ test.serial("generateThemeDesignerResources: Library without themes", async (t) write: sinon.stub() }; - await runSteps(generateThemeDesignerResources, { + await runTask(generateThemeDesignerResources, { workspace, dependencies: {}, options: { @@ -566,7 +566,7 @@ test.serial("generateThemeDesignerResources: Theme-Library", async (t) => { libraryLessGeneratorStub.resolves([libraryLessResource]); - await runSteps(generateThemeDesignerResources, { + await runTask(generateThemeDesignerResources, { workspace, dependencies, options: { @@ -644,7 +644,7 @@ test.serial("generateThemeDesignerResources: .theming file missing in sap.ui.cor libraryLessGeneratorStub.resolves([libraryLessResource]); - await t.throwsAsync(runSteps(generateThemeDesignerResources, { + await t.throwsAsync(runTask(generateThemeDesignerResources, { workspace, dependencies, options: { @@ -699,7 +699,7 @@ test.serial("generateThemeDesignerResources: Failed to extract library name from }; const dependencies = {}; - await t.throwsAsync(runSteps(generateThemeDesignerResources, { + await t.throwsAsync(runTask(generateThemeDesignerResources, { workspace, dependencies, options: { diff --git a/packages/builder/test/lib/tasks/generateVersionInfo.js b/packages/builder/test/lib/tasks/generateVersionInfo.js index bedb63baf19..ff8d53d8619 100644 --- a/packages/builder/test/lib/tasks/generateVersionInfo.js +++ b/packages/builder/test/lib/tasks/generateVersionInfo.js @@ -68,7 +68,7 @@ async function createOptions(t, options) { async function assertCreatedVersionInfo(t, oExpectedVersionInfo, oOptions) { const {generateVersionInfo} = t.context; - await generateVersionInfo(oOptions); + await generateVersionInfo().task(oOptions); const resource = await oOptions.workspace.byPath("/resources/sap-ui-version.json"); if (!resource) { diff --git a/packages/builder/test/lib/tasks/jsdoc/executeJsdocSdkTransformation.js b/packages/builder/test/lib/tasks/jsdoc/executeJsdocSdkTransformation.js index abea08068cc..5392af570ba 100644 --- a/packages/builder/test/lib/tasks/jsdoc/executeJsdocSdkTransformation.js +++ b/packages/builder/test/lib/tasks/jsdoc/executeJsdocSdkTransformation.js @@ -56,7 +56,7 @@ test.serial("executeJsdocSdkTransformation", async (t) => { const dependencies = { byGlob: byGlobDependenciesStub }; - await executeJsdocSdkTransformation({ + await executeJsdocSdkTransformation().task({ workspace, dependencies, options: { @@ -105,7 +105,7 @@ test.serial("executeJsdocSdkTransformation", async (t) => { test("executeJsdocSdkTransformation with missing parameters", async (t) => { const {executeJsdocSdkTransformation} = t.context; - await t.throwsAsync(executeJsdocSdkTransformation(), { + await t.throwsAsync(executeJsdocSdkTransformation().task(), { instanceOf: TypeError }, "TypeError thrown"); }); @@ -131,7 +131,7 @@ test.serial("executeJsdocSdkTransformation with missing project api.json (skips byGlob: byGlobDependenciesStub }; - await executeJsdocSdkTransformation({ + await executeJsdocSdkTransformation().task({ workspace, dependencies, options: { @@ -170,7 +170,7 @@ test("executeJsdocSdkTransformation too many project api.json resources", async byGlob: byGlobDependenciesStub }; - const error = await t.throwsAsync(executeJsdocSdkTransformation({ + const error = await t.throwsAsync(executeJsdocSdkTransformation().task({ workspace, dependencies, options: { @@ -203,7 +203,7 @@ test("executeJsdocSdkTransformation missing project .library", async (t) => { byGlob: byGlobDependenciesStub }; - const error = await t.throwsAsync(executeJsdocSdkTransformation({ + const error = await t.throwsAsync(executeJsdocSdkTransformation().task({ workspace, dependencies, options: { @@ -240,7 +240,7 @@ test("executeJsdocSdkTransformation too many project .library resources", async byGlob: byGlobDependenciesStub }; - const error = await t.throwsAsync(executeJsdocSdkTransformation({ + const error = await t.throwsAsync(executeJsdocSdkTransformation().task({ workspace, dependencies, options: { diff --git a/packages/builder/test/lib/tasks/jsdoc/generateApiIndex.js b/packages/builder/test/lib/tasks/jsdoc/generateApiIndex.js index 262e72fba92..2bcd452f1ff 100644 --- a/packages/builder/test/lib/tasks/jsdoc/generateApiIndex.js +++ b/packages/builder/test/lib/tasks/jsdoc/generateApiIndex.js @@ -32,7 +32,7 @@ test.serial("generateApiIndex", async (t) => { write: writeStub }; const dependencies = {}; - await generateApiIndex({ + await generateApiIndex().task({ workspace, dependencies, options: { @@ -69,7 +69,7 @@ test.serial("generateApiIndex", async (t) => { test("generateApiIndex with missing parameters", async (t) => { const {generateApiIndex} = t.context; - await t.throwsAsync(generateApiIndex(), { + await t.throwsAsync(generateApiIndex().task(), { instanceOf: TypeError }, "TypeError thrown"); }); diff --git a/packages/builder/test/lib/tasks/jsdoc/generateJsdoc.js b/packages/builder/test/lib/tasks/jsdoc/generateJsdoc.js index 31054cd3e7f..68a47586642 100644 --- a/packages/builder/test/lib/tasks/jsdoc/generateJsdoc.js +++ b/packages/builder/test/lib/tasks/jsdoc/generateJsdoc.js @@ -244,15 +244,16 @@ test.serial("generateJsdoc", async (t) => { const taskUtil = { registerCleanupTask: registerCleanupTaskStub }; - await generateJsdoc({ + const {task} = generateJsdoc({ + namespace: "some/project", + version: "some version" + }); + await task({ taskUtil, workspace, dependencies: "dependencies", options: { - pattern: "some pattern", - projectName: "some.project", - namespace: "some/project", - version: "some version" + projectName: "some.project" } }); @@ -267,7 +268,7 @@ test.serial("generateJsdoc", async (t) => { t.is(writeResourcesToDirStub.callCount, 1, "writeResourcesToDir got called once"); t.deepEqual(writeResourcesToDirStub.getCall(0).args[0], { workspace, - pattern: "some pattern", + pattern: ["/resources/**/*.js"], targetPath: path.join("/", "some", "source", "path") // one's target is another one's source }, "writeResourcesToDir got called with correct arguments"); @@ -321,15 +322,16 @@ test.serial("generateJsdoc with missing resources", async (t) => { const taskUtil = { registerCleanupTask: registerCleanupTaskStub }; - await generateJsdoc({ + const {task} = generateJsdoc({ + namespace: "some/project", + version: "some version" + }); + await task({ taskUtil, workspace, dependencies: "dependencies", options: { - pattern: "some pattern", - projectName: "some.project", - namespace: "some/project", - version: "some version" + projectName: "some.project" } }); @@ -339,7 +341,7 @@ test.serial("generateJsdoc with missing resources", async (t) => { t.is(log.info.callCount, 1, "One message has been logged"); t.deepEqual(log.info.getCall(0).args[0], "Failed to find any input resources for project some.project " + - "using pattern some pattern. Skipping JSDoc generation...", + "using pattern /resources/**/*.js. Skipping JSDoc generation...", "Correct message has been logged"); t.is(jsdocGeneratorStub.callCount, 0, "jsdocGenerator processor has *not* been called"); @@ -351,14 +353,15 @@ test.serial("generateJsdoc with missing resources", async (t) => { test.serial("generateJsdoc no parameters", async (t) => { const {generateJsdoc} = t.context; - await t.throwsAsync(generateJsdoc(), { + t.throws(() => generateJsdoc(), { instanceOf: TypeError }, "TypeError thrown"); }); test.serial("generateJsdoc missing parameters", async (t) => { const {generateJsdoc} = t.context; - const error = await t.throwsAsync(generateJsdoc({})); + const {task} = generateJsdoc({}); + const error = await t.throwsAsync(task({options: {}})); t.is(error.message, "[generateJsdoc]: One or more mandatory options not provided", "Correct error message thrown"); }); diff --git a/packages/builder/test/lib/tasks/minify.integration.js b/packages/builder/test/lib/tasks/minify.integration.js index 48cd0196b50..f034a8ad93a 100644 --- a/packages/builder/test/lib/tasks/minify.integration.js +++ b/packages/builder/test/lib/tasks/minify.integration.js @@ -1,7 +1,7 @@ import test from "ava"; import sinonGlobal from "sinon"; import minify from "../../../lib/tasks/minify.js"; -import runSteps from "../../../lib/tasks/runSteps.js"; +import runTask from "../../../lib/tasks/runTask.js"; import * as resourceFactory from "@ui5/fs/resourceFactory"; import DuplexCollection from "@ui5/fs/DuplexCollection"; @@ -55,7 +55,7 @@ test();`; }); await reader.write(testResource); - await runSteps(minify, { + await runTask(minify, { workspace, taskUtil, options: { @@ -135,7 +135,7 @@ test();`; }); await reader.write(testResource); - await runSteps(minify, { + await runTask(minify, { workspace, taskUtil, options: { @@ -200,7 +200,7 @@ test();`; }); await reader.write(testResource); - await runSteps(minify, { + await runTask(minify, { workspace, options: { pattern: "/test.js", @@ -247,7 +247,7 @@ test();`; }); await reader.write(testResource); - await runSteps(minify, { + await runTask(minify, { workspace, options: { pattern: "/test.js", @@ -304,7 +304,7 @@ return;`; await reader.write(testResource); await t.throwsAsync(() => { - return runSteps(minify, { + return runTask(minify, { workspace, taskUtil, options: { @@ -336,7 +336,7 @@ return;`; await reader.write(testResource); await t.throwsAsync(() => { - return runSteps(minify, { + return runTask(minify, { workspace, options: { pattern: "/resources/my/namespace/test.js", @@ -388,7 +388,7 @@ test.serial("integration: minify with taskUtil and resources tagged with OmitFro registerCleanupTask: t.context.sinon.stub() }; - await runSteps(minify, { + await runTask(minify, { workspace, taskUtil, options: { @@ -464,7 +464,7 @@ test.serial("integration: minify skips a .js with an existing -dbg.js sibling an string: content })); - await runSteps(minify, { + await runTask(minify, { workspace, options: { pattern: "/resources/**/*.js" diff --git a/packages/builder/test/lib/tasks/minify.js b/packages/builder/test/lib/tasks/minify.js index cf524dae391..89f14fd05ea 100644 --- a/packages/builder/test/lib/tasks/minify.js +++ b/packages/builder/test/lib/tasks/minify.js @@ -1,7 +1,7 @@ import test from "ava"; import sinonGlobal from "sinon"; import esmock from "esmock"; -import runSteps from "../../../lib/tasks/runSteps.js"; +import runTask from "../../../lib/tasks/runTask.js"; test.beforeEach(async (t) => { const sinon = t.context.sinon = sinonGlobal.createSandbox(); @@ -68,7 +68,7 @@ test("minify: Default params", async (t) => { }] ]); minifierStub.callsFake(({resources}) => Promise.resolve([processedByInput.get(resources[0])])); - await runSteps(minify, { + await runTask(minify, { workspace, taskUtil, options: { @@ -134,7 +134,7 @@ test("minify: omitSourceMapResources: true, useInputSourceMaps: false", async (t }] ]); minifierStub.callsFake(({resources}) => Promise.resolve([processedByInput.get(resources[0])])); - await runSteps(minify, { + await runTask(minify, { workspace, taskUtil, options: { @@ -214,7 +214,7 @@ test("minify: No taskUtil", async (t) => { }] ]); minifierStub.callsFake(({resources}) => Promise.resolve([processedByInput.get(resources[0])])); - await runSteps(minify, { + await runTask(minify, { workspace, options: { pattern: "**" @@ -259,7 +259,7 @@ test("minify: Skips and warns for an existing -dbg.js next to its .js", async (t ]); minifierStub.callsFake(({resources}) => Promise.resolve([processedByInput.get(resources[0])])); - await runSteps(minify, { + await runTask(minify, { workspace, taskUtil, options: { diff --git a/packages/builder/test/lib/tasks/replaceBuildtime.js b/packages/builder/test/lib/tasks/replaceBuildtime.js index 40e772f52c7..4cbefbe14ca 100644 --- a/packages/builder/test/lib/tasks/replaceBuildtime.js +++ b/packages/builder/test/lib/tasks/replaceBuildtime.js @@ -1,5 +1,5 @@ import test from "ava"; -import runSteps from "../../../lib/tasks/runSteps.js"; +import runTask from "../../../lib/tasks/runTask.js"; import replaceBuildtime from "../../../lib/tasks/replaceBuildtime.js"; import {createAdapter, createResource} from "@ui5/fs/resourceFactory"; import DuplexCollection from "@ui5/fs/DuplexCollection"; @@ -23,7 +23,7 @@ test("integration: replace version", async (t) => { const workspace = new DuplexCollection({reader, writer}); await reader.write(resource); - await runSteps(replaceBuildtime, { + await runTask(replaceBuildtime, { workspace, options: { pattern: "/test.js" @@ -64,7 +64,7 @@ test("integration: buildtime is sourced from taskUtil.getBuildTime", async (t) = }); await reader.write(resource); - await runSteps(replaceBuildtime, { + await runTask(replaceBuildtime, { workspace, taskUtil: {getBuildTime: () => buildTime}, options: { diff --git a/packages/builder/test/lib/tasks/replaceCopyright.js b/packages/builder/test/lib/tasks/replaceCopyright.js index d4503ef7748..fcb48a1a847 100644 --- a/packages/builder/test/lib/tasks/replaceCopyright.js +++ b/packages/builder/test/lib/tasks/replaceCopyright.js @@ -1,5 +1,5 @@ import test from "ava"; -import runSteps from "../../../lib/tasks/runSteps.js"; +import runTask from "../../../lib/tasks/runTask.js"; import sinon from "sinon"; import replaceCopyright from "../../../lib/tasks/replaceCopyright.js"; import {createAdapter, createResource} from "@ui5/fs/resourceFactory"; @@ -40,7 +40,7 @@ console.log('HelloWorld');`; await workspace.write(resource); - await runSteps(replaceCopyright, { + await runTask(replaceCopyright, { workspace, options: { copyright: copyright, @@ -91,7 +91,7 @@ console.log('HelloWorld');`; await workspace.write(resource); - await runSteps(replaceCopyright, { + await runTask(replaceCopyright, { workspace, taskUtil: {getTime}, options: { @@ -139,7 +139,7 @@ test("test.xml: replace @copyright@", async (t) => { }); await reader.write(resource); - await runSteps(replaceCopyright, { + await runTask(replaceCopyright, { workspace, options: { pattern: "/**/*.xml", diff --git a/packages/builder/test/lib/tasks/replaceVersion.js b/packages/builder/test/lib/tasks/replaceVersion.js index 58ebf224340..2286b336b2b 100644 --- a/packages/builder/test/lib/tasks/replaceVersion.js +++ b/packages/builder/test/lib/tasks/replaceVersion.js @@ -1,5 +1,5 @@ import test from "ava"; -import runSteps from "../../../lib/tasks/runSteps.js"; +import runTask from "../../../lib/tasks/runTask.js"; import replaceVersion from "../../../lib/tasks/replaceVersion.js"; import {createAdapter, createResource} from "@ui5/fs/resourceFactory"; import DuplexCollection from "@ui5/fs/DuplexCollection"; @@ -22,7 +22,7 @@ test("integration: replace version", async (t) => { const workspace = new DuplexCollection({reader, writer}); await reader.write(resource); - await runSteps(replaceVersion, { + await runTask(replaceVersion, { workspace, options: { pattern: "/test.js", diff --git a/packages/builder/test/lib/tasks/runSteps.js b/packages/builder/test/lib/tasks/runTask.js similarity index 85% rename from packages/builder/test/lib/tasks/runSteps.js rename to packages/builder/test/lib/tasks/runTask.js index 1c0859fb2ee..89d04ca8a59 100644 --- a/packages/builder/test/lib/tasks/runSteps.js +++ b/packages/builder/test/lib/tasks/runTask.js @@ -1,5 +1,5 @@ import test from "ava"; -import runSteps from "../../../lib/tasks/runSteps.js"; +import runTask from "../../../lib/tasks/runTask.js"; function createResource(resourcePath, content = resourcePath) { return { @@ -34,13 +34,13 @@ function createWorkspace(initial = []) { test("Runs a scalar step and threads its return into a consumer via needs", async (t) => { const workspace = createWorkspace(); let consumed; - await runSteps((options) => [ + await runTask((options) => ({steps: [ {name: "scan", run: async () => ({flag: options.flag})}, {name: "use", needs: ["scan"], run: async ({needs, workspace}) => { consumed = needs.scan; await workspace.write(createResource("/out")); }}, - ], {workspace, options: {flag: 42}}); + ]}), {workspace, options: {flag: 42}}); t.deepEqual(consumed, {flag: 42}, "The producer's return arrived as needs.scan"); t.true(workspace.store.has("/out"), "The consumer's write persisted"); @@ -49,12 +49,12 @@ test("Runs a scalar step and threads its return into a consumer via needs", asyn test("Fans out a map step's keys and writes each", async (t) => { const workspace = createWorkspace(); const ran = []; - await runSteps(() => [ + await runTask(() => ({steps: [ {name: "m", keys: async () => ["a", "b", "c"], each: async (key, {workspace}) => { ran.push(key); await workspace.write(createResource(`/out/${key}`)); }}, - ], {workspace}); + ]}), {workspace}); t.deepEqual(ran.sort(), ["a", "b", "c"], "each ran once per key"); t.true(workspace.store.has("/out/a") && workspace.store.has("/out/b") && workspace.store.has("/out/c"), @@ -64,20 +64,20 @@ test("Fans out a map step's keys and writes each", async (t) => { test("A producer return reaches a map step's keys and each", async (t) => { const workspace = createWorkspace(); const eachSaw = []; - await runSteps(() => [ + await runTask(() => ({steps: [ {name: "scan", run: async () => ({wanted: ["x", "y"]})}, {name: "build", needs: ["scan"], keys: async ({needs}) => needs.scan.wanted, each: async (key, {needs}) => { eachSaw.push([key, needs.scan.wanted.length]); }}, - ], {workspace}); + ]}), {workspace}); t.deepEqual(eachSaw.sort(), [["x", 2], ["y", 2]], "each saw the producer return per key"); }); test("A concurrent map step flushes its writes in key order", async (t) => { const workspace = createWorkspace(); - await runSteps(() => [ + await runTask(() => ({steps: [ {name: "m", keys: async () => ["a", "b", "c"], each: async (key, {workspace}) => { // Reverse the natural completion order so the key-order flush is observable. if (key === "a") { @@ -85,7 +85,7 @@ test("A concurrent map step flushes its writes in key order", async (t) => { } await workspace.write(createResource(`/${key}.out`)); }}, - ], {workspace}); + ]}), {workspace}); t.deepEqual(workspace.writeOrder, ["/a.out", "/b.out", "/c.out"], "Buffered writes flushed in key order regardless of completion order"); @@ -94,7 +94,7 @@ test("A concurrent map step flushes its writes in key order", async (t) => { test("A sequential map step makes an earlier key's write visible to a later key", async (t) => { const workspace = createWorkspace(); let secondSawFirst = false; - await runSteps(() => [ + await runTask(() => ({steps: [ {name: "m", sequential: true, keys: async () => ["first", "second"], each: async (key, {workspace}) => { if (key === "first") { await workspace.write(createResource("/shared")); @@ -102,7 +102,7 @@ test("A sequential map step makes an earlier key's write visible to a later key" secondSawFirst = !!(await workspace.byPath("/shared")); } }}, - ], {workspace}); + ]}), {workspace}); t.true(secondSawFirst, "The second key read the first key's write"); }); @@ -111,11 +111,11 @@ test("Concurrent map-step keys writing the same path throw", async (t) => { const workspace = createWorkspace(); const inputA = createResource("/resources/first.js"); const inputB = createResource("/resources/second.js"); - const err = await t.throwsAsync(runSteps(() => [ + const err = await t.throwsAsync(runTask(() => ({steps: [ {name: "m", keys: async () => [inputA, inputB], each: async (key, {workspace}) => { await workspace.write(createResource("/same")); }}, - ], {workspace})); + ]}), {workspace})); // The exact user-visible message, shared with the cached runner through @ui5/fs/internal/stepWriteBuffer. t.is(err.message, "Concurrent map step 'm' tried to write '/same' for invocations '/resources/first.js' and " + @@ -125,7 +125,7 @@ test("Concurrent map-step keys writing the same path throw", async (t) => { test("A concurrent map step preserves each key's write arguments through the flush", async (t) => { const workspace = createWorkspace(); - await runSteps(() => [ + await runTask(() => ({steps: [ {name: "m", keys: async () => ["with", "without"], each: async (key, {workspace}) => { if (key === "with") { await workspace.write(createResource("/with"), {drain: true}); @@ -134,7 +134,7 @@ test("A concurrent map step preserves each key's write arguments through the flu await workspace.write(createResource("/without")); } }}, - ], {workspace}); + ]}), {workspace}); t.deepEqual(workspace.writeOrder, ["/with", "/without"], "Flushed in key order"); t.deepEqual(workspace.writeArgs, [[{drain: true}], []], @@ -144,14 +144,14 @@ test("A concurrent map step preserves each key's write arguments through the flu test("A later step sees an earlier step's write", async (t) => { const workspace = createWorkspace(); let laterSaw = false; - await runSteps(() => [ + await runTask(() => ({steps: [ {name: "first", run: async ({workspace}) => { await workspace.write(createResource("/from-first")); }}, {name: "second", run: async ({workspace}) => { laterSaw = !!(await workspace.byPath("/from-first")); }}, - ], {workspace}); + ]}), {workspace}); t.true(laterSaw, "The second step read the first step's write"); }); @@ -161,11 +161,11 @@ test("taskUtil and dependencies are passed through to steps", async (t) => { const taskUtil = {marker: "taskUtil"}; const dependencies = {marker: "dependencies"}; let seen; - await runSteps(() => [ + await runTask(() => ({steps: [ {name: "s", run: async (ctx) => { seen = {taskUtil: ctx.taskUtil, dependencies: ctx.dependencies}; }}, - ], {workspace, taskUtil, dependencies}); + ]}), {workspace, taskUtil, dependencies}); t.is(seen.taskUtil, taskUtil, "taskUtil passed through"); t.is(seen.dependencies, dependencies, "dependencies passed through"); @@ -173,6 +173,14 @@ test("taskUtil and dependencies are passed through to steps", async (t) => { test("An empty step list is a no-op", async (t) => { const workspace = createWorkspace(); - await runSteps(() => [], {workspace}); + await runTask(() => ({steps: []}), {workspace}); t.is(workspace.store.size, 0, "Nothing written"); }); + +test("A descriptor without a steps array throws", async (t) => { + const workspace = createWorkspace(); + const err = await t.throwsAsync(runTask(() => ({task: async () => {}}), {workspace})); + t.is(err.message, + "runTask requires a step-based task whose factory returns a descriptor with a 'steps' array", + "A non-step descriptor surfaces a clear error"); +}); diff --git a/packages/builder/test/lib/tasks/transformBootstrapHtml.js b/packages/builder/test/lib/tasks/transformBootstrapHtml.js index 60be2f402f8..488f85b22ee 100644 --- a/packages/builder/test/lib/tasks/transformBootstrapHtml.js +++ b/packages/builder/test/lib/tasks/transformBootstrapHtml.js @@ -44,7 +44,7 @@ test.serial("Transforms index.html resource", async (t) => { t.context.bootstrapHtmlTransformerStub.returns([resource]); - await transformBootstrapHtml({ + await transformBootstrapHtml().task({ workspace, options: { projectName: "sap.ui.demo.app", @@ -86,7 +86,7 @@ test.serial("Transforms index.html resource without namespace", async (t) => { t.context.bootstrapHtmlTransformerStub.returns([resource]); - await transformBootstrapHtml({ + await transformBootstrapHtml().task({ workspace, options: { projectName: "sap.ui.demo.app" @@ -122,7 +122,7 @@ test.serial("No index.html resource exists", async (t) => { } }; - await transformBootstrapHtml({ + await transformBootstrapHtml().task({ workspace, options: { projectName: "sap.ui.demo.app", diff --git a/packages/fs/lib/internal/stepWriteBuffer.js b/packages/fs/lib/internal/stepWriteBuffer.js index 304c8db16f6..5136fc01dce 100644 --- a/packages/fs/lib/internal/stepWriteBuffer.js +++ b/packages/fs/lib/internal/stepWriteBuffer.js @@ -9,7 +9,7 @@ * * Two runners implement this: the cache-aware runner in @ui5/project * (build/helpers/StepRunner) and the cache-free runner in @ui5/builder - * (tasks/runSteps). @ui5/builder cannot import @ui5/project + * (tasks/runTask). @ui5/builder cannot import @ui5/project * (the dependency direction is @ui5/cli -> @ui5/project -> @ui5/builder), so the two runners * stay separate, but the same-path guard, its user-visible error message and the key-order flush live here * so both runners share one definition. diff --git a/packages/project/lib/build/TaskDefinitions.js b/packages/project/lib/build/TaskDefinitions.js index a7b20e06221..462420b61ff 100644 --- a/packages/project/lib/build/TaskDefinitions.js +++ b/packages/project/lib/build/TaskDefinitions.js @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import {getLogger} from "@ui5/logger"; +import {validateDescriptor} from "./helpers/StepRunner.js"; export default class TaskDefinitions { #graph; @@ -66,9 +67,47 @@ export default class TaskDefinitions { getTask: this.#taskRepository.getTask }); + // Complete the project-derived options every standard task's factory is called with, before any + // factory runs. The factory is called once per build (early for the build signature, then reused for + // execution), and some factories branch on these values (generateThemeDesignerResources emits its + // libraryTheming step only for a namespace), so an incomplete options object at signature time would + // produce a different descriptor than execution. Completing them here keeps one memoized descriptor + // valid for both phases. + for (const taskDef of this.#standardTasks.values()) { + taskDef.options = taskDef.options ?? {}; + taskDef.options.projectName = this.#project.getName(); + taskDef.options.projectNamespace = this.#project.getNamespace(); + } + this.#customTasks = await this._getCustomTasks(this.#project, this.#taskUtil); } + /** + * Resolves and memoizes a standard task's descriptor by calling its factory (the task module's default + * export) once with the completed options. A task the definition explicitly skips (skip) or + * overrides with its own taskFunction has no descriptor to resolve. + * + * @private + * @param {string} taskName Task name + * @param {object} taskDef Standard task definition + * @returns {Promise} The validated descriptor, or undefined for a skipped + * or taskFunction-overridden task + */ + async #resolveStandardDescriptor(taskName, taskDef) { + if (taskDef.skip || taskDef.taskFunction) { + return undefined; + } + if (!taskDef.descriptorPromise) { + taskDef.descriptorPromise = (async () => { + const {task: factory} = await this.#taskRepository.getTask(taskName); + const descriptor = factory(taskDef.options); + validateDescriptor(descriptor, {taskName}); + return descriptor; + })(); + } + return taskDef.descriptorPromise; + } + async _getCustomTasks() { const projectCustomTasks = this.#project.getCustomTasks(); if (!projectCustomTasks || projectCustomTasks.length === 0) { @@ -129,14 +168,16 @@ export default class TaskDefinitions { await this._initTasks(); const sigKeys = []; - for (const taskDef of this.#standardTasks.values()) { - if (taskDef.determineBuildSignature) { - const sig = await taskDef.determineBuildSignature(); + for (const [taskName, taskDef] of this.#standardTasks) { + const descriptor = await this.#resolveStandardDescriptor(taskName, taskDef); + if (descriptor?.determineBuildSignature) { + const sig = await descriptor.determineBuildSignature(); if (sig) { sigKeys.push(sig); } } else { - // Fallback to task options + // A skipped or taskFunction-overridden task has no descriptor; a descriptor without a + // determineBuildSignature callback falls back to its options. Both hash the task options. sigKeys.push(JSON.stringify(taskDef.options)); } } @@ -145,25 +186,23 @@ export default class TaskDefinitions { const projectNamespace = this.#project.getNamespace(); for (const [taskName, {taskDef, task}] of this.#customTasks) { const specVersion = task.getSpecVersion(); - const determineBuildSignatureCallback = await task.getDetermineBuildSignatureCallback(); + const sigOptions = { + projectName, + projectNamespace, + configuration: taskDef.configuration, + taskName + }; + // The determineBuildSignature callback is a descriptor property, available only from + // Specification Version 5.0. Below 5.0 the default export is the legacy task body, so there is no + // callback to honor and the signature falls back to the task configuration. + const determineBuildSignatureCallback = specVersion.gte("5.0") ? + await task.getDetermineBuildSignatureCallback(taskName, sigOptions) : undefined; if (determineBuildSignatureCallback) { - if (specVersion.lt("5.0")) { - throw new Error( - `Custom task ${taskDef.name} of project ${projectName} uses a ` + - `determineBuildSignature callback, which is not supported for tasks with specVersion < 5.0. ` + - `Please update the task's specVersion to at least 5.0.` - ); - } const taskUtil = this.#taskUtil.getReadOnlyInterface(specVersion); const log = getLogger(`TaskDefinitions:custom-task:${taskName}`); const sig = await determineBuildSignatureCallback({ log, - options: { - projectName, - projectNamespace, - configuration: taskDef.configuration, - taskName - }, + options: sigOptions, taskUtil }); if (sig) { @@ -186,4 +225,18 @@ export default class TaskDefinitions { customTasks: this.#customTasks }; } + + /** + * Returns the memoized descriptor of a standard task (the object its factory returns), or + * undefined for a skipped or taskFunction-overridden task. Shares one factory + * call with {@link #getBuildSignatures}. + * + * @public + * @param {string} taskName Task name + * @param {object} taskDef Standard task definition + * @returns {Promise} The validated descriptor, or undefined + */ + async getStandardDescriptor(taskName, taskDef) { + return this.#resolveStandardDescriptor(taskName, taskDef); + } } diff --git a/packages/project/lib/build/TaskRunner.js b/packages/project/lib/build/TaskRunner.js index 0524647c5c7..3e14b58ef07 100644 --- a/packages/project/lib/build/TaskRunner.js +++ b/packages/project/lib/build/TaskRunner.js @@ -162,7 +162,7 @@ class TaskRunner { const {standardTasks, customTasks} = await this._taskDefinitions.getTaskDefinitions(); for (const [taskName, taskDef] of standardTasks) { - this._addTask(taskName, taskDef); + await this._addTask(taskName, taskDef); } for (const [taskName, {taskDef, task}] of customTasks) { @@ -211,26 +211,24 @@ class TaskRunner { this._log.setTasks(allTasks); // Expand step-based tasks into their per-step stages: each step is its own stage, so the - // stage list must enumerate a step-based task's step names in step order. The factory is pure over - // options (it may not read readers/taskUtil) and options are fixed for this build, so one factory - // call produces the steps that both create the stages here and run at execution. Keep the step array - // on the task so the execution path reuses it instead of calling the factory a second time. Freeze it - // so a custom task cannot mutate the shared value between the two uses; discovery re-derives it on the - // next build, so a surviving TaskRunner never serves a stale array. - const stageTasks = await Promise.all(allTasks.map(async (taskName) => { + // stage list must enumerate a step-based task's step names in step order. The descriptor (and its + // steps array) was resolved once by TaskDefinitions, shared with the build-signature phase, so read + // it here rather than calling the factory again. Freeze the array so a custom task cannot mutate the + // shared value; discovery re-derives it on the next build, so a surviving TaskRunner never serves a + // stale array. + const stageTasks = allTasks.map((taskName) => { const taskDef = this._tasks[taskName]; - if (!taskDef.stepBased) { + const steps = taskDef.descriptor?.steps; + if (!steps) { return {taskName}; } - const factory = await taskDef.stepFactory(); - const steps = await factory(taskDef.options); // Validate the whole step list before it creates any stage: setTasks below derives a stage id per // step name, so a duplicate name or a malformed declaration must be rejected here, not later when // the step runs (by then two steps sharing a name have already created two stages with one id). validateSteps(steps, {taskName}); taskDef.steps = Object.freeze(steps); return {taskName, stepNames: steps.map((step) => step.name)}; - })); + }); this._buildCache.setTasks(stageTasks); for (let i = 0; i < allTasks.length; i++) { @@ -291,23 +289,26 @@ class TaskRunner { * Adds an executable task to the builder * * The order this function is called defines the build order (FIFO). - * Tasks can be explicitly skipped by setting taskFunction to null. + * Tasks can be explicitly skipped by setting skip to true in the definition. + * + * The task's shape comes from the descriptor its factory (the task module's default export) returns: + * a steps array drives the step runner (one stage per step), a task body runs + * as a single legacy stage, and requiresDependencies decides whether a dependencies reader + * is provided. A definition may instead supply its own taskFunction override (for example the + * multi-bundle generateBundle wrapper that cannot live in @ui5/builder); it then runs as the + * single-stage body and the definition's own requiresDependencies flag applies. * * @param {string} taskName Name of the task to add - * @param {object} [parameters] Task parameters - * @param {boolean} [parameters.requiresDependencies=false] - * Whether the task requires access to project dependencies - * @param {boolean} [parameters.stepBased=false] - * Whether the task's default export is a step factory build(options) => Step[] driven - * by the step runner, rather than a legacy task body - * @param {object} [parameters.options={}] Options to pass to the task - * @param {Function|null} [parameters.taskFunction] - * Task function to execute, or null to explicitly skip the task - * @returns {void} + * @param {object} [taskDef] Task definition from the build definition + * @param {boolean} [taskDef.requiresDependencies=false] + * Whether a taskFunction-overridden task requires access to project dependencies + * @param {object} [taskDef.options={}] Options to pass to the task factory + * @param {Function} [taskDef.taskFunction] Task body override; runs as the single legacy stage + * @param {boolean} [taskDef.skip] Whether the task is explicitly skipped + * @returns {Promise} */ - _addTask(taskName, { - requiresDependencies = false, stepBased = false, options = {}, taskFunction - } = {}) { + async _addTask(taskName, taskDef = {}) { + const {options = {}, taskFunction, skip} = taskDef; if (this._tasks[taskName]) { throw new Error(`Failed to add duplicate task ${taskName} for project ${this._project.getName()}`); } @@ -316,39 +317,38 @@ class TaskRunner { `It has already been scheduled for execution`); } - // Complete the options before the task is registered, not when it runs. runTasks calls a step-based - // task's factory at plan time to discover its step names, and the factory may branch on - // projectNamespace (generateThemeDesignerResources emits its libraryTheming step only for a - // namespace). Assigning these at execution time left the plan-time call reading an incomplete - // options object, so the discovered step set missed a stage the step runner then asked for. + // projectName/projectNamespace were completed into the options by TaskDefinitions before the first + // factory call, so the descriptor resolved there matches the options the task body sees here. options.projectName = this._project.getName(); options.projectNamespace = this._project.getNamespace(); + // The descriptor is resolved and memoized by TaskDefinitions (shared with the build-signature phase). + // A skipped or taskFunction-overridden task has no descriptor. + const descriptor = await this._taskDefinitions.getStandardDescriptor(taskName, taskDef); + const steps = descriptor?.steps; + const stepBased = !!steps; + // requiresDependencies comes from the descriptor for a normal task, or from the definition for a + // taskFunction override. + const requiresDependencies = descriptor ? !!descriptor.requiresDependencies : !!taskDef.requiresDependencies; + // The single-stage body is the descriptor's task, or the definition's taskFunction override. + const taskBody = descriptor ? descriptor.task : taskFunction; + let task; - if (taskFunction === null) { + if (skip) { this._log.verbose(`Task ${taskName} is set to be explicitly skipped in definitions.`); task = null; } else { task = async (log) => { - if (!taskFunction) { - const {task} = await this._taskRepository.getTask(taskName); - taskFunction = task; - } - if (stepBased) { - // Step-based task: the default export is a factory build(options) => Step[]. runTasks - // already called the factory at discovery and kept the returned step array on the task, so - // reuse it here instead of calling the factory a second time. The factory is pure over - // options, so one call per build is the single source of truth for both the stage list and - // execution. Fall back to a direct call for a task invoked outside runTasks, where no - // discovery ran. Each step is its own pipeline stage; the step runner drives one stage per - // step via the per-stage hooks below. Every input a step reads arrives through its - // arguments, so no task body closes over the readers or taskUtil. - const steps = this._tasks[taskName].steps ?? await taskFunction(options); + // Step-based task: the descriptor carried a steps array. runTasks already froze it on the + // task at discovery; reuse it here. Each step is its own pipeline stage; the step runner + // drives one stage per step via the per-stage hooks below. Every input a step reads arrives + // through its arguments, so no task body closes over the readers or taskUtil. + const stepList = this._tasks[taskName].steps ?? steps; this._taskStart = performance.now(); const taskReport = this.#createTaskExecutionReport(taskName); const stepDriver = new StepRunner({ - steps, + steps: stepList, taskName, options, ...this.#createStepStageHooks(taskName, requiresDependencies), @@ -375,7 +375,7 @@ class TaskRunner { return; } - // Legacy task: the default export is a task body, not a step factory. It has a single stage. + // Legacy task: the descriptor's task body (or a definition taskFunction override). Single stage. const cacheInfo = await this._buildCache.prepareStageExecutionAndValidateCache(taskName); if (cacheInfo === true) { this._log.skipTask(taskName); @@ -398,7 +398,7 @@ class TaskRunner { } this._log.startTask(taskName, !!cacheInfo); this._taskStart = performance.now(); - await taskFunction(params); + await taskBody(params); if (this._log.isLevelEnabled("perf")) { this._log.perf( `Task ${taskName} finished in ${Math.round((performance.now() - this._taskStart))} ms`); @@ -423,14 +423,7 @@ class TaskRunner { this._tasks[taskName] = { task, stepBased, - // Lazily resolves the task factory so runTasks can enumerate a step-based task's step names for - // setTasks (called only for tasks that actually run). A legacy task keeps this undefined. - stepFactory: stepBased ? (async () => { - if (!taskFunction) { - taskFunction = (await this._taskRepository.getTask(taskName)).task; - } - return taskFunction; - }) : undefined, + descriptor, options, requiredDependencies: requiresDependencies ? this._directDependencies : new Set() }; @@ -462,21 +455,34 @@ class TaskRunner { const project = this._project; const taskUtil = this._taskUtil; - // Tasks can provide an optional callback to tell build process which dependencies they require - const requiredDependenciesCallback = await task.getRequiredDependenciesCallback(); - // const buildSignatureCallback = await task.getBuildSignatureCallback(); - // const expectedOutputCallback = await task.getExpectedOutputCallback(); const specVersion = task.getSpecVersion(); + // From Specification Version 5.0 the default export is a factory returning a descriptor. Below 5.0 it + // is the legacy task body invoked directly (any value it returns is ignored), so there is no descriptor. + // The factory options match those used for the build signature in TaskDefinitions, so Task#getDescriptor + // memoizes one descriptor shared by both phases. + const isV5 = specVersion.gte("5.0"); + const descriptorOptions = { + projectName: project.getName(), + projectNamespace: project.getNamespace(), + configuration: taskDef.configuration, + taskName + }; + const descriptor = isV5 ? await task.getDescriptor(taskName, descriptorOptions) : undefined; + + // Tasks can provide an optional callback to tell build process which dependencies they require + const requiredDependenciesCallback = + await task.getRequiredDependenciesCallback(taskName, descriptorOptions); let requiredDependencies; - // Always provide a dependencies-reader, even if empty. Unless the task is specVersion >=3.0 - // and did not define the respective callback. - // This is to distinguish between tasks semi-intentionally not requesting any dependencies, - // because none are available (i.e. because the project does not have any) and tasks that - // intentionally do not request any dependencies, by not providing a dependency-determination callback function + // A callback always gets a dependencies reader, even when it selects an empty set. Without a callback, + // a Version 5 descriptor can request all dependencies. Other tasks at Specification Version 3.0 and + // later get no reader, which distinguishes that default from a callback selecting an empty set. let provideDependenciesReader = true; if (!requiredDependenciesCallback) { - if (specVersion.gte("3.0")) { + if (isV5 && descriptor.requiresDependencies) { + // The descriptor can request all dependencies without providing a callback to reduce them. + requiredDependencies = this._directDependencies; + } else if (specVersion.gte("3.0")) { // Default for new spec versions: Provide no dependencies if no callback is provided this._log.verbose( `Custom task ${task.getName()} of project ${this._project.getName()} ` + @@ -528,18 +534,9 @@ class TaskRunner { } }); } - // A custom task opts into the step-factory API with a static `stepBased` export, honored from - // Specification Version 5.0. Below 5.0 the export is ignored and the task runs as a legacy body. - const stepBased = specVersion.gte("5.0") && (await task.getStepBased()) === true; - // Options the factory is called with at step-name discovery (runTasks). The factory is pure over - // options, so discovering step names by calling it early is safe, and the step array it returns is - // kept on the task and reused for execution rather than calling the factory again. - const stepOptions = stepBased ? { - projectName: project.getName(), - projectNamespace: project.getNamespace(), - configuration: taskDef.configuration, - ...(specVersion.gte("3.0") ? {taskName} : {}), - } : undefined; + // A custom task is step-based when its descriptor carries a steps array (Specification Version 5.0 + // and later). Below 5.0 there is no descriptor and the task runs as a legacy body. + const stepBased = !!descriptor?.steps; this._tasks[taskName] = { task: this._createCustomTaskWrapper({ task, @@ -548,16 +545,16 @@ class TaskRunner { taskName, taskConfiguration: taskDef.configuration, provideDependenciesReader, + descriptor, stepBased, - stepOptions, getDependenciesReaderCb: () => { // Create the dependencies reader on-demand return this.getDependenciesReader(requiredDependencies); }, }), stepBased, - stepFactory: stepBased ? (async () => task.getTask()) : undefined, - options: stepOptions, + descriptor, + options: descriptorOptions, requiredDependencies }; @@ -604,18 +601,18 @@ class TaskRunner { * Callback to get dependencies reader on-demand * @param {boolean} parameters.provideDependenciesReader * Whether to provide dependencies reader to the task + * @param {object} [parameters.descriptor] + * The task's resolved descriptor (Specification Version 5.0 and later). Absent below 5.0, where the + * default export is the legacy task body * @param {boolean} parameters.stepBased - * Whether the task's default export is a step factory (honored from Specification Version 5.0) - * @param {object} [parameters.stepOptions] - * The options object a step factory is called with at discovery in {@link #runTasks}. Kept so the - * fallback path (a task invoked outside runTasks) calls the factory with the same options + * Whether the descriptor carries a steps array (Specification Version 5.0 and later) * @param {@ui5/project/specifications/Extension} parameters.task Task extension instance * @param {string} parameters.taskName Runtime name of the task (may include suffix) * @param {object} [parameters.taskConfiguration] Task configuration from ui5.yaml * @returns {Function} Async wrapper function for the custom task */ _createCustomTaskWrapper({ - project, taskUtil, getDependenciesReaderCb, provideDependenciesReader, stepBased, stepOptions, + project, taskUtil, getDependenciesReaderCb, provideDependenciesReader, descriptor, stepBased, task, taskName, taskConfiguration }) { return async () => { @@ -643,7 +640,6 @@ class TaskRunner { */ const specVersion = task.getSpecVersion(); const taskUtilInterface = taskUtil.getInterface(specVersion); - const taskFunction = await task.getTask(); const isSpec3 = specVersion.gte("3.0"); const options = { @@ -656,19 +652,16 @@ class TaskRunner { } if (stepBased) { - // Step-based custom task: gated at Specification Version 5.0 in _addCustomTask, which always - // provides a taskUtil interface. The default export is a factory build(options) => Step[]; - // each step is its own pipeline stage, driven by the step runner via per-stage hooks. The - // factory receives options only. runTasks already called it at discovery and kept the step - // array on the task, so reuse it instead of calling the factory a second time (see the - // standard-task path). Fall back to a direct call for a task invoked outside runTasks. - const factoryOptions = stepOptions ?? options; - const steps = this._tasks[taskName].steps ?? await taskFunction(factoryOptions); + // Step-based custom task (Specification Version 5.0 and later): the descriptor carried a steps + // array. Each step is its own pipeline stage, driven by the step runner via per-stage hooks. + // runTasks already froze the step array on the task at discovery; reuse it, falling back to the + // descriptor's own array for a task invoked outside runTasks. + const steps = this._tasks[taskName].steps ?? descriptor.steps; const taskReport = this.#createTaskExecutionReport(taskName); const stepDriver = new StepRunner({ steps, taskName, - options: factoryOptions, + options, ...this.#createStepStageHooks(taskName, provideDependenciesReader, taskUtilInterface), returnValueStore: this._buildCache.getStepReturnValueStore(), resolveInputValue: this._buildCache.getResolveInputValue(), @@ -688,7 +681,10 @@ class TaskRunner { return; } - // Legacy custom task: the default export is a task body, not a step factory. It has a single stage. + // Legacy custom task: a single-stage body. From Specification Version 5.0 the body is the + // descriptor's task; below 5.0 the default export is the body itself (invoked directly), so any + // value it returns is ignored. + const taskFunction = descriptor ? descriptor.task : await task.getTask(); const cacheInfo = await this._buildCache.prepareStageExecutionAndValidateCache(taskName); if (cacheInfo === true) { this._log.skipTask(taskName); diff --git a/packages/project/lib/build/cache/CacheManager.js b/packages/project/lib/build/cache/CacheManager.js index 4740bdba952..4333bd3f1d2 100644 --- a/packages/project/lib/build/cache/CacheManager.js +++ b/packages/project/lib/build/cache/CacheManager.js @@ -11,7 +11,7 @@ const log = getLogger("build:cache:CacheManager"); const cacheManagerInstances = new Map(); // Cache version for compatibility management -export const CACHE_VERSION = "v0_9"; +export const CACHE_VERSION = "v0_10"; /** * Manages persistence for the build cache using a unified SQLite-backed storage diff --git a/packages/project/lib/build/definitions/application.js b/packages/project/lib/build/definitions/application.js index 0fdd3359683..b9599871c6f 100644 --- a/packages/project/lib/build/definitions/application.js +++ b/packages/project/lib/build/definitions/application.js @@ -13,7 +13,6 @@ import {enhanceBundlesWithDefaults} from "../../validation/validator.js"; export default function({project, taskUtil, getTask}) { const tasks = new Map(); tasks.set("escapeNonAsciiCharacters", { - stepBased: true, options: { encoding: project.getPropertiesFileSourceEncoding(), pattern: "/**/*.properties" @@ -21,7 +20,6 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceCopyright", { - stepBased: true, options: { copyright: project.getCopyright(), pattern: "/**/*.{js,json}" @@ -29,7 +27,6 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceVersion", { - stepBased: true, options: { version: project.getVersion(), pattern: "/**/*.{js,json}" @@ -45,13 +42,12 @@ export default function({project, taskUtil, getTask}) { } } tasks.set("minify", { - stepBased: true, options: { pattern: minificationPattern } }); - tasks.set("enhanceManifest", {stepBased: true}); + tasks.set("enhanceManifest", {}); tasks.set("generateFlexChangesBundle", {}); @@ -82,21 +78,26 @@ export default function({project, taskUtil, getTask}) { }); } - tasks.set("generateStandaloneAppBundle", {requiresDependencies: true}); + tasks.set("generateStandaloneAppBundle", {}); tasks.set("transformBootstrapHtml", {}); if (bundles.length) { + // The generateBundle task module resolves one bundle per call. Resolving the per-bundle defaults + // needs enhanceBundlesWithDefaults, which lives in @ui5/project and cannot move into @ui5/builder, so + // the multi-bundle wrapper stays here. It reads the task module's descriptor via getTask and drives + // descriptor.task once per bundle. tasks.set("generateBundle", { requiresDependencies: true, taskFunction: async ({workspace, dependencies, taskUtil, options}) => { - const generateBundleTask = await getTask("generateBundle"); + const {task} = await getTask("generateBundle"); + const {task: generateBundle} = task(options); // Async resolve default values for bundle definitions and options const bundlesDefaults = await enhanceBundlesWithDefaults(bundles, taskUtil.getProject()); return bundlesDefaults.reduce(async function(sequence, bundle) { return sequence.then(function() { - return generateBundleTask.task({ + return generateBundle({ workspace, dependencies, taskUtil, @@ -113,12 +114,11 @@ export default function({project, taskUtil, getTask}) { } else { // No bundles defined. Just set task so that it can be referenced by custom tasks tasks.set("generateBundle", { - taskFunction: null + skip: true }); } tasks.set("generateVersionInfo", { - requiresDependencies: true, options: { rootProject: project, pattern: "/resources/**/.library" @@ -131,8 +131,8 @@ export default function({project, taskUtil, getTask}) { } }); - tasks.set("generateApiIndex", {requiresDependencies: true}); - tasks.set("generateResourcesJson", {requiresDependencies: true}); + tasks.set("generateApiIndex", {}); + tasks.set("generateResourcesJson", {}); return tasks; } diff --git a/packages/project/lib/build/definitions/component.js b/packages/project/lib/build/definitions/component.js index 2fbb396bb89..75833f0e490 100644 --- a/packages/project/lib/build/definitions/component.js +++ b/packages/project/lib/build/definitions/component.js @@ -13,7 +13,6 @@ import {enhanceBundlesWithDefaults} from "../../validation/validator.js"; export default function({project, taskUtil, getTask}) { const tasks = new Map(); tasks.set("escapeNonAsciiCharacters", { - stepBased: true, options: { encoding: project.getPropertiesFileSourceEncoding(), pattern: "/**/*.properties" @@ -21,7 +20,6 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceCopyright", { - stepBased: true, options: { copyright: project.getCopyright(), pattern: "/**/*.{js,json}" @@ -29,7 +27,6 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceVersion", { - stepBased: true, options: { version: project.getVersion(), pattern: "/**/*.{js,json}" @@ -44,13 +41,12 @@ export default function({project, taskUtil, getTask}) { } tasks.set("minify", { - stepBased: true, options: { pattern: minificationPattern } }); - tasks.set("enhanceManifest", {stepBased: true}); + tasks.set("enhanceManifest", {}); tasks.set("generateFlexChangesBundle", {}); @@ -82,16 +78,21 @@ export default function({project, taskUtil, getTask}) { } if (bundles.length) { + // The generateBundle task module resolves one bundle per call. Resolving the per-bundle defaults + // needs enhanceBundlesWithDefaults, which lives in @ui5/project and cannot move into @ui5/builder, so + // the multi-bundle wrapper stays here. It reads the task module's descriptor via getTask and drives + // descriptor.task once per bundle. tasks.set("generateBundle", { requiresDependencies: true, taskFunction: async ({workspace, dependencies, taskUtil, options}) => { - const generateBundleTask = await getTask("generateBundle"); + const {task} = await getTask("generateBundle"); + const {task: generateBundle} = task(options); // Async resolve default values for bundle definitions and options const bundlesDefaults = await enhanceBundlesWithDefaults(bundles, taskUtil.getProject()); return bundlesDefaults.reduce(async function(sequence, bundle) { return sequence.then(function() { - return generateBundleTask.task({ + return generateBundle({ workspace, dependencies, taskUtil, @@ -108,7 +109,7 @@ export default function({project, taskUtil, getTask}) { } else { // No bundles defined. Just set task so that it can be referenced by custom tasks tasks.set("generateBundle", { - taskFunction: null + skip: true }); } @@ -118,7 +119,7 @@ export default function({project, taskUtil, getTask}) { } }); - tasks.set("generateResourcesJson", {requiresDependencies: true}); + tasks.set("generateResourcesJson", {}); return tasks; } diff --git a/packages/project/lib/build/definitions/library.js b/packages/project/lib/build/definitions/library.js index ec457d11517..46faaac20e5 100644 --- a/packages/project/lib/build/definitions/library.js +++ b/packages/project/lib/build/definitions/library.js @@ -14,7 +14,6 @@ import {enhanceBundlesWithDefaults} from "../../validation/validator.js"; export default function({project, taskUtil, getTask}) { const tasks = new Map(); tasks.set("escapeNonAsciiCharacters", { - stepBased: true, options: { encoding: project.getPropertiesFileSourceEncoding(), pattern: "/**/*.properties" @@ -22,7 +21,6 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceCopyright", { - stepBased: true, options: { copyright: project.getCopyright(), pattern: "/**/*.{js,library,css,less,theme,html}" @@ -30,7 +28,6 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceVersion", { - stepBased: true, options: { version: project.getVersion(), pattern: "/**/*.{js,json,library,css,less,theme,html}" @@ -38,43 +35,22 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceBuildtime", { - stepBased: true, options: { pattern: "/resources/sap/ui/{Global,core/Core}.js" } }); + // The task's requiresDependencies flag and its determineBuildSignature callback live on the task + // module's descriptor now; the definition only supplies the project-derived options the factory needs. tasks.set("generateJsdoc", { - requiresDependencies: true, - determineBuildSignature: async () => { - return project.getVersion(); - }, - taskFunction: async ({workspace, dependencies, taskUtil, options}) => { - const patterns = ["/resources/**/*.js"]; - // Add excludes - const excludes = project.getJsdocExcludes(); - if (excludes.length) { - patterns.push(...excludes.map((pattern) => { - return `!/resources/${pattern}`; - })); - } - const generateJsdocTask = await getTask("generateJsdoc"); - return generateJsdocTask.task({ - workspace, - dependencies, - taskUtil, - options: { - projectName: options.projectName, - namespace: project.getNamespace(), - version: project.getVersion(), - pattern: patterns - } - }); + options: { + namespace: project.getNamespace(), + version: project.getVersion(), + jsdocExcludes: project.getJsdocExcludes() } }); tasks.set("executeJsdocSdkTransformation", { - requiresDependencies: true, options: { dotLibraryPattern: "/resources/**/*.library", } @@ -90,7 +66,6 @@ export default function({project, taskUtil, getTask}) { } tasks.set("minify", { - stepBased: true, options: { pattern: minificationPattern } @@ -100,10 +75,10 @@ export default function({project, taskUtil, getTask}) { if (project.getSpecVersion().lt("5.0") || project.isFrameworkProject()) { tasks.set("generateLibraryManifest", {}); } else { - tasks.set("generateLibraryManifest", {taskFunction: null}); + tasks.set("generateLibraryManifest", {skip: true}); } - tasks.set("enhanceManifest", {stepBased: true}); + tasks.set("enhanceManifest", {}); const bundles = project.getBundles(); const existingBundleDefinitionNames = @@ -121,7 +96,7 @@ export default function({project, taskUtil, getTask}) { } }); } else { - tasks.set("generateComponentPreload", {taskFunction: null}); + tasks.set("generateComponentPreload", {skip: true}); } tasks.set("generateLibraryPreload", { @@ -132,16 +107,21 @@ export default function({project, taskUtil, getTask}) { }); if (bundles.length) { + // The generateBundle task module resolves one bundle per call. Resolving the per-bundle defaults + // needs enhanceBundlesWithDefaults, which lives in @ui5/project and cannot move into @ui5/builder, so + // the multi-bundle wrapper stays here. It reads the task module's descriptor via getTask and drives + // descriptor.task once per bundle. tasks.set("generateBundle", { requiresDependencies: true, taskFunction: async ({workspace, dependencies, taskUtil, options}) => { - const generateBundleTask = await getTask("generateBundle"); + const {task} = await getTask("generateBundle"); + const {task: generateBundle} = task(options); // Async resolve default values for bundle definitions and options const bundlesDefaults = await enhanceBundlesWithDefaults(bundles, taskUtil.getProject()); return bundlesDefaults.reduce(function(sequence, bundle) { return sequence.then(function() { - return generateBundleTask.task({ + return generateBundle({ workspace, dependencies, taskUtil, @@ -156,12 +136,10 @@ export default function({project, taskUtil, getTask}) { } }); } else { - tasks.set("generateBundle", {taskFunction: null}); + tasks.set("generateBundle", {skip: true}); } tasks.set("buildThemes", { - stepBased: true, - requiresDependencies: true, options: { projectName: project.getName(), librariesPattern: !taskUtil.isRootProject() ? "/resources/**/(*.library|library.js)" : undefined, @@ -172,19 +150,15 @@ export default function({project, taskUtil, getTask}) { if (project.isFrameworkProject()) { tasks.set("generateThemeDesignerResources", { - stepBased: true, - requiresDependencies: true, options: { version: project.getVersion() } }); } else { - tasks.set("generateThemeDesignerResources", {taskFunction: null}); + tasks.set("generateThemeDesignerResources", {skip: true}); } - tasks.set("generateResourcesJson", { - requiresDependencies: true - }); + tasks.set("generateResourcesJson", {}); return tasks; } diff --git a/packages/project/lib/build/definitions/themeLibrary.js b/packages/project/lib/build/definitions/themeLibrary.js index 76b6eae08ff..79cb73f712f 100644 --- a/packages/project/lib/build/definitions/themeLibrary.js +++ b/packages/project/lib/build/definitions/themeLibrary.js @@ -11,7 +11,6 @@ export default function({project, taskUtil, getTask}) { const tasks = new Map(); tasks.set("replaceCopyright", { - stepBased: true, options: { copyright: project.getCopyright(), pattern: "/resources/**/*.{less,theme}" @@ -19,7 +18,6 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("replaceVersion", { - stepBased: true, options: { version: project.getVersion(), pattern: "/resources/**/*.{less,theme}" @@ -27,8 +25,6 @@ export default function({project, taskUtil, getTask}) { }); tasks.set("buildThemes", { - stepBased: true, - requiresDependencies: true, options: { projectName: project.getName(), librariesPattern: !taskUtil.isRootProject() ? "/resources/**/(*.library|library.js)" : undefined, @@ -39,16 +35,14 @@ export default function({project, taskUtil, getTask}) { if (project.isFrameworkProject()) { tasks.set("generateThemeDesignerResources", { - stepBased: true, - requiresDependencies: true, options: { version: project.getVersion() } }); } else { - tasks.set("generateThemeDesignerResources", {taskFunction: null}); + tasks.set("generateThemeDesignerResources", {skip: true}); } - tasks.set("generateResourcesJson", {requiresDependencies: true}); + tasks.set("generateResourcesJson", {}); return tasks; } diff --git a/packages/project/lib/build/helpers/StepRunner.js b/packages/project/lib/build/helpers/StepRunner.js index d5149bdd831..bc10a910e6a 100644 --- a/packages/project/lib/build/helpers/StepRunner.js +++ b/packages/project/lib/build/helpers/StepRunner.js @@ -118,6 +118,58 @@ export function validateSteps(steps, {taskName} = {}) { } } +/** + * Validates a task descriptor: the object a task factory returns from its default export, from + * Specification Version 5.0 (and for every built-in task). A descriptor carries the whole task + * contract in one object instead of the former scattered exports and flags: exactly one of + * steps (the step-based path) or task (a legacy single-stage body), plus the + * optional determineRequiredDependencies / determineBuildSignature callbacks + * and an optional declared specVersion. + * + * The rules are static over the descriptor; the factory that produced it must stay pure and cheap (it is + * called once per build, including early for the build signature), so it must not read or write resources + * or import heavy processors. The step-equality check against the task extension's configured + * Specification Version is NOT done here (it needs a + * [SpecificationVersion]{@link @ui5/project/specifications/SpecificationVersion} instance only the caller + * holds); this helper only validates that a declared specVersion is a string. + * + * @param {*} descriptor The value the task factory returned + * @param {object} [parameters] + * @param {string} [parameters.taskName] The task name, named in the error messages + * @throws {Error} If the descriptor is invalid + */ +export function validateDescriptor(descriptor, {taskName} = {}) { + const where = taskName ? ` for task '${taskName}'` : ""; + if (!descriptor || typeof descriptor !== "object") { + throw new Error( + `Task factory${where} must return a descriptor object, got ${describeValue(descriptor)}`); + } + const hasSteps = descriptor.steps !== undefined; + const hasTask = descriptor.task !== undefined; + if (hasSteps === hasTask) { + throw new Error( + `Task descriptor${where} must define exactly one of 'steps' or 'task'`); + } + if (hasSteps) { + validateSteps(descriptor.steps, {taskName}); + } + if (hasTask && typeof descriptor.task !== "function") { + throw new Error( + `Task descriptor${where} 'task' must be a function, got ${describeValue(descriptor.task)}`); + } + for (const cb of ["determineRequiredDependencies", "determineBuildSignature", "determineExpectedOutput"]) { + if (descriptor[cb] !== undefined && typeof descriptor[cb] !== "function") { + throw new Error( + `Task descriptor${where} '${cb}' must be a function, got ${describeValue(descriptor[cb])}`); + } + } + if (descriptor.specVersion !== undefined && typeof descriptor.specVersion !== "string") { + throw new Error( + `Task descriptor${where} 'specVersion' must be a string, ` + + `got ${describeValue(descriptor.specVersion)}`); + } +} + /** * Collects the reads and writes of a single invocation (a scalar step's one invocation, or one key of a map * step). Project reads (workspace) and dependency reads are kept apart so they can be folded back into @@ -221,11 +273,11 @@ class RecordingReaderWriter extends AbstractReaderWriter { return this.#workspace.byPath(virPath, options); } - // Overrides _write rather than the public write (unlike @ui5/builder's runSteps BufferedWriter) because + // Overrides _write rather than the public write (unlike @ui5/builder's runTask BufferedWriter) because // this writer also records every write for the build cache, and AbstractReaderWriter.write has already // defaulted options by the time it delegates here. The buffered entry therefore stores the defaulted // options as the single replay argument, which flushWriteBuffer replays via write(resource, ...args). - // The same-path guard, its error message and the key-order flush are shared with runSteps through + // The same-path guard, its error message and the key-order flush are shared with runTask through // @ui5/fs/internal/stepWriteBuffer so the two runners cannot drift. async _write(resource, options) { const resourcePath = resource.getPath(); diff --git a/packages/project/lib/build/helpers/getBuildSignature.js b/packages/project/lib/build/helpers/getBuildSignature.js index 76d93d37691..1283aae2f43 100644 --- a/packages/project/lib/build/helpers/getBuildSignature.js +++ b/packages/project/lib/build/helpers/getBuildSignature.js @@ -3,7 +3,7 @@ import {getPackageVersion} from "./createBuildManifest.js"; // Increment signature version to invalidate all existing build signatures // (e.g. after making a change that impacts build contents) -const BUILD_SIG_VERSION = "0"; +const BUILD_SIG_VERSION = "1"; export function getBaseSignature(buildConfig) { const key = BUILD_SIG_VERSION + JSON.stringify(buildConfig); diff --git a/packages/project/lib/specifications/extensions/Task.js b/packages/project/lib/specifications/extensions/Task.js index ac9ce69f00a..88e2658f191 100644 --- a/packages/project/lib/specifications/extensions/Task.js +++ b/packages/project/lib/specifications/extensions/Task.js @@ -1,6 +1,7 @@ import path from "node:path"; import Extension from "../Extension.js"; import {pathToFileURL} from "node:url"; +import {validateDescriptor} from "../../build/helpers/StepRunner.js"; /** * Task @@ -12,47 +13,130 @@ import {pathToFileURL} from "node:url"; * @hideconstructor */ class Task extends Extension { + #descriptorPromisesByProject = new Map(); + constructor(parameters) { super(parameters); } /* === Attributes === */ /** - * @public - */ + * The task module's default export. + * + * From Specification Version 5.0 this is the task factory (options) => descriptor; use + * {@link #getDescriptor} to obtain the validated descriptor. Below 5.0 it is the legacy task body that + * is invoked directly during the build (any value it returns is ignored). + * + * @public + */ async getTask() { return (await this._getImplementation()).default; } /** - * @public - */ - async getRequiredDependenciesCallback() { + * Resolves and validates the task descriptor by calling the task factory (the default export) once with + * the given options. The result is memoized per project and runtime taskName, so a factory that + * branches on options (for example on projectNamespace) is called once for each + * project/task pair. The build-signature phase and task execution share that descriptor. The runtime task + * name keeps repeated configurations (which get a --<number> suffix) separate. + * + * Only valid from Specification Version 5.0. If the descriptor declares a specVersion, it must + * be equal to this task extension's configured Specification Version (the task extension's own + * ui5.yaml value, which is unrelated to the Specification Version of the project being built). + * + * @public + * @param {string} taskName Runtime task name (memoization key) + * @param {object} options Options passed to the factory + * @returns {Promise} The validated descriptor + */ + async getDescriptor(taskName, options) { + let descriptorPromises = this.#descriptorPromisesByProject.get(options.projectName); + if (!descriptorPromises) { + descriptorPromises = new Map(); + this.#descriptorPromisesByProject.set(options.projectName, descriptorPromises); + } + let descriptorPromise = descriptorPromises.get(taskName); + if (!descriptorPromise) { + descriptorPromise = (async () => { + const factory = (await this._getImplementation()).default; + const descriptor = await factory(options); + validateDescriptor(descriptor, {taskName}); + if (descriptor.specVersion !== undefined && + !this.getSpecVersion().eq(descriptor.specVersion)) { + throw new Error( + `Custom task ${this.getName()} declares Specification Version ` + + `${descriptor.specVersion} in its default export, which does not match the task ` + + `extension's configured Specification Version ${this.getSpecVersion().toString()}`); + } + return descriptor; + })(); + descriptorPromises.set(taskName, descriptorPromise); + } + return descriptorPromise; + } + + /** + * The task's determineRequiredDependencies callback. From Specification Version 5.0 it is a + * property of the descriptor the factory returns; below 5.0 it is a top-level named export of the task + * module (legacy). Returns undefined if the task does not define it. + * + * @public + * @param {string} [taskName] Runtime task name (required at Specification Version 5.0) + * @param {object} [options] Options passed to the factory (required at Specification Version 5.0) + */ + async getRequiredDependenciesCallback(taskName, options) { + if (this.getSpecVersion().gte("5.0")) { + return (await this.getDescriptor(taskName, options)).determineRequiredDependencies; + } return (await this._getImplementation()).determineRequiredDependencies; } /** - * @public - */ - async getDetermineBuildSignatureCallback() { - return (await this._getImplementation()).determineBuildSignature; + * The task's determineBuildSignature callback. From Specification Version 5.0 it is a + * property of the descriptor the factory returns; below 5.0 the callback is not supported and this + * returns undefined. + * + * @public + * @param {string} [taskName] Runtime task name (required at Specification Version 5.0) + * @param {object} [options] Options passed to the factory (required at Specification Version 5.0) + */ + async getDetermineBuildSignatureCallback(taskName, options) { + if (this.getSpecVersion().gte("5.0")) { + return (await this.getDescriptor(taskName, options)).determineBuildSignature; + } + return undefined; } /** - * Whether the task opts into the step-factory build API, declared as a static stepBased - * export (a plain boolean value, not a callback). Honored from Specification Version 5.0. + * Whether the task opts into the step-based build API, derived from the descriptor carrying a + * steps array (Specification Version 5.0 and later). Always false below 5.0, + * where the default export is the legacy task body. * * @public - * @returns {Promise} The task module's stepBased export + * @param {string} [taskName] Runtime task name (required at Specification Version 5.0) + * @param {object} [options] Options passed to the factory (required at Specification Version 5.0) + * @returns {Promise} true if the descriptor defines steps */ - async getStepBased() { - return (await this._getImplementation()).stepBased; + async getStepBased(taskName, options) { + if (this.getSpecVersion().gte("5.0")) { + return !!(await this.getDescriptor(taskName, options)).steps; + } + return false; } /** - * @public - */ - async getExpectedOutputCallback() { + * The task's determineExpectedOutput callback. From Specification Version 5.0 it is a + * property of the descriptor; below 5.0 it is a top-level named export. Currently parsed but not + * consumed anywhere. + * + * @public + * @param {string} [taskName] Runtime task name (required at Specification Version 5.0) + * @param {object} [options] Options passed to the factory (required at Specification Version 5.0) + */ + async getExpectedOutputCallback(taskName, options) { + if (this.getSpecVersion().gte("5.0")) { + return (await this.getDescriptor(taskName, options)).determineExpectedOutput; + } return (await this._getImplementation()).determineExpectedOutput; } diff --git a/packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-reader.js b/packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-reader.js index 91ef3b68232..5a9e8b5be58 100644 --- a/packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-reader.js +++ b/packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-reader.js @@ -2,36 +2,40 @@ const Logger = require("@ui5/logger"); const log = Logger.getLogger("builder:tasks:depTagReader"); let buildRanOnce; -module.exports = async function ({taskUtil, dependencies}) { - log.verbose("dep-tag-reader executed"); +// Specification Version 5.0 descriptor. +module.exports = function build() { + return { + determineRequiredDependencies: ({availableDependencies}) => { + return availableDependencies; + }, + task: async ({taskUtil, dependencies}) => { + log.verbose("dep-tag-reader executed"); - // const libraryDProject = taskUtil.getProject("library.d"); - const resources = await dependencies.byGlob("**/some.js"); - if (resources.length === 0) { - throw new Error("dep-tag-reader: some.js not found in library.d"); - } - const someJs = resources[0]; + // const libraryDProject = taskUtil.getProject("library.d"); + const resources = await dependencies.byGlob("**/some.js"); + if (resources.length === 0) { + throw new Error("dep-tag-reader: some.js not found in library.d"); + } + const someJs = resources[0]; - if (buildRanOnce !== true) { - log.verbose("First build: Verifying ui5:IsDebugVariant is set on some.js"); - buildRanOnce = true; - const tag = taskUtil.getTag(someJs, taskUtil.STANDARD_TAGS.IsDebugVariant); - if (!tag) { - throw new Error( - "dep-tag-reader: Expected ui5:IsDebugVariant tag to be set on some.js in first build" - ); + if (buildRanOnce !== true) { + log.verbose("First build: Verifying ui5:IsDebugVariant is set on some.js"); + buildRanOnce = true; + const tag = taskUtil.getTag(someJs, taskUtil.STANDARD_TAGS.IsDebugVariant); + if (!tag) { + throw new Error( + "dep-tag-reader: Expected ui5:IsDebugVariant tag to be set on some.js in first build" + ); + } + } else { + log.verbose("Subsequent build: Verifying ui5:HasDebugVariant is set on some.js"); + const tag = taskUtil.getTag(someJs, taskUtil.STANDARD_TAGS.HasDebugVariant); + if (!tag) { + throw new Error( + "dep-tag-reader: Expected ui5:HasDebugVariant tag to be set on some.js in subsequent build" + ); + } + } } - } else { - log.verbose("Subsequent build: Verifying ui5:HasDebugVariant is set on some.js"); - const tag = taskUtil.getTag(someJs, taskUtil.STANDARD_TAGS.HasDebugVariant); - if (!tag) { - throw new Error( - "dep-tag-reader: Expected ui5:HasDebugVariant tag to be set on some.js in subsequent build" - ); - } - } + }; }; - -module.exports.determineRequiredDependencies = function ({availableDependencies}) { - return availableDependencies; -} diff --git a/packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-setter.js b/packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-setter.js index 8d5b986dbd4..e7f4552b0db 100644 --- a/packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-setter.js +++ b/packages/project/test/fixtures/application.a/cross-project-tag-tasks/dep-tag-setter.js @@ -2,21 +2,26 @@ const Logger = require("@ui5/logger"); const log = Logger.getLogger("builder:tasks:depTagSetter"); let buildRanOnce; -module.exports = async function ({workspace, taskUtil}) { - log.verbose("dep-tag-setter executed"); +// Specification Version 5.0 descriptor. +module.exports = function build() { + return { + task: async ({workspace, taskUtil}) => { + log.verbose("dep-tag-setter executed"); - const resources = await workspace.byGlob("**/some.js"); - if (resources.length === 0) { - throw new Error("dep-tag-setter: some.js not found in workspace"); - } - const someJs = resources[0]; + const resources = await workspace.byGlob("**/some.js"); + if (resources.length === 0) { + throw new Error("dep-tag-setter: some.js not found in workspace"); + } + const someJs = resources[0]; - if (buildRanOnce !== true) { - log.verbose("First build: Setting ui5:IsDebugVariant on some.js"); - buildRanOnce = true; - taskUtil.setTag(someJs, taskUtil.STANDARD_TAGS.IsDebugVariant); - } else { - log.verbose("Subsequent build: Setting ui5:HasDebugVariant on some.js"); - taskUtil.setTag(someJs, taskUtil.STANDARD_TAGS.HasDebugVariant); - } + if (buildRanOnce !== true) { + log.verbose("First build: Setting ui5:IsDebugVariant on some.js"); + buildRanOnce = true; + taskUtil.setTag(someJs, taskUtil.STANDARD_TAGS.IsDebugVariant); + } else { + log.verbose("Subsequent build: Setting ui5:HasDebugVariant on some.js"); + taskUtil.setTag(someJs, taskUtil.STANDARD_TAGS.HasDebugVariant); + } + } + }; }; diff --git a/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-0.js b/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-0.js index 78495298e3a..e8e13e68661 100644 --- a/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-0.js +++ b/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-0.js @@ -2,21 +2,23 @@ const Logger = require("@ui5/logger"); const log = Logger.getLogger("builder:tasks:customTask0"); let buildRanOnce; -module.exports = async function ({ - workspace, taskUtil, - options: {projectNamespace} -}) { - log.verbose("Custom task 0 executed"); +// Specification Version 5.0 descriptor: the default export is a factory that returns the task body. +module.exports = function build() { + return { + task: async ({workspace, taskUtil, options: {projectNamespace}}) => { + log.verbose("Custom task 0 executed"); - // Read a file to trigger execution of this task: - const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); + // Read a file to trigger execution of this task: + const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); - if (buildRanOnce != true) { - log.verbose("Flag NOT set -> We are in #1 Build still"); - buildRanOnce = true; - taskUtil.setTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); - } else { - log.verbose("Flag set -> We are in #2 Build"); - taskUtil.setTag(testJS, taskUtil.STANDARD_TAGS.OmitFromBuildResult); - } + if (buildRanOnce != true) { + log.verbose("Flag NOT set -> We are in #1 Build still"); + buildRanOnce = true; + taskUtil.setTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); + } else { + log.verbose("Flag set -> We are in #2 Build"); + taskUtil.setTag(testJS, taskUtil.STANDARD_TAGS.OmitFromBuildResult); + } + } + }; }; diff --git a/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-1.js b/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-1.js index 98f8b94c848..3c294808b1b 100644 --- a/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-1.js +++ b/packages/project/test/fixtures/application.a/custom-tasks-2/custom-task-1.js @@ -2,31 +2,33 @@ const Logger = require("@ui5/logger"); const log = Logger.getLogger("builder:tasks:customTask1"); let buildRanOnce; -module.exports = async function ({ - workspace, taskUtil, - options: {projectNamespace} -}) { - log.verbose("Custom task 1 executed"); +// Specification Version 5.0 descriptor: the default export is a factory that returns the task body. +module.exports = function build() { + return { + task: async ({workspace, taskUtil, options: {projectNamespace}}) => { + log.verbose("Custom task 1 executed"); - // Read a file to trigger execution of this task: - const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); + // Read a file to trigger execution of this task: + const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); - if (buildRanOnce != true) { - log.verbose("Flag NOT set -> We are in #1 Build still"); - buildRanOnce = true; - const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); - if (!tag) { - throw new Error("Tag set during #1 Build is not readable, which is UNEXPECTED."); + if (buildRanOnce != true) { + log.verbose("Flag NOT set -> We are in #1 Build still"); + buildRanOnce = true; + const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); + if (!tag) { + throw new Error("Tag set during #1 Build is not readable, which is UNEXPECTED."); + } + } else { + const previousTag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); + if (previousTag) { + throw new Error("Tag set during #1 Build is still readable, which is UNEXPECTED."); + } + log.verbose("Flag set -> We are in #2 Build"); + const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.OmitFromBuildResult); + if (!tag) { + throw new Error("Tag set during #2 Build is not readable, which is UNEXPECTED."); + } + } } - } else { - const previousTag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); - if (previousTag) { - throw new Error("Tag set during #1 Build is still readable, which is UNEXPECTED."); - } - log.verbose("Flag set -> We are in #2 Build"); - const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.OmitFromBuildResult); - if (!tag) { - throw new Error("Tag set during #2 Build is not readable, which is UNEXPECTED."); - } - } + }; }; diff --git a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js index c19f110113c..376b6b602df 100644 --- a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js +++ b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-0.js @@ -1,22 +1,24 @@ const Logger = require("@ui5/logger"); const log = Logger.getLogger("builder:tasks:customTask0"); -module.exports = async function ({ - workspace, taskUtil, - options: {projectNamespace} -}) { - log.verbose("Custom task 0 executed"); +// Specification Version 5.0 descriptor: the default export is a factory that returns the task body. +module.exports = function build() { + return { + task: async ({workspace, taskUtil, options: {projectNamespace}}) => { + log.verbose("Custom task 0 executed"); - // Read a file which is an input of custom-task-1 (which sets a tag on it): - const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); + // Read a file which is an input of custom-task-1 (which sets a tag on it): + const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); - const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); - // For #1 & #3 build: - if (tag) { - throw new Error("Tag set by custom-task-1 is present in custom-task-0, which is UNEXPECTED."); - } + const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); + // For #1 & #3 build: + if (tag) { + throw new Error("Tag set by custom-task-1 is present in custom-task-0, which is UNEXPECTED."); + } - // For #3 build: Read a different file which is not an input of custom-task-1 - // (ensures that this task is executed): - const test2JS = await workspace.byPath(`/resources/${projectNamespace}/test2.js`); + // For #3 build: Read a different file which is not an input of custom-task-1 + // (ensures that this task is executed): + const test2JS = await workspace.byPath(`/resources/${projectNamespace}/test2.js`); + } + }; }; diff --git a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js index 58030f3ea06..388d9f91a38 100644 --- a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js +++ b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-1.js @@ -1,15 +1,17 @@ const Logger = require("@ui5/logger"); const log = Logger.getLogger("builder:tasks:customTask1"); -module.exports = async function ({ - workspace, taskUtil, - options: {projectNamespace} -}) { - log.verbose("Custom task 1 executed"); +// Specification Version 5.0 descriptor: the default export is a factory that returns the task body. +module.exports = function build() { + return { + task: async ({workspace, taskUtil, options: {projectNamespace}}) => { + log.verbose("Custom task 1 executed"); - // Set a tag on a specific resource: - const resource = await workspace.byPath(`/resources/${projectNamespace}/test.js`); - if (resource) { - taskUtil.setTag(resource, taskUtil.STANDARD_TAGS.IsDebugVariant); + // Set a tag on a specific resource: + const resource = await workspace.byPath(`/resources/${projectNamespace}/test.js`); + if (resource) { + taskUtil.setTag(resource, taskUtil.STANDARD_TAGS.IsDebugVariant); + }; + } }; }; diff --git a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js index de5a39068a2..82475603c18 100644 --- a/packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js +++ b/packages/project/test/fixtures/application.a/custom-tasks/custom-task-2.js @@ -1,22 +1,24 @@ const Logger = require("@ui5/logger"); const log = Logger.getLogger("builder:tasks:customTask2"); -module.exports = async function ({ - workspace, taskUtil, - options: {projectNamespace} -}) { - log.verbose("Custom task 2 executed"); +// Specification Version 5.0 descriptor: the default export is a factory that returns the task body. +module.exports = function build() { + return { + task: async ({workspace, taskUtil, options: {projectNamespace}}) => { + log.verbose("Custom task 2 executed"); - // Read a file which is an input of custom-task-1 (which sets a tag on it): - const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); + // Read a file which is an input of custom-task-1 (which sets a tag on it): + const testJS = await workspace.byPath(`/resources/${projectNamespace}/test.js`); - const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); - // For #1 & #3 build: - if (!tag) { - throw new Error("Tag set by custom-task-1 is NOT present in custom-task-2, which is UNEXPECTED."); - } + const tag = taskUtil.getTag(testJS, taskUtil.STANDARD_TAGS.IsDebugVariant); + // For #1 & #3 build: + if (!tag) { + throw new Error("Tag set by custom-task-1 is NOT present in custom-task-2, which is UNEXPECTED."); + } - // For #3 build: Read a different file which is not an input of custom-task-1 - // (ensures that this task is executed): - const test2JS = await workspace.byPath(`/resources/${projectNamespace}/test2.js`); + // For #3 build: Read a different file which is not an input of custom-task-1 + // (ensures that this task is executed): + const test2JS = await workspace.byPath(`/resources/${projectNamespace}/test2.js`); + } + }; }; diff --git a/packages/project/test/fixtures/application.a/dependency-race-condition-task.js b/packages/project/test/fixtures/application.a/dependency-race-condition-task.js index 9fac2ae248d..58cfd537786 100644 --- a/packages/project/test/fixtures/application.a/dependency-race-condition-task.js +++ b/packages/project/test/fixtures/application.a/dependency-race-condition-task.js @@ -16,46 +16,52 @@ const path = require("path"); * 3. Re-read via the dependency reader * 4. Assert the content is still the original (CAS-served) * 5. Restore the original file on disk + * + * Specification Version 5.0 descriptor: the default export is a factory that returns the task body. */ -module.exports = async function ({taskUtil}) { - const libDProject = taskUtil.getProject("library.d"); - const libDReader = libDProject.getReader(); +module.exports = function build() { + return { + task: async ({taskUtil}) => { + const libDProject = taskUtil.getProject("library.d"); + const libDReader = libDProject.getReader(); - // Step 1: Read the original content via the dependency reader (CAS-backed) - // data.json is untransformed (no placeholders, not a .js file subject to minification) - // so it is only served by the frozen CAS reader or the filesystem reader - const resourcePath = "/resources/library/d/data.json"; - const originalResource = await libDReader.byPath(resourcePath); - if (!originalResource) { - throw new Error(`Resource ${resourcePath} not found via dependency reader`); - } - const originalContent = await originalResource.getString(); + // Step 1: Read the original content via the dependency reader (CAS-backed) + // data.json is untransformed (no placeholders, not a .js file subject to minification) + // so it is only served by the frozen CAS reader or the filesystem reader + const resourcePath = "/resources/library/d/data.json"; + const originalResource = await libDReader.byPath(resourcePath); + if (!originalResource) { + throw new Error(`Resource ${resourcePath} not found via dependency reader`); + } + const originalContent = await originalResource.getString(); - // Step 2: Overwrite the file on disk - const sourcePath = libDProject.getSourcePath(); - const diskFilePath = path.join(sourcePath, "library", "d", "data.json"); - const diskOriginalContent = await readFile(diskFilePath, {encoding: "utf8"}); - await writeFile(diskFilePath, JSON.stringify({key: "modified-by-race-condition"})); + // Step 2: Overwrite the file on disk + const sourcePath = libDProject.getSourcePath(); + const diskFilePath = path.join(sourcePath, "library", "d", "data.json"); + const diskOriginalContent = await readFile(diskFilePath, {encoding: "utf8"}); + await writeFile(diskFilePath, JSON.stringify({key: "modified-by-race-condition"})); - try { - // Step 3: Re-read via the dependency reader — should still return CAS-frozen content - // Without the frozen reader, this would read the modified disk content - const reReadResource = await libDReader.byPath(resourcePath); - if (!reReadResource) { - throw new Error(`Resource ${resourcePath} not found on re-read via dependency reader`); - } - const reReadContent = await reReadResource.getString(); + try { + // Step 3: Re-read via the dependency reader — should still return CAS-frozen content + // Without the frozen reader, this would read the modified disk content + const reReadResource = await libDReader.byPath(resourcePath); + if (!reReadResource) { + throw new Error(`Resource ${resourcePath} not found on re-read via dependency reader`); + } + const reReadContent = await reReadResource.getString(); - // Step 4: Assert the content is still the original (not modified disk content) - if (reReadContent !== originalContent) { - throw new Error( - "Frozen source reader protection failed: dependency reader returned modified disk content " + - "instead of the original CAS-frozen content. " + - `Expected: ${JSON.stringify(originalContent)}, Got: ${JSON.stringify(reReadContent)}` - ); + // Step 4: Assert the content is still the original (not modified disk content) + if (reReadContent !== originalContent) { + throw new Error( + "Frozen source reader protection failed: dependency reader returned modified disk " + + "content instead of the original CAS-frozen content. " + + `Expected: ${JSON.stringify(originalContent)}, Got: ${JSON.stringify(reReadContent)}` + ); + } + } finally { + // Step 5: Always restore the original file on disk + await writeFile(diskFilePath, diskOriginalContent); + } } - } finally { - // Step 5: Always restore the original file on disk - await writeFile(diskFilePath, diskOriginalContent); - } + }; }; diff --git a/packages/project/test/fixtures/application.a/race-condition-add-file-task.js b/packages/project/test/fixtures/application.a/race-condition-add-file-task.js index c4572b0aae1..a320041d9fd 100644 --- a/packages/project/test/fixtures/application.a/race-condition-add-file-task.js +++ b/packages/project/test/fixtures/application.a/race-condition-add-file-task.js @@ -1,11 +1,13 @@ const {writeFile} = require("fs/promises"); const path = require("path"); -module.exports = async function ({ - workspace, taskUtil, - options: {projectNamespace} -}) { - const webappPath = taskUtil.getProject().getSourcePath(); - const addedFilePath = path.join(webappPath, "added-during-build.js"); - await writeFile(addedFilePath, `console.log("RACE CONDITION ADDED FILE");\n`); +// Specification Version 5.0 descriptor: the default export is a factory that returns the task body. +module.exports = function build() { + return { + task: async ({workspace, taskUtil, options: {projectNamespace}}) => { + const webappPath = taskUtil.getProject().getSourcePath(); + const addedFilePath = path.join(webappPath, "added-during-build.js"); + await writeFile(addedFilePath, `console.log("RACE CONDITION ADDED FILE");\n`); + } + }; }; diff --git a/packages/project/test/fixtures/application.a/race-condition-delete-file-task.js b/packages/project/test/fixtures/application.a/race-condition-delete-file-task.js index bad0440f7c4..5d1760d74c8 100644 --- a/packages/project/test/fixtures/application.a/race-condition-delete-file-task.js +++ b/packages/project/test/fixtures/application.a/race-condition-delete-file-task.js @@ -1,11 +1,13 @@ const {rm} = require("fs/promises"); const path = require("path"); -module.exports = async function ({ - workspace, taskUtil, - options: {projectNamespace} -}) { - const webappPath = taskUtil.getProject().getSourcePath(); - const deletedFilePath = path.join(webappPath, "test.js"); - await rm(deletedFilePath, {force: true}); +// Specification Version 5.0 descriptor: the default export is a factory that returns the task body. +module.exports = function build() { + return { + task: async ({workspace, taskUtil, options: {projectNamespace}}) => { + const webappPath = taskUtil.getProject().getSourcePath(); + const deletedFilePath = path.join(webappPath, "test.js"); + await rm(deletedFilePath, {force: true}); + } + }; }; diff --git a/packages/project/test/fixtures/application.a/race-condition-task.js b/packages/project/test/fixtures/application.a/race-condition-task.js index e70ed51ce7f..571e1c3e1bb 100644 --- a/packages/project/test/fixtures/application.a/race-condition-task.js +++ b/packages/project/test/fixtures/application.a/race-condition-task.js @@ -1,13 +1,15 @@ const {readFile, writeFile} = require("fs/promises"); const path = require("path"); -module.exports = async function ({ - workspace, taskUtil, - options: {projectNamespace} -}) { - const webappPath = taskUtil.getProject().getSourcePath(); - // Modify source file during build - const testFilePath = path.join(webappPath, "test.js"); - const originalContent = await readFile(testFilePath, {encoding: "utf8"}); - await writeFile(testFilePath, originalContent + `\nconsole.log("RACE CONDITION MODIFICATION");\n`); +// Specification Version 5.0 descriptor: the default export is a factory that returns the task body. +module.exports = function build() { + return { + task: async ({workspace, taskUtil, options: {projectNamespace}}) => { + const webappPath = taskUtil.getProject().getSourcePath(); + // Modify source file during build + const testFilePath = path.join(webappPath, "test.js"); + const originalContent = await readFile(testFilePath, {encoding: "utf8"}); + await writeFile(testFilePath, originalContent + `\nconsole.log("RACE CONDITION MODIFICATION");\n`); + } + }; }; diff --git a/packages/project/test/fixtures/application.a/task.build-signature.js b/packages/project/test/fixtures/application.a/task.build-signature.js index 9b78d20c3b5..b3406523de2 100644 --- a/packages/project/test/fixtures/application.a/task.build-signature.js +++ b/packages/project/test/fixtures/application.a/task.build-signature.js @@ -19,25 +19,28 @@ async function readControlValue(rootPath) { } } -// Task body: appends the current control value to the application's test.js. This makes the built -// output observably depend on the control file, so a served resource reflects its value. -module.exports = async function ({taskUtil, workspace, options: {projectNamespace}}) { - log.verbose("build-signature-task executed"); +// Specification Version 5.0 descriptor: the task body and the determineBuildSignature callback are +// properties of the object the default-export factory returns. Both derive their value from the control +// file, so a test can change the returned value / produced output independently of any source change. +module.exports = function build() { + return { + determineBuildSignature: async ({taskUtil}) => { + const controlValue = await readControlValue(taskUtil.getProject().getRootPath()); + log.verbose(`build-signature-task determineBuildSignature: ${controlValue}`); + return controlValue; + }, + // Task body: appends the current control value to the application's test.js. This makes the built + // output observably depend on the control file, so a served resource reflects its value. + task: async ({taskUtil, workspace, options: {projectNamespace}}) => { + log.verbose("build-signature-task executed"); - const controlValue = await readControlValue(taskUtil.getProject().getRootPath()); - const resource = await workspace.byPath(`/resources/${projectNamespace}/test.js`); - if (resource) { - const content = `${await resource.getString()}\n// build-signature-control: ${controlValue}\n`; - resource.setString(content); - await workspace.write(resource); - } -}; - -// determineBuildSignature is invoked by TaskDefinitions#getBuildSignatures() to contribute a value -// to the project's build signature. We derive it from the same control file so that changing the -// file changes the returned signature (and thus must invalidate the project's build cache). -module.exports.determineBuildSignature = async function ({taskUtil}) { - const controlValue = await readControlValue(taskUtil.getProject().getRootPath()); - log.verbose(`build-signature-task determineBuildSignature: ${controlValue}`); - return controlValue; + const controlValue = await readControlValue(taskUtil.getProject().getRootPath()); + const resource = await workspace.byPath(`/resources/${projectNamespace}/test.js`); + if (resource) { + const content = `${await resource.getString()}\n// build-signature-control: ${controlValue}\n`; + resource.setString(content); + await workspace.write(resource); + } + } + }; }; diff --git a/packages/project/test/fixtures/application.a/task.dependency-change.js b/packages/project/test/fixtures/application.a/task.dependency-change.js index 8007b936df7..c2463a890d9 100644 --- a/packages/project/test/fixtures/application.a/task.dependency-change.js +++ b/packages/project/test/fixtures/application.a/task.dependency-change.js @@ -1,40 +1,45 @@ // This is a modified version of the compileLicenseSummary example of the UI5 CLI documentation. // (https://github.com/UI5/cli/blob/b72919469d856508dd757ecf325a5fb45f15e56d/internal/documentation/docs/pages/extensibility/CustomTasks.md#example-libtaskscompilelicensesummaryjs) -module.exports = async function ({log, taskUtil, workspace}) { - const {createResource} = taskUtil.resourceFactory; - const projectsVisited = new Set(); +// Specification Version 5.0 descriptor: the task body and the determineRequiredDependencies callback are +// properties of the object the default-export factory returns. +module.exports = function build() { + return { + determineRequiredDependencies: ({availableDependencies}) => { + return availableDependencies; + }, + task: async ({log, taskUtil, workspace}) => { + const {createResource} = taskUtil.resourceFactory; + const projectsVisited = new Set(); - async function processProject() { - return Promise.all(taskUtil.getDependencies().map(async (projectName) => { - if (projectName !== "library.d") { - return; + async function processProject() { + return Promise.all(taskUtil.getDependencies().map(async (projectName) => { + if (projectName !== "library.d") { + return; + } + if (projectsVisited.has(projectName)) { + return; + } + projectsVisited.add(projectName); + const project = taskUtil.getProject(projectName); + const newLibraryFile = await project.getReader().byGlob("**/newLibraryFile.js"); + if (newLibraryFile.length > 0) { + log.verbose('New Library file found. We are in #4 build.'); + // Change content of application.a: + const applicationResource = await workspace.byPath("/resources/id1/test.js"); + const content = (await applicationResource.getString()) + "\n console.log('something new');"; + await workspace.write(createResource({ + path: "/test.js", + string: content + })); + } else { + log.verbose(`New Library file not found. We are still in an earlier build.`); + } + return processProject(project); + })); } - if (projectsVisited.has(projectName)) { - return; - } - projectsVisited.add(projectName); - const project = taskUtil.getProject(projectName); - const newLibraryFile = await project.getReader().byGlob("**/newLibraryFile.js"); - if (newLibraryFile.length > 0) { - log.verbose('New Library file found. We are in #4 build.'); - // Change content of application.a: - const applicationResource = await workspace.byPath("/resources/id1/test.js"); - const content = (await applicationResource.getString()) + "\n console.log('something new');"; - await workspace.write(createResource({ - path: "/test.js", - string: content - })); - } else { - log.verbose(`New Library file not found. We are still in an earlier build.`); - } - return processProject(project); - })); - } - // Start processing dependencies of the root project - await processProject(taskUtil.getProject()); + // Start processing dependencies of the root project + await processProject(taskUtil.getProject()); + } + }; }; - -module.exports.determineRequiredDependencies = function ({availableDependencies}) { - return availableDependencies; -} diff --git a/packages/project/test/fixtures/application.a/task.example.js b/packages/project/test/fixtures/application.a/task.example.js index 669c19c3fbf..6f548d10218 100644 --- a/packages/project/test/fixtures/application.a/task.example.js +++ b/packages/project/test/fixtures/application.a/task.example.js @@ -1,15 +1,17 @@ const Logger = require("@ui5/logger"); const log = Logger.getLogger("builder:tasks:exampleTask"); -module.exports = async function ({ - workspace, taskUtil, - options: {projectNamespace} -}) { - log.verbose("Example task executed"); +// Specification Version 5.0 descriptor: the default export is a factory that returns the task body. +module.exports = function build() { + return { + task: async ({workspace, taskUtil, options: {projectNamespace}}) => { + log.verbose("Example task executed"); - // Omit a specific resource from the build result - const omittedResource = await workspace.byPath(`/resources/${projectNamespace}/fileToBeOmitted.js`); - if (omittedResource) { - taskUtil.setTag(omittedResource, taskUtil.STANDARD_TAGS.OmitFromBuildResult); + // Omit a specific resource from the build result + const omittedResource = await workspace.byPath(`/resources/${projectNamespace}/fileToBeOmitted.js`); + if (omittedResource) { + taskUtil.setTag(omittedResource, taskUtil.STANDARD_TAGS.OmitFromBuildResult); + }; + } }; }; diff --git a/packages/project/test/fixtures/application.a/task.root-conditional.js b/packages/project/test/fixtures/application.a/task.root-conditional.js index fa15cac1527..e9faebad40c 100644 --- a/packages/project/test/fixtures/application.a/task.root-conditional.js +++ b/packages/project/test/fixtures/application.a/task.root-conditional.js @@ -3,17 +3,22 @@ // Once /toggle.js is gone the task records no root read, so the previously recorded root request must // be cleared (and the emptied request set re-persisted), and a later change to /tsconfig.json must then // no longer invalidate this stage. -module.exports = async function ({taskUtil, workspace, options: {projectNamespace}}) { - const {createResource} = taskUtil.resourceFactory; - const toggle = await workspace.byPath(`/resources/${projectNamespace}/toggle.js`); - let content = "root-not-read"; - if (toggle) { - const rootReader = taskUtil.getProject().getRootReader(); - const tsconfig = await rootReader.byPath("/tsconfig.json"); - content = tsconfig ? await tsconfig.getString() : "no-tsconfig"; - } - await workspace.write(createResource({ - path: "/rootConditionalDigest.js", - string: `export const content = ${JSON.stringify(content)};\n`, - })); +// Specification Version 5.0 descriptor: the default export is a factory that returns the task body. +module.exports = function build() { + return { + task: async ({taskUtil, workspace, options: {projectNamespace}}) => { + const {createResource} = taskUtil.resourceFactory; + const toggle = await workspace.byPath(`/resources/${projectNamespace}/toggle.js`); + let content = "root-not-read"; + if (toggle) { + const rootReader = taskUtil.getProject().getRootReader(); + const tsconfig = await rootReader.byPath("/tsconfig.json"); + content = tsconfig ? await tsconfig.getString() : "no-tsconfig"; + } + await workspace.write(createResource({ + path: "/rootConditionalDigest.js", + string: `export const content = ${JSON.stringify(content)};\n`, + })); + } + }; }; diff --git a/packages/project/test/fixtures/application.a/task.root-config.js b/packages/project/test/fixtures/application.a/task.root-config.js index 20fe7b00a15..748d605ce94 100644 --- a/packages/project/test/fixtures/application.a/task.root-config.js +++ b/packages/project/test/fixtures/application.a/task.root-config.js @@ -1,13 +1,18 @@ // Custom task that reads a configuration file from the project root (outside the UI5 resource model) // through taskUtil.getProject().getRootReader() and derives its output from that file's content. // Exercises root-resource tracking: a change to the root config must invalidate this task's cache. -module.exports = async function ({taskUtil, workspace}) { - const {createResource} = taskUtil.resourceFactory; - const rootReader = taskUtil.getProject().getRootReader(); - const tsconfig = await rootReader.byPath("/tsconfig.json"); - const content = tsconfig ? await tsconfig.getString() : "no-tsconfig"; - await workspace.write(createResource({ - path: "/tsconfigDigest.js", - string: `export const tsconfig = ${JSON.stringify(content)};\n`, - })); +// Specification Version 5.0 descriptor: the default export is a factory that returns the task body. +module.exports = function build() { + return { + task: async ({taskUtil, workspace}) => { + const {createResource} = taskUtil.resourceFactory; + const rootReader = taskUtil.getProject().getRootReader(); + const tsconfig = await rootReader.byPath("/tsconfig.json"); + const content = tsconfig ? await tsconfig.getString() : "no-tsconfig"; + await workspace.write(createResource({ + path: "/tsconfigDigest.js", + string: `export const tsconfig = ${JSON.stringify(content)};\n`, + })); + } + }; }; diff --git a/packages/project/test/fixtures/application.a/task.root-glob.js b/packages/project/test/fixtures/application.a/task.root-glob.js index 931eb65bb73..fe8c3b7abdf 100644 --- a/packages/project/test/fixtures/application.a/task.root-glob.js +++ b/packages/project/test/fixtures/application.a/task.root-glob.js @@ -2,13 +2,18 @@ // the read is recorded against the useGitignore:false root manager. Exercises glob-based root tracking: // adding or removing a file matching the glob must invalidate the task's cache (root indices refresh by // re-globbing, so a newly matching file is detected). -module.exports = async function ({taskUtil, workspace}) { - const {createResource} = taskUtil.resourceFactory; - const rootReader = taskUtil.getProject().getRootReader({useGitignore: false}); - const configs = await rootReader.byGlob("/rootcfg/**/*.json"); - const names = configs.map((r) => r.getPath()).sort(); - await workspace.write(createResource({ - path: "/rootGlobDigest.js", - string: `export const configs = ${JSON.stringify(names)};\n`, - })); +// Specification Version 5.0 descriptor: the default export is a factory that returns the task body. +module.exports = function build() { + return { + task: async ({taskUtil, workspace}) => { + const {createResource} = taskUtil.resourceFactory; + const rootReader = taskUtil.getProject().getRootReader({useGitignore: false}); + const configs = await rootReader.byGlob("/rootcfg/**/*.json"); + const names = configs.map((r) => r.getPath()).sort(); + await workspace.write(createResource({ + path: "/rootGlobDigest.js", + string: `export const configs = ${JSON.stringify(names)};\n`, + })); + } + }; }; diff --git a/packages/project/test/fixtures/application.a/task.step-based.js b/packages/project/test/fixtures/application.a/task.step-based.js index 8881955e72b..b81ec6e59ff 100644 --- a/packages/project/test/fixtures/application.a/task.step-based.js +++ b/packages/project/test/fixtures/application.a/task.step-based.js @@ -8,33 +8,34 @@ const log = Logger.getLogger("builder:tasks:stepBasedTask"); // a `.dep` re-runs its owning invocation and leaves the others served from cache. This is the same reverse // mapping the built-in minify relies on for its `.js.map` -> `.js` relation, expressed by a custom task. module.exports = function build() { - return [{ - name: "procEach", - keys: async ({workspace, taskUtil, options: {projectNamespace}}) => { - // Tag from the enumerator, not from an invocation: the enumerator runs whenever the stage runs, but - // it owns no key, so its tags have no per-key invocation entry to be replayed from. They - // survive a fully cached stage through the stage's own recorded tag operations. - const omittedResources = await workspace.byGlob(`/resources/${projectNamespace}/procEach/*.omitme`); - for (const omittedResource of omittedResources) { - taskUtil.setTag(omittedResource, taskUtil.STANDARD_TAGS.OmitFromBuildResult); - } - const srcResources = await workspace.byGlob(`/resources/${projectNamespace}/procEach/*.src`); - log.verbose(`step-based-task processing ${srcResources.length} source(s)`); - return srcResources; - }, - each: async (srcResource, {workspace, taskUtil}) => { - const srcPath = srcResource.getPath(); - const depPath = srcPath.replace(/\.src$/, ".dep"); - // Read the cross-resource input through the step workspace so it is tracked as this invocation's input. - const depResource = await workspace.byPath(depPath); - const depContent = depResource ? await depResource.getString() : ""; - const srcContent = await srcResource.getString(); - const outResource = taskUtil.resourceFactory.createResource({ - path: srcPath.replace(/\.src$/, ".out"), - string: `${srcContent}\n// dep: ${depContent}\n`, - }); - await workspace.write(outResource); - }, - }]; + return { + steps: [{ + name: "procEach", + keys: async ({workspace, taskUtil, options: {projectNamespace}}) => { + // Tag from the enumerator, not from an invocation: the enumerator runs whenever the stage runs, but + // it owns no key, so its tags have no per-key invocation entry to be replayed from. They + // survive a fully cached stage through the stage's own recorded tag operations. + const omittedResources = await workspace.byGlob(`/resources/${projectNamespace}/procEach/*.omitme`); + for (const omittedResource of omittedResources) { + taskUtil.setTag(omittedResource, taskUtil.STANDARD_TAGS.OmitFromBuildResult); + } + const srcResources = await workspace.byGlob(`/resources/${projectNamespace}/procEach/*.src`); + log.verbose(`step-based-task processing ${srcResources.length} source(s)`); + return srcResources; + }, + each: async (srcResource, {workspace, taskUtil}) => { + const srcPath = srcResource.getPath(); + const depPath = srcPath.replace(/\.src$/, ".dep"); + // Read the cross-resource input through the step workspace so it is tracked as this invocation's input. + const depResource = await workspace.byPath(depPath); + const depContent = depResource ? await depResource.getString() : ""; + const srcContent = await srcResource.getString(); + const outResource = taskUtil.resourceFactory.createResource({ + path: srcPath.replace(/\.src$/, ".out"), + string: `${srcContent}\n// dep: ${depContent}\n`, + }); + await workspace.write(outResource); + }, + }], + }; }; -module.exports.stepBased = true; diff --git a/packages/project/test/lib/build/TaskDefinitions.js b/packages/project/test/lib/build/TaskDefinitions.js index b155aebc455..659bed93378 100644 --- a/packages/project/test/lib/build/TaskDefinitions.js +++ b/packages/project/test/lib/build/TaskDefinitions.js @@ -57,9 +57,9 @@ test.beforeEach(async (t) => { getAllTaskNames: sinon.stub().returns(["replaceVersion", "minify"]), }; - t.context.customTaskSpecVersionLtStub = sinon.stub().returns(false); + t.context.customTaskSpecVersionGteStub = sinon.stub().returns(false); t.context.customTaskSpecVersion = { - lt: t.context.customTaskSpecVersionLtStub + gte: t.context.customTaskSpecVersionGteStub }; t.context.getDetermineBuildSignatureCallbackStub = sinon.stub().resolves(undefined); t.context.customTask = { @@ -412,6 +412,10 @@ test("getBuildSignatures: Standard tasks without determineBuildSignature use opt ["taskA", {options: {foo: "bar"}}], ["taskB", {options: {hello: "world"}}], ]); + // The factory for each task returns a descriptor with no determineBuildSignature, so the + // signature loop falls back to JSON.stringify(taskDef.options). + taskRepository.getTask.withArgs("taskA").resolves({task: (opts) => ({task: async () => {}})}); + taskRepository.getTask.withArgs("taskB").resolves({task: (opts) => ({task: async () => {}})}); const definitionStub = sinon.stub().returns(standardTasks); const MockedTaskDefinitions = await esmock.p("../../../lib/build/TaskDefinitions.js", {}, { "../../../lib/build/definitions/module.js": { @@ -424,8 +428,8 @@ test("getBuildSignatures: Standard tasks without determineBuildSignature use opt const sig = await taskDefinitions.getBuildSignatures(); const expectedKey = [ - JSON.stringify({foo: "bar"}), - JSON.stringify({hello: "world"}) + JSON.stringify({foo: "bar", projectName: "project.b", projectNamespace: "project/b"}), + JSON.stringify({hello: "world", projectName: "project.b", projectNamespace: "project/b"}), ].join("|"); const expectedHash = crypto.createHash("sha256").update(expectedKey).digest("hex"); t.is(sig, expectedHash, "Returns sha256 hash of joined task options"); @@ -435,8 +439,12 @@ test("getBuildSignatures: Standard task determineBuildSignature is awaited and u const {graph, taskUtil, taskRepository} = t.context; const sinon = t.context.sinon; const determineBuildSignatureStub = sinon.stub().resolves("custom-sig"); + // The factory returns a descriptor carrying the determineBuildSignature callback. + taskRepository.getTask.withArgs("taskA").resolves({ + task: (opts) => ({task: async () => {}, determineBuildSignature: determineBuildSignatureStub}) + }); const standardTasks = new Map([ - ["taskA", {determineBuildSignature: determineBuildSignatureStub}], + ["taskA", {options: {}}], ]); const definitionStub = sinon.stub().returns(standardTasks); const MockedTaskDefinitions = await esmock.p("../../../lib/build/TaskDefinitions.js", {}, { @@ -457,8 +465,14 @@ test("getBuildSignatures: Standard task determineBuildSignature is awaited and u test("getBuildSignatures: Standard task determineBuildSignature returning falsy is skipped", async (t) => { const {graph, taskUtil, taskRepository} = t.context; const sinon = t.context.sinon; + // taskA has a determineBuildSignature that returns undefined; taskB has no callback and falls + // back to its options JSON. + taskRepository.getTask.withArgs("taskA").resolves({ + task: (opts) => ({task: async () => {}, determineBuildSignature: sinon.stub().resolves(undefined)}) + }); + taskRepository.getTask.withArgs("taskB").resolves({task: (opts) => ({task: async () => {}})}); const standardTasks = new Map([ - ["taskA", {determineBuildSignature: sinon.stub().resolves(undefined)}], + ["taskA", {options: {}}], ["taskB", {options: {a: 1}}], ]); const definitionStub = sinon.stub().returns(standardTasks); @@ -472,7 +486,9 @@ test("getBuildSignatures: Standard task determineBuildSignature returning falsy const taskDefinitions = new MockedTaskDefinitions(graph, project, taskUtil, taskRepository); const sig = await taskDefinitions.getBuildSignatures(); - const expectedHash = crypto.createHash("sha256").update(JSON.stringify({a: 1})).digest("hex"); + const expectedHash = crypto.createHash("sha256").update( + JSON.stringify({a: 1, projectName: "project.b", projectNamespace: "project/b"}) + ).digest("hex"); t.is(sig, expectedHash, "Falsy signature returned by callback is skipped"); }); @@ -492,8 +508,10 @@ test("getBuildSignatures: Custom task without determineBuildSignature falls back }); test("getBuildSignatures: Custom task determineBuildSignature gets invoked correctly", async (t) => { - const {TaskDefinitions, graph, taskUtil, taskRepository, customTask, customTaskSpecVersion, sinon} = - t.context; + const {TaskDefinitions, graph, taskUtil, taskRepository, customTask, customTaskSpecVersion, + customTaskSpecVersionGteStub, sinon} = t.context; + // Make the task specVersion report >= 5.0 so the new code takes the descriptor path. + customTaskSpecVersionGteStub.withArgs("5.0").returns(true); const determineBuildSignatureCallback = sinon.stub().resolves("my-custom-sig"); customTask.getDetermineBuildSignatureCallback = sinon.stub().resolves(determineBuildSignatureCallback); @@ -525,7 +543,10 @@ test("getBuildSignatures: Custom task determineBuildSignature gets invoked corre }); test("getBuildSignatures: Custom task determineBuildSignature returning falsy is skipped", async (t) => { - const {TaskDefinitions, graph, taskUtil, taskRepository, customTask, sinon} = t.context; + const {TaskDefinitions, graph, taskUtil, taskRepository, customTask, customTaskSpecVersionGteStub, + sinon} = t.context; + // Make the task specVersion report >= 5.0 so the new code takes the descriptor path. + customTaskSpecVersionGteStub.withArgs("5.0").returns(true); const determineBuildSignatureCallback = sinon.stub().resolves(null); customTask.getDetermineBuildSignatureCallback = sinon.stub().resolves(determineBuildSignatureCallback); @@ -541,11 +562,12 @@ test("getBuildSignatures: Custom task determineBuildSignature returning falsy is t.is(sig, expectedHash, "Falsy signature is omitted from hash input"); }); -test("getBuildSignatures: Custom task with specVersion < 5.0 and determineBuildSignature throws", async (t) => { +test("getBuildSignatures: Custom task with specVersion < 5.0 falls back to configuration", async (t) => { const {TaskDefinitions, graph, taskUtil, taskRepository, customTask, sinon} = t.context; + // gte("5.0") returns false: below 5.0, the descriptor path is skipped and the signature falls + // back to JSON.stringify(taskDef.configuration) regardless of any callback. const determineBuildSignatureCallback = sinon.stub().resolves("custom-sig"); customTask.getDetermineBuildSignatureCallback = sinon.stub().resolves(determineBuildSignatureCallback); - t.context.customTaskSpecVersionLtStub.withArgs("5.0").returns(true); const project = getMockProject("module"); project.getCustomTasks = () => [ @@ -553,21 +575,27 @@ test("getBuildSignatures: Custom task with specVersion < 5.0 and determineBuildS ]; const taskDefinitions = new TaskDefinitions(graph, project, taskUtil, taskRepository); - const err = await t.throwsAsync(taskDefinitions.getBuildSignatures()); - t.is(err.message, - `Custom task myTask of project project.b uses a determineBuildSignature callback, ` + - `which is not supported for tasks with specVersion < 5.0. ` + - `Please update the task's specVersion to at least 5.0.`, - "Threw with expected error message"); + const sig = await taskDefinitions.getBuildSignatures(); + const expectedHash = crypto.createHash("sha256").update( + JSON.stringify("configuration") + ).digest("hex"); + t.is(sig, expectedHash, "Below specVersion 5.0, signature falls back to configuration JSON"); t.is(determineBuildSignatureCallback.callCount, 0, - "determineBuildSignature callback was not invoked"); + "determineBuildSignature callback is not invoked below specVersion 5.0"); }); test("getBuildSignatures: Combines standard and custom task signatures", async (t) => { - const {graph, taskUtil, taskRepository, customTask, sinon} = t.context; + const {graph, taskUtil, taskRepository, customTask, customTaskSpecVersionGteStub, sinon} = t.context; + // taskA: no determineBuildSignature → falls back to options JSON. + // taskB: descriptor carries determineBuildSignature returning "std-sig". + const stdSigStub = sinon.stub().resolves("std-sig"); + taskRepository.getTask.withArgs("taskA").resolves({task: (opts) => ({task: async () => {}})}); + taskRepository.getTask.withArgs("taskB").resolves({ + task: (opts) => ({task: async () => {}, determineBuildSignature: stdSigStub}) + }); const standardTasks = new Map([ ["taskA", {options: {a: 1}}], - ["taskB", {determineBuildSignature: sinon.stub().resolves("std-sig")}], + ["taskB", {options: {}}], ]); const definitionStub = sinon.stub().returns(standardTasks); const MockedTaskDefinitions = await esmock.p("../../../lib/build/TaskDefinitions.js", {}, { @@ -576,6 +604,8 @@ test("getBuildSignatures: Combines standard and custom task signatures", async ( } }); + // Make the custom task specVersion >= 5.0 so its determineBuildSignature is honored. + customTaskSpecVersionGteStub.withArgs("5.0").returns(true); const determineBuildSignatureCallback = sinon.stub().resolves("custom-sig"); customTask.getDetermineBuildSignatureCallback = sinon.stub().resolves(determineBuildSignatureCallback); @@ -587,7 +617,7 @@ test("getBuildSignatures: Combines standard and custom task signatures", async ( const sig = await taskDefinitions.getBuildSignatures(); const expectedKey = [ - JSON.stringify({a: 1}), + JSON.stringify({a: 1, projectName: "project.b", projectNamespace: "project/b"}), "std-sig", "custom-sig" ].join("|"); diff --git a/packages/project/test/lib/build/TaskRunner.js b/packages/project/test/lib/build/TaskRunner.js index 1b364a71f3b..1771d3e7967 100644 --- a/packages/project/test/lib/build/TaskRunner.js +++ b/packages/project/test/lib/build/TaskRunner.js @@ -2,6 +2,7 @@ import test from "ava"; import sinonGlobal from "sinon"; import esmock from "esmock"; import {setLogLevel} from "@ui5/logger"; +import {validateSteps} from "../../../lib/build/helpers/StepRunner.js"; setLogLevel("perf"); function noop() {} @@ -29,6 +30,30 @@ const buildConfig = { excludedTasks: [] }; +/** + * Returns a getStandardDescriptor implementation for mock taskDefinitions objects used in step-based tests. + * + * Step-based standard task mocks store the step factory in taskDef.taskFunction instead of in a real + * taskRepository. This helper calls the factory with the completed options, validates the steps, and returns + * the descriptor so _addTask treats the task as step-based. + * + * For tasks with skip or no taskFunction, returns undefined so _addTask falls back to taskDef properties. + * + * @returns {Function} Async getStandardDescriptor implementation + */ +function makeStepBasedGetStandardDescriptor() { + return async function getStandardDescriptor(taskName, taskDef) { + if (taskDef.skip || !taskDef.taskFunction) { + return undefined; + } + // The mock stores the step factory in taskFunction. Call it with the completed options. + const steps = taskDef.taskFunction(taskDef.options ?? {}); + // validateSteps throws if the factory returned a non-array or a malformed step. + validateSteps(steps, {taskName}); + return {steps, requiresDependencies: !!taskDef.requiresDependencies}; + }; +} + function getMockProject(type) { return { getName: () => "project.b", @@ -79,6 +104,7 @@ function getMockProject(type) { isFrameworkProject: () => false, sealWorkspace: noop, createNewWorkspaceVersion: noop, + getJsdocExcludes: emptyarray, }; } @@ -95,9 +121,9 @@ test.beforeEach(async (t) => { t.context.taskUtil.getInterface.returns(t.context.taskUtil); t.context.taskRepository = { - getTask: sinon.stub().callsFake(async (taskName) => { - throw new Error(`taskRepository: Unknown Task ${taskName}`); - }), + getTask: sinon.stub().callsFake((taskName) => ({ + task: (opts) => ({task: async () => {}}) + })), getAllTaskNames: sinon.stub().returns(["replaceVersion"]), getRemovedTaskNames: sinon.stub().returns(["removedTask"]), }; @@ -111,6 +137,8 @@ test.beforeEach(async (t) => { gte: t.context.customTaskSpecVersionGteStub }; }, + getTask: async () => sinon.stub(), + getDescriptor: sinon.stub().resolves({task: async () => {}}), getRequiredDependenciesCallback: t.context.getRequiredDependenciesCallbackStub, getStepBased: async () => false, }; @@ -703,10 +731,10 @@ test("Custom task is called correctly", async (t) => { await taskRunner._tasks["myTask"].task(); t.is(specVersionGteStub.callCount, 3, "SpecificationVersion#gte got called three times"); - t.is(specVersionGteStub.getCall(0).args[0], "3.0", + t.is(specVersionGteStub.getCall(0).args[0], "5.0", "SpecificationVersion#gte got called with correct arguments on first call"); - t.is(specVersionGteStub.getCall(1).args[0], "5.0", - "SpecificationVersion#gte got called with correct arguments on second call (step-based opt-in)"); + t.is(specVersionGteStub.getCall(1).args[0], "3.0", + "SpecificationVersion#gte got called with correct arguments on second call (no-callback branch)"); t.is(specVersionGteStub.getCall(2).args[0], "3.0", "SpecificationVersion#gte got called with correct arguments on third call (task execution)"); @@ -763,10 +791,10 @@ test("Custom task with legacy spec version", async (t) => { await taskRunner._tasks["myTask"].task(); t.is(specVersionGteStub.callCount, 3, "SpecificationVersion#gte got called three times"); - t.is(specVersionGteStub.getCall(0).args[0], "3.0", + t.is(specVersionGteStub.getCall(0).args[0], "5.0", "SpecificationVersion#gte got called with correct arguments on first call"); - t.is(specVersionGteStub.getCall(1).args[0], "5.0", - "SpecificationVersion#gte got called with correct arguments on second call (step-based opt-in)"); + t.is(specVersionGteStub.getCall(1).args[0], "3.0", + "SpecificationVersion#gte got called with correct arguments on second call (no-callback branch)"); t.is(specVersionGteStub.getCall(2).args[0], "3.0", "SpecificationVersion#gte got called with correct arguments on third call (task execution)"); @@ -816,35 +844,35 @@ test("Custom task with legacy spec version and requiredDependenciesCallback", as t.truthy(taskRunner._tasks["myTask"], "Custom tasks has been added to task map"); t.deepEqual(taskRunner._tasks["myTask"].requiredDependencies, new Set(["dep.b"]), - "Custom tasks requires all dependencies by default"); - - t.is(requiredDependenciesCallbackStub.callCount, 1, "requiredDependenciesCallback got called once"); - t.deepEqual(requiredDependenciesCallbackStub.getCall(0).args[0], { - availableDependencies: new Set(["dep.a", "dep.b"]), - options: { + "The callback selects the required dependency"); + t.is(getRequiredDependenciesCallbackStub.callCount, 1, + "getRequiredDependenciesCallback is invoked below Specification Version 5.0"); + t.deepEqual(getRequiredDependenciesCallbackStub.firstCall.args, [ + "myTask", + { projectName: "project.b", projectNamespace: "project/b", configuration: "configuration", taskName: "myTask" } - }, "requiredDependenciesCallback got called with expected arguments"); + ], "getRequiredDependenciesCallback receives the runtime task name and options"); const createDependencyReaderStub = sinon.stub(taskRunner, "getDependenciesReader") .resolves({getName: () => "dependencies"}); await taskRunner._tasks["myTask"].task(); t.is(specVersionGteStub.callCount, 3, "SpecificationVersion#gte got called three times"); - t.is(specVersionGteStub.getCall(0).args[0], "3.0", - "SpecificationVersion#gte got called with correct arguments on first call"); - t.is(specVersionGteStub.getCall(1).args[0], "5.0", - "SpecificationVersion#gte got called with correct arguments on second call (step-based opt-in)"); + t.is(specVersionGteStub.getCall(0).args[0], "5.0", + "SpecificationVersion#gte first call checks 5.0"); + t.is(specVersionGteStub.getCall(1).args[0], "3.0", + "SpecificationVersion#gte second call checks 3.0 for callback parameters"); t.is(specVersionGteStub.getCall(2).args[0], "3.0", - "SpecificationVersion#gte got called with correct arguments on third call (task execution)"); + "SpecificationVersion#gte third call checks 3.0 (task execution)"); t.is(createDependencyReaderStub.callCount, 1, "getDependenciesReader got called once"); t.deepEqual(createDependencyReaderStub.getCall(0).args[0], new Set(["dep.b"]), - "getDependenciesReader got called with correct arguments"); + "The dependency reader contains the callback selection"); t.is(taskStub.callCount, 1, "Task got called once"); t.is(taskStub.getCall(0).args.length, 1, "Task got called with one argument"); @@ -863,17 +891,18 @@ test("Custom task with legacy spec version and requiredDependenciesCallback", as test("Custom task with specVersion 3.0", async (t) => { const {sinon, graph, taskUtil} = t.context; const taskStub = sinon.stub(); - const specVersionGteStub = sinon.stub().returns(true); + // gte("3.0") = true, gte("5.0") = false: V3 range, no descriptor. + const specVersionGteStub = sinon.stub().callsFake((v) => v === "3.0"); const mockSpecVersion = { toString: () => "3.0", gte: specVersionGteStub }; const requiredDependenciesCallbackStub = sinon.stub().resolves(new Set(["dep.b"])); - const getRequiredDependenciesCallbackStub = sinon.stub() - .resolves(requiredDependenciesCallbackStub); + const getRequiredDependenciesCallbackStub = sinon.stub().resolves(requiredDependenciesCallbackStub); graph.getExtension.returns({ + getName: () => "custom task name", getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, @@ -888,49 +917,23 @@ test("Custom task with specVersion 3.0", async (t) => { const taskRunner = createTaskRunner(t, project); await taskRunner._initTasks(); - t.is(requiredDependenciesCallbackStub.callCount, 1, "requiredDependenciesCallback got called once"); - const requiredDependenciesCallbackArgs = requiredDependenciesCallbackStub.getCall(0).args[0]; - - t.is(typeof requiredDependenciesCallbackArgs.getProject, "function", "getProject function provided"); - requiredDependenciesCallbackArgs.getProject("some.project"); - t.is(taskUtil.getProject.callCount, 1, "taskUtil.getProject got called once"); - t.is(taskUtil.getProject.getCall(0).args[0], "some.project", - "taskUtil.getProject got called with expected arguments"); - requiredDependenciesCallbackArgs.getProject = "getProject function"; - - t.is(typeof requiredDependenciesCallbackArgs.getDependencies, "function", "getDependencies function provided"); - requiredDependenciesCallbackArgs.getDependencies("some.project"); - t.is(taskUtil.getDependencies.callCount, 2, "taskUtil.getDependencies got called twice"); - t.is(taskUtil.getDependencies.getCall(1).args[0], "some.project", - "taskUtil.getDependencies got called with expected arguments"); - requiredDependenciesCallbackArgs.getDependencies = "getDependencies function"; - - t.deepEqual(requiredDependenciesCallbackArgs, { - availableDependencies: new Set(["dep.a", "dep.b"]), - getProject: "getProject function", - getDependencies: "getDependencies function", - options: { - projectName: "project.b", - projectNamespace: "project/b", - taskName: "myTask", - configuration: "configuration", - } - }, "requiredDependenciesCallback got called with expected arguments"); - t.truthy(taskRunner._tasks["myTask"], "Custom tasks has been added to task map"); t.deepEqual(taskRunner._tasks["myTask"].requiredDependencies, new Set(["dep.b"]), - "Custom tasks requires all dependencies by default"); + "The legacy callback selects the required dependency"); + t.is(getRequiredDependenciesCallbackStub.callCount, 1, + "getRequiredDependenciesCallback is invoked below Specification Version 5.0"); + const createDependencyReaderStub = sinon.stub(taskRunner, "getDependenciesReader") .resolves({getName: () => "dependencies"}); await taskRunner._tasks["myTask"].task(); t.is(specVersionGteStub.callCount, 3, "SpecificationVersion#gte got called three times"); - t.is(specVersionGteStub.getCall(0).args[0], "3.0", - "SpecificationVersion#gte got called with correct arguments on first call"); - t.is(specVersionGteStub.getCall(1).args[0], "5.0", - "SpecificationVersion#gte got called with correct arguments on second call (step-based opt-in)"); + t.is(specVersionGteStub.getCall(0).args[0], "5.0", + "SpecificationVersion#gte first call checks 5.0 (isV5)"); + t.is(specVersionGteStub.getCall(1).args[0], "3.0", + "SpecificationVersion#gte second call checks 3.0 for callback parameters"); t.is(specVersionGteStub.getCall(2).args[0], "3.0", - "SpecificationVersion#gte got called with correct arguments on third call (task execution)"); + "SpecificationVersion#gte third call checks 3.0 (task execution)"); t.is(taskUtil.getInterface.callCount, 2, "taskUtil#getInterface got called twice"); t.is(taskUtil.getInterface.getCall(0).args[0], mockSpecVersion, @@ -939,15 +942,13 @@ test("Custom task with specVersion 3.0", async (t) => { "taskUtil#getInterface got called with correct argument on second call"); t.is(createDependencyReaderStub.callCount, 1, "getDependenciesReader got called once"); - t.deepEqual(createDependencyReaderStub.getCall(0).args[0], - new Set(["dep.b"]), - "getDependenciesReader got called with correct arguments"); + t.deepEqual(createDependencyReaderStub.getCall(0).args[0], new Set(["dep.b"]), + "The dependency reader contains the callback selection"); t.is(taskStub.callCount, 1, "Task got called once"); t.is(taskStub.getCall(0).args.length, 1, "Task got called with one argument"); const taskArgs = taskStub.getCall(0).args[0]; t.is(taskArgs.workspace.constructor.name, "MonitoredReader", "workspace is MonitoredReader"); - t.is(taskArgs.dependencies.constructor.name, "MonitoredReader", "dependencies is MonitoredReader"); t.is(taskArgs.log, "group logger", "log is correct"); assertMonitoredTaskUtil(t, taskArgs.taskUtil); t.is(taskArgs.options.projectName, "project.b", "projectName is correct"); @@ -959,19 +960,17 @@ test("Custom task with specVersion 3.0", async (t) => { test("Custom task with specVersion 3.0 and no requiredDependenciesCallback", async (t) => { const {sinon, graph, taskUtil} = t.context; const taskStub = sinon.stub(); - const specVersionGteStub = sinon.stub().returns(true); + const specVersionGteStub = sinon.stub().callsFake((v) => v === "3.0"); const mockSpecVersion = { toString: () => "3.0", gte: specVersionGteStub }; - const getRequiredDependenciesCallbackStub = sinon.stub().resolves(undefined); - graph.getExtension.returns({ getName: () => "custom task name", getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getRequiredDependenciesCallback: sinon.stub().resolves(undefined), getStepBased: async () => false, }); @@ -984,6 +983,7 @@ test("Custom task with specVersion 3.0 and no requiredDependenciesCallback", asy await taskRunner._initTasks(); t.truthy(taskRunner._tasks["myTask"], "Custom tasks has been added to task map"); + // A task at Specification Version 3.0 or later gets no reader when the legacy callback is absent. t.deepEqual(taskRunner._tasks["myTask"].requiredDependencies, new Set(), "Custom tasks requires no dependencies by default"); const createDependencyReaderStub = sinon.stub(taskRunner, "getDependenciesReader") @@ -991,12 +991,12 @@ test("Custom task with specVersion 3.0 and no requiredDependenciesCallback", asy await taskRunner._tasks["myTask"].task(); t.is(specVersionGteStub.callCount, 3, "SpecificationVersion#gte got called three times"); - t.is(specVersionGteStub.getCall(0).args[0], "3.0", - "SpecificationVersion#gte got called with correct arguments on first call"); - t.is(specVersionGteStub.getCall(1).args[0], "5.0", - "SpecificationVersion#gte got called with correct arguments on second call (step-based opt-in)"); + t.is(specVersionGteStub.getCall(0).args[0], "5.0", + "SpecificationVersion#gte first call checks 5.0 (isV5)"); + t.is(specVersionGteStub.getCall(1).args[0], "3.0", + "SpecificationVersion#gte second call checks 3.0 (no-callback branch)"); t.is(specVersionGteStub.getCall(2).args[0], "3.0", - "SpecificationVersion#gte got called with correct arguments on third call (task execution)"); + "SpecificationVersion#gte third call checks 3.0 (task execution)"); t.is(taskUtil.getInterface.callCount, 1, "taskUtil#getInterface got called once"); t.is(taskUtil.getInterface.getCall(0).args[0], mockSpecVersion, @@ -1016,12 +1016,59 @@ test("Custom task with specVersion 3.0 and no requiredDependenciesCallback", asy t.is(taskArgs.options.configuration, "configuration", "configuration is correct"); }); +test("Custom task descriptor requires all dependencies without a callback", async (t) => { + const {sinon, graph} = t.context; + const taskStub = sinon.stub(); + const mockSpecVersion = { + toString: () => "5.0", + gte: sinon.stub().returns(true) + }; + const getRequiredDependenciesCallbackStub = sinon.stub().resolves(undefined); + + graph.getExtension.returns({ + getName: () => "custom task name", + getTask: () => taskStub, + getSpecVersion: () => mockSpecVersion, + getDescriptor: sinon.stub().resolves({ + task: taskStub, + requiresDependencies: true + }), + getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getStepBased: async () => false, + }); + + const project = getMockProject("module"); + project.getCustomTasks = () => [ + {name: "myTask", configuration: "configuration"} + ]; + + const taskRunner = createTaskRunner(t, project); + await taskRunner._initTasks(); + + t.deepEqual(taskRunner._tasks.myTask.requiredDependencies, new Set(["dep.a", "dep.b"]), + "The descriptor selects all direct dependencies"); + t.deepEqual(await taskRunner.getRequiredDependencies(), new Set(["dep.a", "dep.b"]), + "The build includes all direct dependencies"); + + const createDependencyReaderStub = sinon.stub(taskRunner, "getDependenciesReader") + .resolves({getName: () => "dependencies"}); + await taskRunner._tasks.myTask.task(); + + t.is(createDependencyReaderStub.callCount, 1, "getDependenciesReader got called once"); + t.deepEqual(createDependencyReaderStub.firstCall.firstArg, new Set(["dep.a", "dep.b"]), + "The dependency reader contains all direct dependencies"); + t.is(taskStub.callCount, 1, "Task got called once"); + t.is(taskStub.firstCall.firstArg.dependencies.constructor.name, "MonitoredReader", + "The task receives the dependencies reader"); +}); + test("Multiple custom tasks with same name are called correctly", async (t) => { const {sinon, graph, taskUtil, projectBuildLogger} = t.context; const taskStubA = sinon.stub(); const taskStubB = sinon.stub(); const taskStubC = sinon.stub(); const taskStubD = sinon.stub(); + // Tasks A and B are below V5 (legacy body path, gte all false). const mockSpecVersionA = { toString: () => "2.5", gte: () => false @@ -1030,6 +1077,8 @@ test("Multiple custom tasks with same name are called correctly", async (t) => { toString: () => "2.6", gte: () => false }; + // Tasks C and D are V5 (gte all true, descriptor path). Their descriptors carry no + // determineRequiredDependencies, so both get no dependencies and no reader. const mockSpecVersionC = { toString: () => "3.0", gte: () => true @@ -1038,39 +1087,37 @@ test("Multiple custom tasks with same name are called correctly", async (t) => { toString: () => "3.0", gte: () => true }; - const requiredDependenciesCallbackStubA = sinon.stub().resolves(new Set(["dep.b"])); - const requiredDependenciesCallbackStubD = sinon.stub().resolves(new Set(["dep.a"])); - const getRequiredDependenciesCallbackStub = sinon.stub() - .resolves(null) - .onCall(0).resolves(requiredDependenciesCallbackStubA) - .onCall(3).resolves(requiredDependenciesCallbackStubD); graph.getExtension.onFirstCall().returns({ getName: () => "Task Name A", getTask: () => taskStubA, getSpecVersion: () => mockSpecVersionA, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getRequiredDependenciesCallback: sinon.stub().resolves(undefined), getStepBased: async () => false, }); graph.getExtension.onSecondCall().returns({ getName: () => "Task Name B", getTask: () => taskStubB, getSpecVersion: () => mockSpecVersionB, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getRequiredDependenciesCallback: sinon.stub().resolves(undefined), getStepBased: async () => false, }); graph.getExtension.onThirdCall().returns({ getName: () => "Task Name C", getTask: () => taskStubC, getSpecVersion: () => mockSpecVersionC, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + // V5 descriptor with no determineRequiredDependencies → no deps. + getDescriptor: sinon.stub().resolves({task: taskStubC}), + getRequiredDependenciesCallback: sinon.stub().resolves(undefined), getStepBased: async () => false, }); graph.getExtension.onCall(3).returns({ getName: () => "Task Name D", getTask: () => taskStubD, getSpecVersion: () => mockSpecVersionD, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + // V5 descriptor with no determineRequiredDependencies → no deps. + getDescriptor: sinon.stub().resolves({task: taskStubD}), + getRequiredDependenciesCallback: sinon.stub().resolves(undefined), getStepBased: async () => false, }); const project = getMockProject("module"); @@ -1083,29 +1130,20 @@ test("Multiple custom tasks with same name are called correctly", async (t) => { const taskRunner = createTaskRunner(t, project); await taskRunner._initTasks(); - // getRequiredDependenciesCallbackStub is only called for specVersion >= 3.0 - t.is(getRequiredDependenciesCallbackStub.callCount, 4, - "getRequiredDependenciesCallback stub was called for all tasks"); - t.is(requiredDependenciesCallbackStubA.callCount, 1, - "requiredDependenciesCallback stub for task A was called once"); - t.is(requiredDependenciesCallbackStubD.callCount, 1, - "requiredDependenciesCallback stub for Task D stub was called once"); - t.truthy(taskRunner._tasks["myTask"], "Custom tasks A has been added to task map"); t.truthy(taskRunner._tasks["myTask--2"], "Custom tasks B has been added to task map"); t.truthy(taskRunner._tasks["myTask--3"], "Custom tasks C has been added to task map"); t.truthy(taskRunner._tasks["myTask--4"], "Custom tasks D has been added to task map"); - t.deepEqual(taskRunner._tasks["myTask"].requiredDependencies, new Set(["dep.b"]), - "Custom tasks with legacy specVersion and requiredDependenciesCallback defines " + - "required dependencies"); + // A, B (legacy, gte all false → < 3.0 path): all deps. + t.deepEqual(taskRunner._tasks["myTask"].requiredDependencies, new Set(["dep.a", "dep.b"]), + "Legacy specVersion task A gets all dependencies by default"); t.deepEqual(taskRunner._tasks["myTask--2"].requiredDependencies, new Set(["dep.a", "dep.b"]), - "Custom tasks with legacy specVersion require all dependencies by default"); + "Legacy specVersion task B gets all dependencies by default"); + // C, D (V5, descriptor with no callback → gte("3.0")=true → no reader): empty set. t.deepEqual(taskRunner._tasks["myTask--3"].requiredDependencies, new Set([]), - "Custom tasks with specVersion 3.0 but no requiredDependenciesCallback " + - "require no dependencies by default"); - t.deepEqual(taskRunner._tasks["myTask--4"].requiredDependencies, new Set(["dep.a"]), - "Custom tasks with specVersion 3.0 and requiredDependenciesCallback defines " + - "required dependencies"); + "V5 task C with no determineRequiredDependencies gets no dependencies"); + t.deepEqual(taskRunner._tasks["myTask--4"].requiredDependencies, new Set([]), + "V5 task D with no determineRequiredDependencies gets no dependencies"); // "Last in is the first out" t.deepEqual(taskRunner._taskExecutionOrder, [ @@ -1142,31 +1180,29 @@ test("Multiple custom tasks with same name are called correctly", async (t) => { "myTask--2", ], "ProjectBuildLogger#endTask got called with expected arguments"); - t.is(taskUtil.getInterface.callCount, 5, "taskUtil#getInterface got called three times"); - t.is(taskUtil.getInterface.getCall(0).args[0], mockSpecVersionD, - "taskUtil#getInterface got called with correct argument on first call"); - t.is(taskUtil.getInterface.getCall(1).args[0], mockSpecVersionA, - "taskUtil#getInterface got called with correct argument on second call"); - t.is(taskUtil.getInterface.getCall(2).args[0], mockSpecVersionD, - "taskUtil#getInterface got called with correct argument on third call"); - t.is(taskUtil.getInterface.getCall(3).args[0], mockSpecVersionC, - "taskUtil#getInterface got called with correct argument on fourth call"); - t.is(taskUtil.getInterface.getCall(4).args[0], mockSpecVersionB, - "taskUtil#getInterface got called with correct argument on fifth call"); - - t.is(createDependencyReaderStub.callCount, 4, "getDependenciesReader got called four times"); + // One getInterface call per task execution (four tasks run). + t.is(taskUtil.getInterface.callCount, 4, "taskUtil#getInterface got called four times"); + t.is(taskUtil.getInterface.getCall(0).args[0], mockSpecVersionA, + "taskUtil#getInterface got called with correct argument on first call (Task A)"); + t.is(taskUtil.getInterface.getCall(1).args[0], mockSpecVersionD, + "taskUtil#getInterface got called with correct argument on second call (Task D)"); + t.is(taskUtil.getInterface.getCall(2).args[0], mockSpecVersionC, + "taskUtil#getInterface got called with correct argument on third call (Task C)"); + t.is(taskUtil.getInterface.getCall(3).args[0], mockSpecVersionB, + "taskUtil#getInterface got called with correct argument on fourth call (Task B)"); + + // Calls: (1) runTasks init pass with all direct deps, (2) Task A all deps, (3) Task B all deps. + // Tasks C and D have no reader (provideDependenciesReader = false). + t.is(createDependencyReaderStub.callCount, 3, "getDependenciesReader got called three times"); t.deepEqual(createDependencyReaderStub.getCall(0).args[0], new Set(["dep.a", "dep.b"]), "getDependenciesReader got called with correct arguments on first call (runTasks init)"); t.deepEqual(createDependencyReaderStub.getCall(1).args[0], - new Set(["dep.b"]), + new Set(["dep.a", "dep.b"]), "getDependenciesReader got called with correct arguments on second call (Task A)"); t.deepEqual(createDependencyReaderStub.getCall(2).args[0], - new Set(["dep.a"]), - "getDependenciesReader got called with correct arguments on third call (Task D)"); - t.deepEqual(createDependencyReaderStub.getCall(3).args[0], new Set(["dep.a", "dep.b"]), - "getDependenciesReader got called with correct arguments on fourth call (Task B)"); + "getDependenciesReader got called with correct arguments on third call (Task B)"); t.is(taskStubA.callCount, 1, "Task A got called once"); t.is(taskStubA.getCall(0).args.length, 1, "Task A got called with one argument"); @@ -1201,7 +1237,6 @@ test("Multiple custom tasks with same name are called correctly", async (t) => { t.is(taskStubD.getCall(0).args.length, 1, "Task D got called with one argument"); const taskDArgs = taskStubD.getCall(0).args[0]; t.is(taskDArgs.workspace.constructor.name, "MonitoredReader", "workspace is MonitoredReader"); - t.is(taskDArgs.dependencies.constructor.name, "MonitoredReader", "dependencies is MonitoredReader"); t.is(taskDArgs.log, "group logger", "log is correct"); assertMonitoredTaskUtil(t, taskDArgs.taskUtil); t.is(taskDArgs.options.projectName, "project.b", "projectName is correct"); @@ -1219,15 +1254,19 @@ test("Custom task: requiredDependenciesCallback returns unknown dependency", asy gte: specVersionGteStub }; + // At specVersion 5.0+, determineRequiredDependencies lives on the descriptor. const requiredDependenciesCallbackStub = sinon.stub().resolves(new Set(["dep.b", "other.dep"])); - const getRequiredDependenciesCallbackStub = sinon.stub() - .resolves(requiredDependenciesCallbackStub); + const getDescriptorStub = sinon.stub().resolves({ + task: taskStub, + determineRequiredDependencies: requiredDependenciesCallbackStub + }); graph.getExtension.returns({ getName: () => "custom.task.a", getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getDescriptor: getDescriptorStub, + getRequiredDependenciesCallback: sinon.stub().resolves(requiredDependenciesCallbackStub), getStepBased: async () => false, }); @@ -1255,15 +1294,19 @@ test("Custom task: requiredDependenciesCallback returns Array instead of Set", a gte: specVersionGteStub }; + // At specVersion 5.0+, determineRequiredDependencies lives on the descriptor. const requiredDependenciesCallbackStub = sinon.stub().resolves(["dep.b"]); - const getRequiredDependenciesCallbackStub = sinon.stub() - .resolves(requiredDependenciesCallbackStub); + const getDescriptorStub = sinon.stub().resolves({ + task: taskStub, + determineRequiredDependencies: requiredDependenciesCallbackStub + }); graph.getExtension.returns({ getName: () => "custom.task.a", getTask: () => taskStub, getSpecVersion: () => mockSpecVersion, - getRequiredDependenciesCallback: getRequiredDependenciesCallbackStub, + getDescriptor: getDescriptorStub, + getRequiredDependenciesCallback: sinon.stub().resolves(requiredDependenciesCallbackStub), getStepBased: async () => false, }); @@ -1292,8 +1335,9 @@ test("Custom task attached to a disabled task", async (t) => { // Standard tasks are step-based factories; the stub returns an empty step list so the step runner is // a no-op and this test only exercises task ordering and the custom task's execution. - taskRepository.getTask = sinon.stub().returns({task: sinon.stub().returns([])}); - customTask.getTask = () => customTaskFnStub; + taskRepository.getTask = sinon.stub().returns({task: sinon.stub().returns({steps: []})}); + // For V5 custom tasks the task body comes from descriptor.task, not getTask(). + customTask.getDescriptor = sinon.stub().resolves({task: customTaskFnStub}); const taskRunner = createTaskRunner(t, project); @@ -1325,15 +1369,16 @@ test.serial("_addTask", async (t) => { const {sinon, taskRepository} = t.context; const taskStub = sinon.stub(); - taskRepository.getTask.withArgs("standardTask").resolves({ - task: taskStub + // The factory wraps the task stub as the single-stage body in the descriptor. + taskRepository.getTask.withArgs("standardTask").returns({ + task: (opts) => ({task: taskStub}) }); const project = getMockProject("module"); const taskRunner = createTaskRunner(t, project); await taskRunner._initTasks(); - taskRunner._addTask("standardTask"); + await taskRunner._addTask("standardTask"); t.truthy(taskRunner._tasks["standardTask"], "Task has been added to task map"); t.deepEqual(taskRunner._tasks["standardTask"].requiredDependencies, new Set(), @@ -1365,7 +1410,7 @@ test.serial("_addTask with options", async (t) => { const taskRunner = createTaskRunner(t, project); await taskRunner._initTasks(); - taskRunner._addTask("standardTask", { + await taskRunner._addTask("standardTask", { requiresDependencies: true, options: { myTaskOption: "cat", @@ -1433,7 +1478,7 @@ test.serial("Folds taskUtil project-reader reads into the recorded resource requ const taskRunner = createTaskRunner(t, project); await taskRunner._initTasks(); - taskRunner._addTask("standardTask", {requiresDependencies: true, taskFunction: taskStub}); + await taskRunner._addTask("standardTask", {requiresDependencies: true, taskFunction: taskStub}); // Warm the cached dependencies reader (normally done by runTasks) await taskRunner.getDependenciesReader(new Set(["dep.a", "dep.b"]), true); @@ -1457,15 +1502,13 @@ test("_addTask: Duplicate task", async (t) => { const taskRunner = createTaskRunner(t, project); await taskRunner._initTasks(); - taskRunner._addTask("standardTask", { + await taskRunner._addTask("standardTask", { taskFunction: () => {} }); - const err = t.throws(() => { - taskRunner._addTask("standardTask", { - taskFunction: () => {} - }); - }); + const err = await t.throwsAsync(taskRunner._addTask("standardTask", { + taskFunction: () => {} + })); t.is(err.message, "Failed to add duplicate task standardTask for project project.b", "Threw with expected error message"); }); @@ -1476,11 +1519,9 @@ test("_addTask: Task already added to execution order", async (t) => { await taskRunner._initTasks(); taskRunner._taskExecutionOrder.push("standardTask"); - const err = t.throws(() => { - taskRunner._addTask("standardTask", { - taskFunction: () => {} - }); - }); + const err = await t.throwsAsync(taskRunner._addTask("standardTask", { + taskFunction: () => {} + })); t.is(err.message, "Failed to add task standardTask for project project.b. It has already been scheduled for execution", "Threw with expected error message"); @@ -1502,6 +1543,13 @@ test("getRequiredDependencies: Default application", async (t) => { // By default, builds of this project type always contain the "generateVersionInfo" task, // which requires dependencies to be built. + const {taskRepository} = t.context; + // generateVersionInfo's factory returns requiresDependencies: true in the real module. + // Override the default stub so _addTask reads the correct flag from the descriptor. + taskRepository.getTask.withArgs("generateVersionInfo").returns({ + task: () => ({task: async () => {}, requiresDependencies: true}) + }); + const project = getMockProject("application"); project.getBundles = () => []; const taskRunner = createTaskRunner(t, project); @@ -1520,6 +1568,16 @@ test("getRequiredDependencies: Default component", async (t) => { }); test("getRequiredDependencies: Default library", async (t) => { + const {taskRepository} = t.context; + // Several library tasks return requiresDependencies: true from their descriptors. + // Override the stubs so _addTask reads the correct flag. + const requiresDepFactory = {task: () => ({task: async () => {}, requiresDependencies: true})}; + taskRepository.getTask.withArgs("generateJsdoc").returns(requiresDepFactory); + taskRepository.getTask.withArgs("executeJsdocSdkTransformation").returns(requiresDepFactory); + taskRepository.getTask.withArgs("generateLibraryPreload").returns(requiresDepFactory); + taskRepository.getTask.withArgs("buildThemes").returns(requiresDepFactory); + taskRepository.getTask.withArgs("generateCachebusterInfo").returns(requiresDepFactory); + const project = getMockProject("library"); project.getBundles = () => []; const taskRunner = createTaskRunner(t, project); @@ -1528,6 +1586,13 @@ test("getRequiredDependencies: Default library", async (t) => { }); test("getRequiredDependencies: Default theme-library", async (t) => { + const {taskRepository} = t.context; + // buildThemes and generateThemeDesignerResources return requiresDependencies: true from their descriptors. + const requiresDepFactory = {task: () => ({task: async () => {}, requiresDependencies: true})}; + taskRepository.getTask.withArgs("buildThemes").returns(requiresDepFactory); + taskRepository.getTask.withArgs("generateThemeDesignerResources").returns(requiresDepFactory); + taskRepository.getTask.withArgs("generateCachebusterInfo").returns(requiresDepFactory); + const project = getMockProject("theme-library"); const taskRunner = createTaskRunner(t, project); @@ -1681,6 +1746,7 @@ test("Step-based task: a per-step input change re-runs only that step; a restore ]), customTasks: new Map(), }), + getStandardDescriptor: makeStepBasedGetStandardDescriptor(), }; let capturedInvocationData; @@ -1737,6 +1803,10 @@ function createCustomTaskExtension(sinon, {taskFunction, gte, stepBased = false} getSpecVersion: () => ({gte}), getTask: async () => taskFunction, getRequiredDependenciesCallback: sinon.stub().resolves(undefined), + // V5 path: descriptor carries either steps (step-based) or task (legacy body). + getDescriptor: sinon.stub().resolves( + stepBased ? {steps: taskFunction()} : {task: taskFunction} + ), getStepBased: async () => stepBased, }; } @@ -1773,6 +1843,7 @@ test("Step-based task: a full stage-cache hit re-runs a read-free consumer when ]), customTasks: new Map(), }), + getStandardDescriptor: makeStepBasedGetStandardDescriptor(), }; // Per-stage invocation data, keyed by stage id so 'scan' and 'use' carry their own data forward. @@ -1851,6 +1922,7 @@ test("Step-based task: the factory runs once per build and its steps are reused ]), customTasks: new Map(), }), + getStandardDescriptor: makeStepBasedGetStandardDescriptor(), }; // Only stages that setTasks created can be prepared, as in ProjectResources#useStage. @@ -1906,6 +1978,7 @@ test("Step-based task: a factory returning a non-array throws a named error at d ]), customTasks: new Map(), }), + getStandardDescriptor: makeStepBasedGetStandardDescriptor(), }; const project = getMockProject("module"); @@ -1936,6 +2009,7 @@ test("Step-based task: a duplicate step name throws at discovery, before setTask ]), customTasks: new Map(), }), + getStandardDescriptor: makeStepBasedGetStandardDescriptor(), }; const project = getMockProject("module"); @@ -1974,6 +2048,7 @@ test("Step-based task: the factory is called once, so discovery and execution ca ]), customTasks: new Map(), }), + getStandardDescriptor: makeStepBasedGetStandardDescriptor(), }; const createdStages = new Set(); @@ -2013,8 +2088,10 @@ test("Step-based task: the factory is called once, so discovery and execution ca // The discovered step array is kept on the task, frozen so a custom task cannot mutate the shared value, and // re-derived on the next build so a surviving TaskRunner (reused across ui5 serve rebuilds) never serves a // stale array. Options are fixed for a TaskRunner's lifetime (a changed ui5.yaml rebuilds the whole stack), -// so re-deriving per build keeps the steps in step with the options without any explicit invalidation. -test("Step-based task: the kept step array is frozen and re-derived on each build", async (t) => { +// The step array in the descriptor is frozen at discovery time so a custom task cannot mutate the shared +// value. Because the descriptor is memoized by TaskDefinitions (shared with the build-signature phase), the +// same frozen array is reused across builds — a standard task factory is never called more than once. +test("Step-based task: the kept step array is frozen and reused across builds", async (t) => { const {sinon, taskUtil} = t.context; let callCount = 0; @@ -2030,6 +2107,7 @@ test("Step-based task: the kept step array is frozen and re-derived on each buil ]), customTasks: new Map(), }), + getStandardDescriptor: makeStepBasedGetStandardDescriptor(), }; const createdStages = new Set(); @@ -2057,20 +2135,21 @@ test("Step-based task: the kept step array is frozen and re-derived on each buil const taskRunner = createTaskRunner(t, project, {taskUtil, buildCache, taskDefinitions}); sinon.stub(taskRunner, "getDependenciesReader").resolves({getName: () => "dependencies"}); - // First build: one factory call, the kept array is frozen. + // First build: factory called once via getStandardDescriptor, the kept array is frozen. await taskRunner.runTasks(); - t.is(callCount, 1, "The factory ran once for the first build"); + t.is(callCount, 1, "The factory ran once at discovery"); const firstSteps = taskRunner._tasks["stepTask"].steps; t.true(Object.isFrozen(firstSteps), "The kept step array is frozen"); t.throws(() => firstSteps.push({name: "injected"}), undefined, "A custom task cannot mutate the frozen step array"); - // Second build through the same TaskRunner: the factory runs again and the kept array is a fresh one. + // Second build through the same TaskRunner: the descriptor is memoized so the factory does not run again. + // The same frozen array is reused, so secondSteps === firstSteps. await taskRunner.runTasks(); - t.is(callCount, 2, "The factory ran once more for the second build (re-derived, not reused across builds)"); + t.is(callCount, 1, "The factory is not called again on the second build (descriptor memoized)"); const secondSteps = taskRunner._tasks["stepTask"].steps; - t.not(secondSteps, firstSteps, "The second build keeps a fresh step array, not the previous one"); - t.true(Object.isFrozen(secondSteps), "The re-derived array is frozen too"); + t.is(secondSteps, firstSteps, "The second build reuses the same frozen step array from the memoized descriptor"); + t.true(Object.isFrozen(secondSteps), "The reused array is still frozen"); }); // Integration: the custom-task path drives the same real MonitoredTaskUtil + StepRunner as the standard-task @@ -2207,6 +2286,7 @@ test("Step-based task: recordStage passes stale outputs without mutating the del ]), customTasks: new Map(), }), + getStandardDescriptor: makeStepBasedGetStandardDescriptor(), }; const buildCache = { setTasks: sinon.stub(), @@ -2293,6 +2373,7 @@ test("Step-based task: recordStage dedups the fold against the monitored request ]), customTasks: new Map(), }), + getStandardDescriptor: makeStepBasedGetStandardDescriptor(), }; const buildCache = { setTasks: sinon.stub(), @@ -2389,7 +2470,11 @@ function createStepReportingFixture(t, {stepBased, fullyCached = false}) { task: createCustomTaskExtension(sinon, {taskFunction: build, gte: () => true, stepBased: true}), }], ]) : new Map(); - const taskDefinitions = {getTaskDefinitions: async () => ({standardTasks, customTasks})}; + const taskDefinitions = { + getTaskDefinitions: async () => ({standardTasks, customTasks}), + // Standard step-based tasks use taskFunction as the factory; resolve it to a descriptor. + getStandardDescriptor: makeStepBasedGetStandardDescriptor(), + }; const buildCache = { ...t.context.buildCache, diff --git a/packages/project/test/lib/build/definitions/application.js b/packages/project/test/lib/build/definitions/application.js index e0267bf634d..9006acc235d 100644 --- a/packages/project/test/lib/build/definitions/application.js +++ b/packages/project/test/lib/build/definitions/application.js @@ -50,25 +50,21 @@ test("Standard build", (t) => { t.deepEqual(Object.fromEntries(tasks), { escapeNonAsciiCharacters: { - stepBased: true, options: { encoding: "UTF-412", pattern: "/**/*.properties" } }, replaceCopyright: { - stepBased: true, options: { copyright: "copyright", pattern: "/**/*.{js,json}" }, }, replaceVersion: { - stepBased: true, options: { version: "version", pattern: "/**/*.{js,json}" }, }, minify: { - stepBased: true, options: { pattern: [ "/**/*.js", @@ -76,7 +72,7 @@ test("Standard build", (t) => { ] }, }, - enhanceManifest: {stepBased: true}, + enhanceManifest: {}, generateFlexChangesBundle: {}, generateComponentPreload: { options: { @@ -85,15 +81,10 @@ test("Standard build", (t) => { skipBundles: [] } }, - generateStandaloneAppBundle: { - requiresDependencies: true - }, + generateStandaloneAppBundle: {}, transformBootstrapHtml: {}, - generateBundle: { - taskFunction: null - }, + generateBundle: {skip: true}, generateVersionInfo: { - requiresDependencies: true, options: { rootProject: project, pattern: "/resources/**/.library" @@ -104,12 +95,8 @@ test("Standard build", (t) => { signatureType: "PONY" } }, - generateApiIndex: { - requiresDependencies: true - }, - generateResourcesJson: { - requiresDependencies: true - } + generateApiIndex: {}, + generateResourcesJson: {} }, "Correct task definitions"); t.is(taskUtil.getBuildOption.callCount, 0, "taskUtil#getBuildOption has not been called"); @@ -134,25 +121,21 @@ test("Standard build with legacy spec version", (t) => { t.deepEqual(Object.fromEntries(tasks), { escapeNonAsciiCharacters: { - stepBased: true, options: { encoding: "UTF-412", pattern: "/**/*.properties" } }, replaceCopyright: { - stepBased: true, options: { copyright: "copyright", pattern: "/**/*.{js,json}" }, }, replaceVersion: { - stepBased: true, options: { version: "version", pattern: "/**/*.{js,json}" }, }, minify: { - stepBased: true, options: { pattern: [ "/**/*.js", @@ -160,7 +143,7 @@ test("Standard build with legacy spec version", (t) => { ] }, }, - enhanceManifest: {stepBased: true}, + enhanceManifest: {}, generateFlexChangesBundle: {}, generateComponentPreload: { options: { @@ -169,15 +152,10 @@ test("Standard build with legacy spec version", (t) => { skipBundles: [] } }, - generateStandaloneAppBundle: { - requiresDependencies: true - }, + generateStandaloneAppBundle: {}, transformBootstrapHtml: {}, - generateBundle: { - taskFunction: null - }, + generateBundle: {skip: true}, generateVersionInfo: { - requiresDependencies: true, options: { rootProject: project, pattern: "/resources/**/.library" @@ -188,12 +166,8 @@ test("Standard build with legacy spec version", (t) => { signatureType: "PONY" } }, - generateApiIndex: { - requiresDependencies: true - }, - generateResourcesJson: { - requiresDependencies: true - } + generateApiIndex: {}, + generateResourcesJson: {} }, "Correct task definitions"); }); @@ -240,8 +214,9 @@ test("Custom bundles", async (t) => { }]; const generateBundleTaskStub = sinon.stub(); + const generateBundleFactoryStub = sinon.stub().returns({task: generateBundleTaskStub}); getTask.returns({ - task: generateBundleTaskStub + task: generateBundleFactoryStub }); const tasks = application({ @@ -251,25 +226,21 @@ test("Custom bundles", async (t) => { t.deepEqual(Object.fromEntries(tasks), { escapeNonAsciiCharacters: { - stepBased: true, options: { encoding: "UTF-412", pattern: "/**/*.properties" } }, replaceCopyright: { - stepBased: true, options: { copyright: "copyright", pattern: "/**/*.{js,json}" }, }, replaceVersion: { - stepBased: true, options: { version: "version", pattern: "/**/*.{js,json}" }, }, minify: { - stepBased: true, options: { pattern: [ "/**/*.js", @@ -277,7 +248,7 @@ test("Custom bundles", async (t) => { ] }, }, - enhanceManifest: {stepBased: true}, + enhanceManifest: {}, generateFlexChangesBundle: {}, generateComponentPreload: { options: { @@ -289,16 +260,13 @@ test("Custom bundles", async (t) => { ] } }, - generateStandaloneAppBundle: { - requiresDependencies: true - }, + generateStandaloneAppBundle: {}, transformBootstrapHtml: {}, generateBundle: { requiresDependencies: true, taskFunction: generateBundleTaskDefinition.taskFunction }, generateVersionInfo: { - requiresDependencies: true, options: { rootProject: project, pattern: "/resources/**/.library" @@ -309,12 +277,8 @@ test("Custom bundles", async (t) => { signatureType: "PONY" } }, - generateApiIndex: { - requiresDependencies: true - }, - generateResourcesJson: { - requiresDependencies: true - } + generateApiIndex: {}, + generateResourcesJson: {} }, "Correct task definitions"); @@ -402,7 +366,6 @@ test("Minification excludes", (t) => { const taskDefinition = tasks.get("minify"); t.deepEqual(taskDefinition, { - stepBased: true, options: { pattern: [ "/**/*.js", @@ -429,7 +392,6 @@ test("Minification excludes not applied for legacy specVersion", (t) => { const taskDefinition = tasks.get("minify"); t.deepEqual(taskDefinition, { - stepBased: true, options: { pattern: [ "/**/*.js", diff --git a/packages/project/test/lib/build/definitions/component.js b/packages/project/test/lib/build/definitions/component.js index 4baf6ca9a13..9440c279354 100644 --- a/packages/project/test/lib/build/definitions/component.js +++ b/packages/project/test/lib/build/definitions/component.js @@ -49,25 +49,21 @@ test("Standard build", (t) => { t.deepEqual(Object.fromEntries(tasks), { escapeNonAsciiCharacters: { - stepBased: true, options: { encoding: "UTF-412", pattern: "/**/*.properties" } }, replaceCopyright: { - stepBased: true, options: { copyright: "copyright", pattern: "/**/*.{js,json}" }, }, replaceVersion: { - stepBased: true, options: { version: "version", pattern: "/**/*.{js,json}" }, }, minify: { - stepBased: true, options: { pattern: [ "/**/*.js", @@ -75,7 +71,7 @@ test("Standard build", (t) => { ] }, }, - enhanceManifest: {stepBased: true}, + enhanceManifest: {}, generateFlexChangesBundle: {}, generateComponentPreload: { options: { @@ -84,17 +80,13 @@ test("Standard build", (t) => { skipBundles: [] } }, - generateBundle: { - taskFunction: null - }, + generateBundle: {skip: true}, generateCachebusterInfo: { options: { signatureType: "PONY" } }, - generateResourcesJson: { - requiresDependencies: true - } + generateResourcesJson: {} }, "Correct task definitions"); t.is(taskUtil.getBuildOption.callCount, 0, "taskUtil#getBuildOption has not been called"); @@ -141,8 +133,9 @@ test("Custom bundles", async (t) => { }]; const generateBundleTaskStub = sinon.stub(); + const generateBundleFactoryStub = sinon.stub().returns({task: generateBundleTaskStub}); getTask.returns({ - task: generateBundleTaskStub + task: generateBundleFactoryStub }); const tasks = component({ @@ -152,25 +145,21 @@ test("Custom bundles", async (t) => { t.deepEqual(Object.fromEntries(tasks), { escapeNonAsciiCharacters: { - stepBased: true, options: { encoding: "UTF-412", pattern: "/**/*.properties" } }, replaceCopyright: { - stepBased: true, options: { copyright: "copyright", pattern: "/**/*.{js,json}" }, }, replaceVersion: { - stepBased: true, options: { version: "version", pattern: "/**/*.{js,json}" }, }, minify: { - stepBased: true, options: { pattern: [ "/**/*.js", @@ -178,7 +167,7 @@ test("Custom bundles", async (t) => { ] }, }, - enhanceManifest: {stepBased: true}, + enhanceManifest: {}, generateFlexChangesBundle: {}, generateComponentPreload: { options: { @@ -199,9 +188,7 @@ test("Custom bundles", async (t) => { signatureType: "PONY" } }, - generateResourcesJson: { - requiresDependencies: true - } + generateResourcesJson: {} }, "Correct task definitions"); @@ -289,7 +276,6 @@ test("Minification excludes", (t) => { const taskDefinition = tasks.get("minify"); t.deepEqual(taskDefinition, { - stepBased: true, options: { pattern: [ "/**/*.js", diff --git a/packages/project/test/lib/build/definitions/library.js b/packages/project/test/lib/build/definitions/library.js index c02b1cd1b26..664385b00ad 100644 --- a/packages/project/test/lib/build/definitions/library.js +++ b/packages/project/test/lib/build/definitions/library.js @@ -46,59 +46,49 @@ test.beforeEach((t) => { t.context.getTask = sinon.stub(); }); -test("Standard build", async (t) => { +test("Standard build", (t) => { const {project, taskUtil, getTask} = t.context; project.getJsdocExcludes = () => ["**.html"]; - const generateJsdocTaskStub = sinon.stub(); - getTask.returns({ - task: generateJsdocTaskStub - }); - const tasks = library({ project, taskUtil, getTask }); - const generateJsdocTaskDefinition = tasks.get("generateJsdoc"); t.deepEqual(Object.fromEntries(tasks), { escapeNonAsciiCharacters: { - stepBased: true, options: { encoding: "UTF-412", pattern: "/**/*.properties" } }, replaceCopyright: { - stepBased: true, options: { copyright: "copyright", pattern: "/**/*.{js,library,css,less,theme,html}" }, }, replaceVersion: { - stepBased: true, options: { version: "version", pattern: "/**/*.{js,json,library,css,less,theme,html}" }, }, replaceBuildtime: { - stepBased: true, options: { pattern: "/resources/sap/ui/{Global,core/Core}.js" }, }, generateJsdoc: { - determineBuildSignature: generateJsdocTaskDefinition.determineBuildSignature, - requiresDependencies: true, - taskFunction: generateJsdocTaskDefinition.taskFunction + options: { + namespace: "project/b", + version: "version", + jsdocExcludes: ["**.html"] + } }, executeJsdocSdkTransformation: { - requiresDependencies: true, options: { dotLibraryPattern: "/resources/**/*.library" } }, minify: { - stepBased: true, options: { pattern: [ "/resources/**/*.js", @@ -107,15 +97,15 @@ test("Standard build", async (t) => { }, }, generateLibraryManifest: {}, - enhanceManifest: {stepBased: true}, + enhanceManifest: {}, + generateComponentPreload: {skip: true}, generateLibraryPreload: { options: { excludes: [], skipBundles: [] } }, + generateBundle: {skip: true}, buildThemes: { - stepBased: true, - requiresDependencies: true, options: { projectName: "project.b", librariesPattern: undefined, @@ -123,45 +113,9 @@ test("Standard build", async (t) => { inputPattern: "/resources/project/b/themes/*/library.source.less", } }, - generateBundle: { - taskFunction: null - }, - generateComponentPreload: { - taskFunction: null - }, - generateThemeDesignerResources: { - taskFunction: null - }, - generateResourcesJson: { - requiresDependencies: true - } + generateThemeDesignerResources: {skip: true}, + generateResourcesJson: {} }, "Correct task definitions"); - - - await generateJsdocTaskDefinition.taskFunction({ - workspace: "workspace", - dependencies: "dependencies", - taskUtil, - options: { - projectName: "projectName" - } - }); - - t.is(generateJsdocTaskStub.callCount, 1, "generateJsdoc task got called once"); - t.deepEqual(generateJsdocTaskStub.getCall(0).args[0], { - workspace: "workspace", - dependencies: "dependencies", - taskUtil, - options: { - projectName: "projectName", - namespace: "project/b", - version: "version", - pattern: [ - "/resources/**/*.js", - "!/resources/**.html" - ], - } - }, "generateBundle task got called with correct arguments"); }); test("Standard build (framework project)", (t) => { @@ -169,18 +123,12 @@ test("Standard build (framework project)", (t) => { project.isFrameworkProject = () => true; - const generateJsdocTaskStub = sinon.stub(); - getTask.returns({ - task: generateJsdocTaskStub - }); - const tasks = library({ project, taskUtil, getTask }); t.deepEqual(tasks.get("generateThemeDesignerResources"), { - stepBased: true, - requiresDependencies: true, options: { + options: { version: "version" } }); @@ -199,47 +147,42 @@ test("Standard build with legacy spec version", (t) => { const tasks = library({ project, taskUtil, getTask }); - const generateJsdocTaskDefinition = tasks.get("generateJsdoc"); t.deepEqual(Object.fromEntries(tasks), { escapeNonAsciiCharacters: { - stepBased: true, options: { encoding: "UTF-412", pattern: "/**/*.properties" } }, replaceCopyright: { - stepBased: true, options: { copyright: "copyright", pattern: "/**/*.{js,library,css,less,theme,html}" }, }, replaceVersion: { - stepBased: true, options: { version: "version", pattern: "/**/*.{js,json,library,css,less,theme,html}" }, }, replaceBuildtime: { - stepBased: true, options: { pattern: "/resources/sap/ui/{Global,core/Core}.js" }, }, generateJsdoc: { - determineBuildSignature: generateJsdocTaskDefinition.determineBuildSignature, - requiresDependencies: true, - taskFunction: generateJsdocTaskDefinition.taskFunction + options: { + namespace: "project/b", + version: "version", + jsdocExcludes: [] + } }, executeJsdocSdkTransformation: { - requiresDependencies: true, options: { dotLibraryPattern: "/resources/**/*.library" } }, minify: { - stepBased: true, options: { pattern: [ "/resources/**/*.js", @@ -248,15 +191,15 @@ test("Standard build with legacy spec version", (t) => { }, }, generateLibraryManifest: {}, - enhanceManifest: {stepBased: true}, + enhanceManifest: {}, + generateComponentPreload: {skip: true}, generateLibraryPreload: { options: { excludes: [], skipBundles: [] } }, + generateBundle: {skip: true}, buildThemes: { - stepBased: true, - requiresDependencies: true, options: { projectName: "project.b", librariesPattern: undefined, @@ -264,18 +207,8 @@ test("Standard build with legacy spec version", (t) => { inputPattern: "/resources/project/b/themes/*/library.source.less", } }, - generateBundle: { - taskFunction: null - }, - generateComponentPreload: { - taskFunction: null - }, - generateThemeDesignerResources: { - taskFunction: null - }, - generateResourcesJson: { - requiresDependencies: true - } + generateThemeDesignerResources: {skip: true}, + generateResourcesJson: {} }, "Correct task definitions"); }); @@ -322,56 +255,52 @@ test("Custom bundles", async (t) => { }]; const generateBundleTaskStub = sinon.stub(); + const generateBundleFactoryStub = sinon.stub().returns({task: generateBundleTaskStub}); getTask.returns({ - task: generateBundleTaskStub + task: generateBundleFactoryStub }); const tasks = library({ project, taskUtil, getTask }); - const generateJsdocTaskDefinition = tasks.get("generateJsdoc"); const generateBundleTaskDefinition = tasks.get("generateBundle"); t.deepEqual(Object.fromEntries(tasks), { escapeNonAsciiCharacters: { - stepBased: true, options: { encoding: "UTF-412", pattern: "/**/*.properties" } }, replaceCopyright: { - stepBased: true, options: { copyright: "copyright", pattern: "/**/*.{js,library,css,less,theme,html}" }, }, replaceVersion: { - stepBased: true, options: { version: "version", pattern: "/**/*.{js,json,library,css,less,theme,html}" }, }, replaceBuildtime: { - stepBased: true, options: { pattern: "/resources/sap/ui/{Global,core/Core}.js" }, }, generateJsdoc: { - determineBuildSignature: generateJsdocTaskDefinition.determineBuildSignature, - requiresDependencies: true, - taskFunction: generateJsdocTaskDefinition.taskFunction + options: { + namespace: "project/b", + version: "version", + jsdocExcludes: [] + } }, executeJsdocSdkTransformation: { - requiresDependencies: true, options: { dotLibraryPattern: "/resources/**/*.library" } }, minify: { - stepBased: true, options: { pattern: [ "/resources/**/*.js", @@ -380,7 +309,8 @@ test("Custom bundles", async (t) => { }, }, generateLibraryManifest: {}, - enhanceManifest: {stepBased: true}, + enhanceManifest: {}, + generateComponentPreload: {skip: true}, generateLibraryPreload: { options: { excludes: [], @@ -395,8 +325,6 @@ test("Custom bundles", async (t) => { taskFunction: generateBundleTaskDefinition.taskFunction }, buildThemes: { - stepBased: true, - requiresDependencies: true, options: { projectName: "project.b", librariesPattern: undefined, @@ -404,15 +332,8 @@ test("Custom bundles", async (t) => { inputPattern: "/resources/project/b/themes/*/library.source.less", } }, - generateComponentPreload: { - taskFunction: null - }, - generateThemeDesignerResources: { - taskFunction: null - }, - generateResourcesJson: { - requiresDependencies: true - } + generateThemeDesignerResources: {skip: true}, + generateResourcesJson: {} }, "Correct task definitions"); @@ -500,7 +421,6 @@ test("Minification excludes", (t) => { const taskDefinition = tasks.get("minify"); t.deepEqual(taskDefinition, { - stepBased: true, options: { pattern: [ "/resources/**/*.js", @@ -528,7 +448,6 @@ test("Minification excludes not applied for legacy specVersion", (t) => { const taskDefinition = tasks.get("minify"); t.deepEqual(taskDefinition, { - stepBased: true, options: { pattern: [ "/resources/**/*.js", @@ -640,8 +559,6 @@ test("buildThemes: Project is not root", (t) => { const taskDefinition = tasks.get("buildThemes"); t.deepEqual(taskDefinition, { - stepBased: true, - requiresDependencies: true, options: { projectName: "project.b", librariesPattern: "/resources/**/(*.library|library.js)", @@ -650,6 +567,7 @@ test("buildThemes: Project is not root", (t) => { } }, "Correct buildThemes task definition"); }); + test("generateLibraryManifest: specVersion 5.0, non-framework project", (t) => { const {project, taskUtil, getTask} = t.context; @@ -664,7 +582,7 @@ test("generateLibraryManifest: specVersion 5.0, non-framework project", (t) => { const tasks = library({project, taskUtil, getTask}); - t.deepEqual(tasks.get("generateLibraryManifest"), {taskFunction: null}, + t.deepEqual(tasks.get("generateLibraryManifest"), {skip: true}, "generateLibraryManifest is skipped for non-framework libraries on specVersion 5.0"); }); @@ -696,8 +614,6 @@ test("buildThemes: CSS Variables enabled", (t) => { const taskDefinition = tasks.get("buildThemes"); t.deepEqual(taskDefinition, { - stepBased: true, - requiresDependencies: true, options: { projectName: "project.b", librariesPattern: undefined, @@ -707,96 +623,17 @@ test("buildThemes: CSS Variables enabled", (t) => { }, "Correct buildThemes task definition"); }); -test("Standard build: nulled taskFunction to skip tasks", (t) => { +test("Standard build: skipped tasks use skip flag", (t) => { const {project, taskUtil, getTask} = t.context; - project.getJsdocExcludes = () => ["**.html"]; const tasks = library({ project, taskUtil, getTask }); - const generateComponentPreloadTaskDefinition = tasks.get("generateComponentPreload"); - const generateBundleTaskDefinition = tasks.get("generateBundle"); - const generateThemeDesignerResourcesTaskDefinition = tasks.get("generateThemeDesignerResources"); - const generateJsdocTaskDefinition = tasks.get("generateJsdoc"); - t.deepEqual(Object.fromEntries(tasks), { - escapeNonAsciiCharacters: { - stepBased: true, - options: { - encoding: "UTF-412", pattern: "/**/*.properties" - } - }, - replaceCopyright: { - stepBased: true, - options: { - copyright: "copyright", - pattern: "/**/*.{js,library,css,less,theme,html}" - }, - }, - replaceVersion: { - stepBased: true, - options: { - version: "version", - pattern: "/**/*.{js,json,library,css,less,theme,html}" - }, - }, - replaceBuildtime: { - stepBased: true, - options: { - pattern: "/resources/sap/ui/{Global,core/Core}.js" - }, - }, - generateJsdoc: { - determineBuildSignature: generateJsdocTaskDefinition.determineBuildSignature, - requiresDependencies: true, - taskFunction: async () => {}, - }, - executeJsdocSdkTransformation: { - requiresDependencies: true, - options: { - dotLibraryPattern: "/resources/**/*.library" - } - }, - minify: { - stepBased: true, - options: { - pattern: [ - "/resources/**/*.js", - "!**/*.support.js", - ] - }, - }, - generateLibraryManifest: {}, - enhanceManifest: {stepBased: true}, - generateLibraryPreload: { - options: { - excludes: [], skipBundles: [] - } - }, - buildThemes: { - stepBased: true, - requiresDependencies: true, - options: { - projectName: "project.b", - librariesPattern: undefined, - themesPattern: undefined, - inputPattern: "/resources/project/b/themes/*/library.source.less", - } - }, - generateBundle: { - taskFunction: null - }, - generateComponentPreload: { - taskFunction: null - }, - generateThemeDesignerResources: { - taskFunction: null - }, - generateResourcesJson: { - requiresDependencies: true - } - }, "Correct task definitions"); - t.is(generateComponentPreloadTaskDefinition.taskFunction, null, "taskFunction is explicitly set to null"); - t.is(generateBundleTaskDefinition.taskFunction, null, "taskFunction is explicitly set to null"); - t.is(generateThemeDesignerResourcesTaskDefinition.taskFunction, null, "taskFunction is explicitly set to null"); + t.deepEqual(tasks.get("generateComponentPreload"), {skip: true}, + "generateComponentPreload is skipped when no paths or namespaces"); + t.deepEqual(tasks.get("generateBundle"), {skip: true}, + "generateBundle is skipped when no bundles"); + t.deepEqual(tasks.get("generateThemeDesignerResources"), {skip: true}, + "generateThemeDesignerResources is skipped for non-framework projects"); }); diff --git a/packages/project/test/lib/build/definitions/themeLibrary.js b/packages/project/test/lib/build/definitions/themeLibrary.js index cd9209a03c6..6c321828de0 100644 --- a/packages/project/test/lib/build/definitions/themeLibrary.js +++ b/packages/project/test/lib/build/definitions/themeLibrary.js @@ -47,25 +47,20 @@ test("Standard build", (t) => { const tasks = themeLibrary({ project, taskUtil, getTask }); - const generateThemeDesignerResourcesTaskFunction = tasks.get("generateThemeDesignerResources"); t.deepEqual(Object.fromEntries(tasks), { replaceCopyright: { - stepBased: true, options: { copyright: "copyright", pattern: "/resources/**/*.{less,theme}" }, }, replaceVersion: { - stepBased: true, options: { version: "version", pattern: "/resources/**/*.{less,theme}" }, }, buildThemes: { - stepBased: true, - requiresDependencies: true, options: { projectName: "project.b", librariesPattern: undefined, @@ -73,16 +68,9 @@ test("Standard build", (t) => { inputPattern: "/resources/**/themes/*/library.source.less", } }, - generateResourcesJson: { - requiresDependencies: true - }, - generateThemeDesignerResources: { - taskFunction: null - } + generateThemeDesignerResources: {skip: true}, + generateResourcesJson: {} }, "Correct task definitions"); - - - t.is(generateThemeDesignerResourcesTaskFunction.taskFunction, null, "taskFunction is explicitly set to null"); }); test("Standard build (framework project)", (t) => { @@ -95,8 +83,7 @@ test("Standard build (framework project)", (t) => { }); t.deepEqual(tasks.get("generateThemeDesignerResources"), { - stepBased: true, - requiresDependencies: true, options: { + options: { version: "version" } }); @@ -111,22 +98,18 @@ test("Standard build for non root project", (t) => { }); t.deepEqual(Object.fromEntries(tasks), { replaceCopyright: { - stepBased: true, options: { copyright: "copyright", pattern: "/resources/**/*.{less,theme}" }, }, replaceVersion: { - stepBased: true, options: { version: "version", pattern: "/resources/**/*.{less,theme}" }, }, buildThemes: { - stepBased: true, - requiresDependencies: true, options: { projectName: "project.b", librariesPattern: "/resources/**/(*.library|library.js)", @@ -134,12 +117,8 @@ test("Standard build for non root project", (t) => { inputPattern: "/resources/**/themes/*/library.source.less", } }, - generateResourcesJson: { - requiresDependencies: true - }, - generateThemeDesignerResources: { - taskFunction: null - } + generateThemeDesignerResources: {skip: true}, + generateResourcesJson: {} }, "Correct task definitions"); }); @@ -153,8 +132,6 @@ test("CSS variables enabled", (t) => { const taskDefinition = tasks.get("buildThemes"); t.deepEqual(taskDefinition, { - stepBased: true, - requiresDependencies: true, options: { projectName: "project.b", librariesPattern: undefined, diff --git a/packages/project/test/lib/build/helpers/getBuildSignature.js b/packages/project/test/lib/build/helpers/getBuildSignature.js index de747517386..34ac637fdd7 100644 --- a/packages/project/test/lib/build/helpers/getBuildSignature.js +++ b/packages/project/test/lib/build/helpers/getBuildSignature.js @@ -2,7 +2,7 @@ import test from "ava"; import crypto from "node:crypto"; import esmock from "esmock"; -const BUILD_SIG_VERSION = "0"; +const BUILD_SIG_VERSION = "1"; function hash(key) { return crypto.createHash("sha256").update(key).digest("hex"); diff --git a/packages/project/test/lib/specifications/extensions/Task.js b/packages/project/test/lib/specifications/extensions/Task.js index 059468e28d6..2a7c9393048 100644 --- a/packages/project/test/lib/specifications/extensions/Task.js +++ b/packages/project/test/lib/specifications/extensions/Task.js @@ -124,6 +124,46 @@ test("getExpectedOutputCallback (ESM)", async (t) => { t.is(callback, undefined, "Returns undefined when not exported"); }); +test.serial("getDescriptor memoizes descriptors per project and runtime task name", async (t) => { + const taskInput = clone(basicEsmTaskInput); + taskInput.configuration.specVersion = "5.0"; + const extension = await Specification.create(taskInput); + const factory = sinon.stub().callsFake((options) => ({ + task: async () => options.projectName, + determineBuildSignature: async () => options.projectName + })); + sinon.stub(extension, "_getImplementation").resolves({default: factory}); + + const projectAOptions = { + projectName: "project.a", + projectNamespace: "project/a", + configuration: {value: "a"} + }; + const projectBOptions = { + projectName: "project.b", + projectNamespace: "project/b", + configuration: {value: "b"} + }; + + const projectASignature = + await extension.getDetermineBuildSignatureCallback("task-a", projectAOptions); + const projectBSignature = + await extension.getDetermineBuildSignatureCallback("task-a", projectBOptions); + const projectADescriptor = await extension.getDescriptor("task-a", projectAOptions); + const projectASecondCall = await extension.getDescriptor("task-a", {...projectAOptions}); + const projectBDescriptor = await extension.getDescriptor("task-a", projectBOptions); + const projectASecondTask = await extension.getDescriptor("task-a--2", projectAOptions); + + t.is(projectASecondCall, projectADescriptor, "Reused the descriptor within one project"); + t.not(projectBDescriptor, projectADescriptor, "Created a separate descriptor for another project"); + t.not(projectASecondTask, projectADescriptor, "Created a separate descriptor for another runtime task"); + t.is(await projectASignature(), "project.a", "The first signature callback captured project A's options"); + t.is(await projectBSignature(), "project.b", "The second signature callback captured project B's options"); + t.is(await projectADescriptor.task(), "project.a", "The first descriptor captured project A's options"); + t.is(await projectBDescriptor.task(), "project.b", "The second descriptor captured project B's options"); + t.is(factory.callCount, 3, "Called the factory once per project and runtime task"); +}); + test("Extension abstract class cannot be directly instantiated", (t) => { t.throws(() => new Extension({configuration: {}}), { instanceOf: TypeError,