Appearance
Project configuration
Keep the project's test choices in furnace.json at its root. The shared compatibility catalog supplies the valid platform/PHP combinations; the project selects releases, PHP minors, themes and test layers from it.
See what can be tested
bash
furnace catalog
furnace catalog --json=/tmp/furnace-catalog.jsonThe catalog lists the target Magento and MageOS versions, compatible PHP minors, available cells and missing or older cells. Its theme table shows the actual storefront theme paths and installed package versions, including different Hyvä versions in different cells. PHP interpreters installed on the host are listed too.
The default policy is config/platforms.yaml in the Furnace installation. Set FURNACE_CATALOG=/absolute/path/to/platforms.yaml to use another policy for catalog, plan, run and manifest scaffolding. Changing the catalog defines required coverage; it does not provision or change cells.
Generate and edit a manifest
bash
furnace conform /path/to/project --init
furnace conform /path/to/project --explainScaffolding writes editable version and theme lists and the layers supported by the repository. It preserves an existing manifest. Legacy browser suites still need to move to dev/tests/specs/ and use @furnace/playwright; scaffolding alone does not migrate them.
For a project with Magento modules under app/code/Qoliber/, a manifest can look like this. Declare only layers for which the project ships tests or needs checks.
json
{
"furnace": 1,
"name": "qoliber/your-extension",
"modules": ["app/code/Qoliber/*"],
"layers": ["unit", "integration", "cron", "production", "e2e"],
"matrix": {
"magento": ["2.4.6", "2.4.7", "2.4.8", "2.4.9"],
"mageos": ["1.3.1", "2.3.0", "3.4.0"],
"php": ["8.1", "8.2", "8.3", "8.4", "8.5"],
"themes": ["luma", "hyva"]
},
"requires": {
"magento": ">=2.4.6 <2.5",
"mageos": ">=1.3 <4.0",
"php": ">=8.1 <8.6",
"capabilities": []
},
"e2e": {
"suites": {
"hyva": { "requires": ["hyva"] }
}
}
}An editable example and an editor JSON schema ship as resources/furnace.example.json and resources/furnace.schema.json. Associate the schema with furnace.json in your editor, or set $schema to the schema's local absolute path. Runtime validation also checks Composer version constraints and whether selected release lines exist in the catalog.
Install method
json
"install": { "method": "composer", "source": "path" }| Field | Meaning |
|---|---|
method | composer installs the packages the modules declare with composer require; copy rsyncs module directories into the cell's app/code (legacy) |
source | path stages the working tree into a path repository (default); archive installs what composer archive would ship, so export-ignore and autoload mistakes surface; packagist requires a released version from the repositories the cell knows |
packages | optional subset of the modules' package names to install; by default every module's composer.json name. Required when modules is omitted |
version | optional constraint, only with source: packagist (default *) |
repositories | only with source: packagist: registry URLs (composer repositories) or {"type": "vcs", "url": ...} objects, added to each cell for the run. No credentials here: composer reads its own auth.json or COMPOSER_AUTH |
A manifest without install keeps the legacy copy and conformance warns about it. furnace conform --init writes composer when every module has a composer.json name. furnace run --install=composer|copy overrides the manifest's method for one run, --source=path|archive|packagist its source, and --release=<constraint> installs that released version from the registry (it implies packagist). Keep the committed manifest on path: it describes the repository, and a release test is a property of one run.
With composer, every module directory must carry a composer.json with a name, and the install gate fails if a package is already present in the cell (the golden is not pristine), if composer require fails, or if the package is not in vendor/composer/installed.json afterwards from the staged repository at the expected version. Modules without a version field are given one for the run only, in this order: the highest lower bound a sibling module places on the name, the nearest git tag, then 1.0.0. The staged copy drops export-ignore rules so Test/ reaches the cell; archive keeps them, and the repository's own Test/Integration then runs against the installed package. Teardown restores the golden's composer.json and composer.lock, runs composer install and verifies the vendor fingerprint before the cell is handed back.
With packagist, the package comes from a registry and the integration tests from the checkout, so the install gate compares the two: it warns when HEAD is not at a tag equal to the installed version, when the tree has uncommitted changes, or when the checkout is not a git repository. Composer finds private registries through its own credentials: the cell's auth.json, the global auth.json, or COMPOSER_AUTH. Repositories of type composer declared in the extension's root composer.json, and everything in install.repositories, are added to the cell for the run.
Harness: a manifest without code
Omit modules and the manifest is a harness: it names released packages, and composer installs them into each cell. This is how a suite is certified from the registry with no checkout, and how a harness package of its own (qoliber/furnace-advanced-faq, holding this file, a seed and specs) is published next to the extension it tests.
json
{
"furnace": 1,
"name": "qoliber/advanced-faq",
"install": {
"method": "composer",
"source": "packagist",
"repositories": ["https://qoliber.repo.packagist.com/qoliber-shop"],
"packages": ["qoliber/advanced-faq", "qoliber/advanced-faq-hyva"],
"version": "^1.0"
},
"layers": ["unit", "integration", "e2e"],
"matrix": { "magento": ["2.4.7", "2.4.8"], "mageos": ["3.4.0"] }
}The rules follow from the shape:
installmust becomposerfrompackagistand namepackages;copy,pathandarchiveneed module code and are refused, on the command line too.- Every package is installed by one
composer requirewith full dependency resolution, exactly as a customer would. With a range such as^1.0each cell's solver picks the release its platform allows, and the report names the installed version per cell. Nothing is downloaded anywhere else. - The packages that carry a
registration.phpbecome the modules, with their paths inside the cell'svendor. Rector, the syntax floor, static analysis and unit tests therefore run on each cell after the install instead of once before, with the cell'svendor/bin/phpunit,vendor/bin/phpstanand Magento's unit bootstrap. Aphpstan.neonorphpcs.xmlin the harness sets the rules; furnace supplies the paths, so the config must not. - Integration tests come from the installed packages. Specs and the seed come from the harness directory, so
layersmay still declaree2e. The verifier does not look forTest/UnitorTest/Integrationnext to the manifest, because they are in the packages. furnace runrequires--gate=all, and thenameis what the report is filed under, whatever the harness's owncomposer.jsonis called.
Version selection
| Field | Meaning |
|---|---|
matrix.magento | Magento release lines or explicit patch versions to test |
matrix.mageos | MageOS releases to test |
matrix.php | PHP minors used for the selected platforms and syntax checks |
matrix.themes | Required storefront families, exact paths, or versioned theme targets |
matrix.inventory | Inventory variants to prove: msi (Multi-Source Inventory present), legacy (removed), or both |
moduleRequires | Capabilities one module or package needs on a cell; on a cell lacking them it is left out and recorded n/a |
requires.magento, requires.mageos | Independent Composer constraints for the two platforms |
requires.php | Additional PHP constraint, intersected with every module's Composer claims |
A bare Magento line such as 2.4.8 follows the catalog's target patch. An explicit entry such as 2.4.8-p4 targets that patch; a newer patch is not equivalent coverage. An older cell on the same line can still be tested, but is reported as BEHIND. Only PHP combinations present in the platform catalog are generated. Selecting PHP 8.1 and 8.4 does not force an unsupported PHP 8.1 run on Magento 2.4.8.
Once either platform list is supplied, omitted platforms are disabled. For a Magento-only run, use "magento": ["2.4.8"] and omit mageos, or set it to []. Omitted PHP and theme lists retain the default coverage. Manifests without a matrix section continue to use the shared platform policy and all store views. Unknown fields, duplicate selections and an empty effective version selection produce actionable errors. A release line the policy no longer carries (2.4.4 and 2.4.5 since 2026-10-28) is reported and skipped rather than refused, so a manifest scaffolded while the line was current keeps running; remove it when you see the warning.
Theme selection
"themes": ["luma", "hyva"] requests both storefront families for every selected platform/PHP combination. Theme inheritance is respected: a custom child of Hyvä counts as Hyvä. Select a specific storefront with its exact path, such as "themes": ["Acme/shop"]. Furnace uses existing store views and does not switch their themes.
You can require separate Hyvä versions:
json
"themes": [
"luma",
{ "name": "hyva", "version": "~1.3.0" },
{ "name": "hyva", "version": "~1.5.0" }
]An unversioned family (luma, hyva) is demanded on every selected platform/PHP combination: every cell carries both store views, so that costs nothing. A versioned entry is a theme line, and one installation holds exactly one Hyvä version, so a line is covered fleet-wide instead: one cell anywhere in the admitted matrix whose matching store view passes the storefront suites. Demanding every line on every tuple would need a cell per line per tuple. The fleet keeps one cell per Hyvä line (1.2 on 2.4.6, 1.3 on 2.4.7, 1.4 on 2.4.8, 1.5 everywhere), chosen so each also fills a PHP tuple; see cells.
Version constraints use Composer package versions. Furnace reads versions for Magento/MageOS Luma and Blank, and Hyvä default and CSP themes from cell lockfiles. A family selector can use its known parent's version; a custom theme path's own version is not inferred from its parent. Unknown versions cannot satisfy a version constraint. furnace plan lists a line as any (theme line) with the cells that run it; a line with no cell, or no passing Hyvä or storefront specs on its cell, is a certification gap.
Hyvä 1.3 is Tailwind 3 and 1.4 onward is Tailwind 4. A suite that compiles on one and not the other is exactly what one 1.5 cell never shows, so an extension with a Hyvä companion should declare at least ~1.3.0 and ~1.5.0.
Keep Hyvä requirements in e2e.suites.hyva.requires when testing both themes. Putting hyva in the global requires.capabilities filters the entire project to Hyvä-capable cells. Generic storefront specs belong in specs/frontend/ and run on selected views; Hyvä-only specs belong in specs/hyva/ and run on Hyvä views. Theme coverage is enforced when the e2e layer is enabled. Certification requires passing frontend, hyva or roundtrip tests against an exact cell for each theme target; admin or API tests alone do not prove storefront coverage.
Inventory selection
Magento ships Multi-Source Inventory, and many merchants remove it outright with composer replace entries. That is a different dependency graph: a hard require on magento/module-inventory-api stops the customer's composer require, a plugin on an Inventory class breaks di:compile, code calling the salable-quantity services fatals. Furnace reads which kind a cell is (msi is advertised when Magento_InventoryApi is enabled) and lets a manifest ask for both to be proven:
json
"matrix": { "inventory": ["msi", "legacy"] }Inventory is a coverage axis, not a filter. Every matched cell still runs; the claim is that each named variant was exercised by at least one cell that went all the way through. furnace plan lists which cells cover each variant, the report carries inventoryCoverage, and a variant with no cell or no complete passing run is a certification gap. Requiring a legacy twin of every tuple would double the fleet for one axis, so one certified cell per variant is the bar.
An extension that needs MSI everywhere declares it the usual way, "requires": { "capabilities": ["msi"] }, and cells without it are skipped as n/a. The more common shape is a core module that works either way and a companion that only makes sense with MSI. Declare that per module:
json
"moduleRequires": {
"Acme_StockMsi": ["msi"],
"acme/stock-msi-hyva": ["msi", "hyva"]
}Keys are module names or composer package names (a harness has only the latter); values are capabilities from the same list as requires.capabilities. On a cell lacking one, that module and its package are left out of the install and of every gate that follows, and the cell's row opens with a scope line naming what was omitted and why. A cell on which every module is omitted is recorded n/a without being touched. A key that names nothing in the repository is a conformance problem, so a typo cannot silently require nothing.
Preview before running
bash
furnace plan /path/to/project
furnace plan /path/to/project --require-ready --json=/tmp/plan.json
furnace run /path/to/project --gate=all --json=/tmp/run.json --html=/tmp/run.htmlThe plan shows required platform/PHP combinations and a separate storefront table. Missing requested themes or versions prevent --require-ready from succeeding and prevent full-run certification. JSON and HTML reports preserve those gaps. --allow-incomplete accepts gaps for development runs while keeping certified: false in the report.