@commerce-klaus/sfcc-test-runtime
Framework-independent SFCC runtime modules and dependency mocking for local tests.
TL;DR
Most Vitest projects should install @commerce-klaus/vitest-sfcc and access runtime APIs through @commerce-klaus/vitest-sfcc/runtime. Install this package directly only when using the runtime without Vitest or building another test-runner integration.
import { createSfccTestRuntime } from "@commerce-klaus/sfcc-test-runtime"
const runtime = createSfccTestRuntime({
site: {
id: "RefArch",
preferences: { reviewsEnabled: true },
},
})
runtime.mock("dw/system/Logger", loggerMock)
runtime.setGlobals({ request, session, customer })Why this package exists
Tests often need a small, deterministic subset of SFCC behavior without coupling that behavior to Vite, Vitest, cartridge discovery, or filesystem resolution. This package provides that framework-independent core: an explicit module registry, controlled globals, focused platform modules, and execution harnesses for controllers, hooks, and job steps.
Not a platform emulator
This package intentionally does not emulate the complete SFCC platform. Unknown modules fail unless a caller registers a mock or supplies a real-module fallback.
Features
- Replaces module identifiers globally or one exact resolved file.
- Installs and restores controlled SFCC process globals.
- Implements focused
dw/systemanddw/utilmodules used by local tests. - Executes Custom API operations, SFRA controller routes, hooks, script-module job steps, and chunk jobs.
- Records logger, transaction, and hook calls for assertions.
- Remains independent of Vite, Vitest, cartridge paths, and site-template files.
Installation
pnpm add -D @commerce-klaus/sfcc-test-runtimenpm install -D @commerce-klaus/sfcc-test-runtimeyarn add -D @commerce-klaus/sfcc-test-runtimevp install -D @commerce-klaus/sfcc-test-runtimeUsing Vitest?
Do not add this direct dependency when @commerce-klaus/vitest-sfcc already owns the test integration. Use its @commerce-klaus/vitest-sfcc/runtime subpath instead.
Runtime API
Create an isolated runtime instance for each test context:
import { createSfccTestRuntime } from "@commerce-klaus/sfcc-test-runtime"
const runtime = createSfccTestRuntime({
site: {
id: "RefArch",
preferences: { reviewsEnabled: true },
},
})
runtime.mock("dw/system/Logger", loggerMock)
runtime.mockResolved("/workspace/cartridges/app_custom/cartridge/scripts/provider.js", providerMock)
runtime.hasModuleMock("dw/system/Logger")
runtime.reset()mock() targets the original module identifier. SFCC script modules match whether the mock or import includes a supported .js, .ds, or .json extension; an exact identifier match takes precedence when multiple variants are registered. This is useful for platform modules and shared identifiers such as */cartridge/scripts/provider or ./helper. mockResolved() targets exactly one absolute cartridge file and takes precedence when identical relative identifiers occur in different directories. hasModuleMock() reports whether an identifier or one of its supported extension aliases has an explicit mock() replacement; built-in runtime implementations do not count as mocks.
Runtime modules
The initial runtime provides focused implementations of:
dw/system/RESTResponseMgrdw/system/Statusdw/system/Transactiondw/system/Loggerdw/system/Sitedw/system/HookMgr
These implementations model behavior needed by tests and expose call history where useful. They do not attempt to emulate the complete SFCC platform.
Status
dw/system/Status supports empty, single-item, and aggregated statuses. It exposes status/getStatus(), code/getCode(), message/getMessage(), parameters/getParameters(), details/getDetails(), error/isError(), and items/getItems(). When multiple items are present, these values come from the first error item or otherwise the first item. addItem() updates the overall error state, while addDetail() targets the currently selected item.
dw/system/StatusItem is also built in. Its constructors, property/getter pairs, details, and setStatus(), setCode(), setMessage(), and setParameters() methods allow tests to build and mutate multi-item statuses.
ArrayList
dw/util/ArrayList accepts arrays, SFCC collections, iterators, and variadic values. It supports add(), addAll(), push(), sort(), reverse(), and clone() alongside indexed access, collection inspection, JavaScript iteration, and SFCC iterators. Clones and input arrays are copied, while constructing from an iterator consumes its remaining items.
HashMap
dw/util/HashMap supports arbitrary key types and provides put(), putAll(), get(), remove(), clear(), containment checks, size inspection, and clone(). Missing keys return null. keySet(), values(), and entrySet() are live SfccCollection views, while clones use an independent backing map.
StringUtils
dw/util/StringUtils.format() replaces numbered placeholders such as {0} and {1}, including repeated indices. Placeholders without a supplied value remain unchanged. encodeBase64() and decodeBase64() use UTF-8, including non-ASCII input. Locale, calendar, number, money, and resource formatting are not modeled and can be supplied with runtime.mock().
Calendar
dw/util/Calendar provides deterministic UTC behavior for construction from the current time or a Date, defensive time/getTime()/setTime() copies, and common year, month, date, day-of-year, day-of-week, hour, minute, second, and millisecond fields. get(), set(), and add() support the corresponding field constants. before(), after(), compareTo(), equals(), isSameDay(), and isLeapYear() cover common comparison logic. Parsing, rolling, clearing, locale patterns, and time-zone conversion are not modeled.
SFCC globals
Use setGlobals() for process globals referenced directly by cartridge code:
runtime.setGlobals({
customer: { authenticated: true },
request: { locale: "de_DE", querystring: {} },
session: { custom: {} },
})Known globals use focused, extensible contracts:
SfccCustomerGlobalSfccRequestGlobalSfccResponseGlobalSfccSessionGlobalSfccKnownGlobals
Each object contract permits additional project fields while making common properties such as customer.authenticated, request.locale, session.custom, and response setters discoverable in the editor.
Installed values can be inspected without casting globalThis:
const request = runtime.getGlobal("request")
expect(request?.locale).toBe("de_DE")
const organization = runtime.getGlobal<{ ID: string }>("organization")
expect(organization?.ID).toBe("org")
expect(runtime.hasGlobal("session")).toBe(true)
expect(runtime.getGlobals()).toMatchObject({
customer: { authenticated: true },
session: { custom: {} },
})
runtime.unsetGlobals("request", "response")getGlobals() returns a snapshot of globals controlled by this runtime. It does not expose unrelated process globals. unsetGlobals() restores the value and complete property descriptor that existed before the runtime first changed that name.
Stable project context can be installed through runtime options. These defaults are reinstated by every reset():
const runtime = createSfccTestRuntime({
globals: {
organization: { ID: "org" },
session: { custom: { channel: "storefront" } },
},
})The runtime provides empty() by default. Matching the Script API, it returns true for null, undefined, empty strings and arrays, and SFCC collections whose isEmpty() method returns true. Values such as false, 0, whitespace, and plain objects are not empty. Tests can override it through setGlobals() when application-specific behavior is required.
reset() removes per-test globals, restores the complete property descriptor of values that existed before the test, and reinstalls empty() plus configured defaults. Call it after each test so the worker does not retain request state. Because globals are process-wide within a worker, do not run tests that install different global contexts concurrently in the same worker.
Custom APIs
Pass an imported Custom API module to customApi() and invoke one of its exported operations:
const api = await import("../cartridge/rest-apis/products/getProduct.js")
const result = await runtime.customApi(api.default ?? api).invoke("getProduct", {
body: { includeOffline: false },
customer: { authenticated: true, registered: true, profile: {} },
headers: { authorization: "Bearer test-token" },
pathParameters: { productId: "sample-product" },
queryParameters: { locale: "de-DE" },
})
expect(result.response).toMatchObject({
kind: "success",
statusCode: 200,
})The harness installs deterministic request, response, and customer globals and supplies dw/system/RESTResponseMgr. It records rendered success, empty-success, and RFC 9457 error responses, including response headers, expiration, and custom error attributes.
Use the request, response, and globals options to extend the standard context when an operation relies on additional project-specific fields. The runtime deliberately does not discover files or validate OAS schemas; test-runner integrations own module loading, while generated types and project validation own metadata contracts.
SFRA controllers
The built-in server module captures server.get() and server.post() registrations from SFRA controllers. Pass the exported controller to the harness and run a route with a request object:
const controller = await import("../cartridge/controllers/Checkout.js")
const response = await runtime.controller(controller.default).run("Begin", {
querystring: { stage: "shipping" },
})
expect(response.view).toBe("checkout/checkout")
expect(response.viewData).toMatchObject({ currentStage: "shipping" })Middleware runs in registration order when it calls next(). The response implementation supports render(), json(), redirect(), setViewData(), and getViewData().
Raw response methods are observable without a separate base-response mock:
res.setContentType("text/xml")
res.setStatusCode(202)
res.print("<sitemap />")The returned harness response exposes these calls through contentType, statusCode, and the ordered printed array.
Redirect and response metadata are captured as well:
res.cacheExpiration(2)
res.log("redirect", { permanent: true })
res.setHttpHeader("X-Redirect-Source", "controller")
res.setRedirectStatus(301)
res.redirect("/target")
next()The result exposes redirectUrl, redirectStatus, headers, cachePeriod, and messageLog. Matching SFRA routing behavior, a pending redirect stops the remaining middleware when next() is called.
Middleware control
A middleware function controls the route chain through next():
- Calling
next()executes the next registered middleware. - Returning without
next()stops the chain normally. - Calling
next(error)logs the error and rejects the Promise returned byrun(). - Calling
next()more than once from the same middleware rejects with an explicit diagnostic.
Both synchronous SFRA-style next() calls and awaited calls are supported.
Extended cartridges can inherit and modify routes using the standard SFRA pattern:
const server = require("server")
server.extend(module.superModule)
server.prepend("Show", authorizeCustomer)
server.append("Show", addViewData)
server.replace("Submit", submitReplacement)
module.exports = server.exports()extend() clones the inherited route registry. prepend() and append() preserve middleware order, while replace() removes the previous chain for that route. Modifying a missing route fails with an explicit error.
Hook implementations can also be registered directly. The first registration for an extension point wins, matching cartridge-path priority:
runtime.registerHook("app.payment.authorize", {
authorize: (paymentId) => ({ paymentId, authorized: true }),
})
runtime.callHook("app.payment.authorize", "authorize", "payment-1")runtime.hookCalls records extension point, function name, and arguments for assertions. A missing extension point or function returns undefined; hook exceptions propagate to the caller.
Job steps
Task-oriented script-module-step modules can be executed by their configured function name:
const jobModule = await import("../cartridge/scripts/jobs/GenerateFeed.js")
const jobStep = runtime.jobStep(jobModule, {
context: { previousFile: "feed-1.csv" },
jobExecutionId: "execution-1",
jobId: "NightlyFeed",
stepExecutionId: "step-execution-1",
stepId: "GenerateFeed",
stepTypeId: "custom.GenerateFeed",
})
const status = await jobStep.run("Run", {
TargetFolder: "IMPEX/src/feeds",
})
expect(jobStep.jobExecution.context).toMatchObject({
previousFile: "feed-2.csv",
})The configured function receives (parameters, stepExecution). stepExecution.getJobExecution() returns the same job execution for every call, so scripts can share mutable context state across steps or repeated runs. The execution objects expose the platform properties and equivalent getters: ID/getID(), jobID/getJobID(), stepID/getStepID(), stepTypeID/getStepTypeID(), and context/getContext(). Their IDs can be set through SfccJobStepHarnessOptions; omitted IDs receive stable test defaults. Missing and non-function exports fail with an explicit diagnostic.
SfccJobContext preserves the supplied object's identity and normal property access while adding a non-enumerable dw.util.Map-like core: get(), put(), remove(), clear(), containsKey(), containsValue(), size(), getLength(), isEmpty(), length, and empty. Missing keys return null, matching the platform API. This keeps existing object assertions and production-style context[key] code working together.
keySet(), values(), and entrySet() return live SfccCollection views. They expose length, empty, size(), getLength(), isEmpty(), contains(), JavaScript iteration, iterator(), and both toArray() forms. Every toArray() call returns an independent array. SfccIterator implements the production-style hasNext()/next() loop over the elements present when the iterator is created. Its parameterless asList() consumes all remaining elements into an independent SfccList, which adds indexed get(). SfccMapEntry exposes key/getKey() and value/getValue(). The harness intentionally leaves mutating collection-view operations and the partially consuming Iterator.asList(start, size) overload out until their platform semantics can be represented faithfully; mutate the context itself with put(), remove(), or clear().
Chunk-oriented modules use runChunk() with the function names declared in steptypes.json:
const result = await runtime.jobStep(chunkModule).runChunk({
chunkSize: 1000,
functions: {
afterChunk: "afterChunk",
afterStep: "afterStep",
beforeStep: "beforeStep",
getTotalCount: "getTotalCount",
process: "process",
read: "read",
write: "write",
},
parameters: { TargetFolder: "IMPEX/src/feeds" },
})
expect(result.writtenCount).toBeGreaterThan(0)The harness calls beforeStep, obtains the optional total count, and runs read and optional process functions in chunks. Nullish process results are skipped. write receives an iterable SFCC-like list with size(), get(), toArray(), and isEmpty(). afterChunk runs after each non-empty input chunk, while afterStep(success, parameters, stepExecution) runs after success or failure. The result exposes read, processed, written, and chunk counts plus the total and afterStep result.
An unknown module fails with an actionable error unless the caller supplies a real cartridge fallback. This prevents incomplete platform behavior from making tests pass accidentally.
Relationship to @commerce-klaus/vitest-sfcc
This package does not read cartridge paths, jsconfig.json, site.xml, hooks.json, or steptypes.json, and it does not transform CommonJS. Those project-facing responsibilities belong to @commerce-klaus/vitest-sfcc and the shared module resolver.
@commerce-klaus/vitest-sfcc depends on this package internally and exposes its consumer-facing APIs and types through @commerce-klaus/vitest-sfcc/runtime. Direct consumers of this package are typically test-runner adapters or custom harnesses.
Development
This repository uses Vite+ (vp):
vp install
vp check
vp test
vp run buildLicense
MIT