Skip to content

[FEATURE] Provision functional test instances in composer mode - #747

Open
bmack wants to merge 2 commits into
TYPO3:mainfrom
bmack:composer-mode-standalone
Open

bmack wants to merge 2 commits into
TYPO3:mainfrom
bmack:composer-mode-standalone

Conversation

@bmack

@bmack bmack commented Sep 8, 2026 •

Copy link
Copy Markdown
Member

Functional test instances can now be provisioned as composer mode installations. Until
now every instance is a classic mode installation, although composer mode is what most
projects run, so a whole installation layout - config/ and var/ beside a public/
document root, packages resolved from a package artifact, assets in _assets/ - never
gets tested.

TYPO3_TESTING_INSTANCE_MODE=composer

Nothing changes without the switch

Classic mode stays the default and behaves exactly as before: same instance path, same
database name, same setup steps, same API. In particular:

  • no public method of Testbase or FunctionalTestCase changes its signature
  • no new protected methods on FunctionalTestCase that could collide with methods of
    test cases in extensions
  • every composer mode code path runs only when the variable is set

Composer mode needs TYPO3 v14.3 or later, and says so with a clear message when it is
enabled on an older version.

How a composer mode instance is built

An instance is not its own composer installation. A shared installation containing every
extension the test cases load is built once per run (TYPO3 Core does that with
Build/Scripts/setupFunctionalComposerSuperset.php, TYPO3_TESTING_SUPERSET_PATH can
point elsewhere). Each instance borrows its vendor tree:

<instance>/vendor                 -> <shared>/vendor
<instance>/public/_assets         -> <shared>/public/_assets
<instance>/config/system          own configuration
<instance>/var/testing-framework  own package artifact
<instance>/public                 own document root, fileadmin, typo3temp/assets
<instance>/var                    own caches and logs

The package set of a test case is written into the instance as its own package artifact,
derived from the one the installer plugin built for the shared installation: every
package stays known, only the ones the test case loads are active. It is the composer mode
counterpart of the PackageStates.php of a classic instance, and TYPO3 reads it with its
own ComposerPackageArtifact. TestingBootstrapRunner points Bootstrap at it by
overriding createPackageCache(), so TYPO3 itself needs no change.

Classes are not loaded from the shared installation. The root autoloader already maps
every extension, so no second autoloader is registered and no third party package is
loaded twice.

What to look at

  1. The installed-but-inactive packages. In a real composer installation, installed
    and active are always the same set. Here the shared installation installs everything
    and each instance activates a subset. This is the one place the model departs from
    reality.
  2. SystemEnvironmentBuilder::determineCurrentScript(). On the CLI, composer mode
    reports the console binary as the entry script. Functional tests are CLI processes
    simulating web requests, and NormalizedParams derives the site path from the entry
    script, so every generated link lost its leading slash. In composer mode the entry
    script is public/index.php now, as for a web request.
  3. Console commands in sub processes. EXT:core/bin/typo3 finds its autoloader
    relative to itself, which resolves outside the instance. Composer mode instances get a
    generated entry point instead, with the instance paths written in, because Symfony
    Process does not pass on putenv() values.

For tests that run in both modes

TestInstance (internal, used by TYPO3 Core) has three helpers for tests that must
work in either layout:

  • boot(), for tests that bootstrap an application themselves
  • getCliEntryPoint(), for console commands in a sub process
  • getAppRelativePublicPath(), for PKG:typo3/app: resource identifiers, which are
    relative to the project root and not the document root

Verification

TYPO3 Core functional suite on SQLite, in 4 chunks as in CI:

  • Classic mode, this change vs. the unmodified testing framework: the same tests in
    every chunk. The only differences come from the Core companion patch's use of
    TestInstance, which does not exist without this change.
  • Composer mode: works end to end, including console sub processes and frontend
    requests. The remaining failures are in Core tests added or changed since the companion
    patch was prepared, which assume the classic layout. These tests call Bootstrap::init()
    directly, link into typo3conf/sites, or use the new file storage configuration. The
    companion patch is being updated for them.

Lint (PHP 8.2), CGL and the unit tests of this package are green.

@bmack
bmack force-pushed the composer-mode-standalone branch from 465ac8e to b1cefe1 Compare September 8, 2026 10:49
TYPO3 ships two installation modes and only one of them was covered by
functional tests: every test instance was a classic mode installation, even
though composer mode is what most projects run. Set

    TYPO3_TESTING_INSTANCE_MODE=composer

to provision composer mode instances instead. Classic mode stays the default,
and the mode is part of the instance identifier, so a test case's classic and
composer instances never collide.

A composer mode instance is not its own composer installation. One shared
installation containing every system extension and every fixture extension is
built once per run by Build/Scripts/setupFunctionalComposerSuperset.php, and
each instance borrows its vendor tree and its package artifact by symlink:

    <instance>/vendor          -> <shared>/vendor
    <instance>/public/_assets  -> <shared>/public/_assets
    <instance>/config/system   own configuration
    <instance>/public          own document root, fileadmin, typo3temp/assets
    <instance>/var             own caches and logs

Per test case only the *active* package set differs, which is expressed by
narrowing the artifact in memory. Building a real installation per test
configuration was measured at about five seconds each, which for the roughly
two hundred configurations in this repository would cost far more than the
provisioning this saves.

Classes are deliberately not loaded from the shared installation. The root
autoloader already maps every system extension and every fixture extension, so
no second autoloader is registered and no second copy of any third party
package is ever loaded. One consequence: class alias maps that the shared
installation registered at install time are not active, because the root
installation never processed those packages.

Notable details, each of which was a bug on the way and none of which fails
loudly:

* TYPO3 decides between the classic layout (typo3conf, typo3temp/var) and the
  composer layout (config, var) by whether the project root and the document
  root differ - not by composer mode. Test instances therefore set
  TYPO3_PATH_ROOT to the public directory in composer mode.
* Package::getPackagePath() resolves the artifact's relative path against
  Environment::getProjectPath() once and writes the result back. Caching one
  PackageCacheEntry for the process therefore binds every package to whichever
  instance touched it first, and every later instance looks for its packages
  inside that instance. Only the raw artifact data is cached; the entry is
  rebuilt whenever the instance changes.
* On the command line in composer mode TYPO3 reports the console binary as the
  entry script, because in a real installation it sits outside the document
  root. Functional tests are command line processes simulating web requests,
  and NormalizedParams derives the site URL by subtracting the entry script's
  directory from the request - so it subtracted typo3/sysext/core/bin/ from
  every request, the site path collapsed to an empty string, and every
  generated link silently lost its leading slash. The front end entry script
  is reported instead, which is what a web request has.
* A console command run in a sub process cannot use EXT:core/bin/typo3: that
  script derives its autoloader from its own directory, and since the shared
  installation's packages are symlinks into the checkout, it resolves back to
  the root autoloader, never defines TYPO3_COMPOSER_MODE and runs in classic
  mode. Composer mode instances get a generated entry point instead, which
  getCliEntryPoint() hands to the test.
* Applications bootstrapped by a test - the install tool, notably - must boot
  through bootTestInstance(), because Bootstrap resolves the artifact from the
  root installation, and the mono repo has none.
* fileadmin and typo3temp/assets are web accessible and belong below public/.
  Getting that wrong makes the default file storage point at nothing.
* Frontend sub requests bootstrap a second time and need the same narrowed
  package set. Their server parameters also have to name the real document
  root, or the site path is derived from a script outside the public path.
* The sites configuration cache removal in tearDown() has to use the layout's
  variable path, or it silently does nothing and the stale configuration leaks
  into the next test.
* Paths declared by test cases are given relative to the document root, which
  only carries the extension tree in classic mode; they now fall back to the
  original root.

A "PKG:typo3/app:" resource identifier carries a path relative to the project
root, which is the document root only in classic mode, so
getAppRelativePublicPath() is provided for tests naming such resources.

TestingBootstrapRunner overrides Bootstrap::createPackageCache() through late
static binding, so no change to typo3/cms is required.
Composer mode is opt-in, and everyone who does not opt in - TYPO3 v14
and v15 extensions tested from the same branch included - must not
notice it. A few parts of the first version reached into classic mode,
or into API that extensions build on:

* The instance identifier, and with it the instance path and database
  name, included the mode for classic instances too. It is unchanged
  now, only composer instances get a suffix.
* Paths to link or provide fell back to the original root in classic
  mode as well, so a wrong path no longer failed. The fallback is
  limited to composer mode.
* Testbase::setUpLocalConfiguration() and setUpBasicTypo3Bootstrap()
  gained parameters, which is fatal for subclasses overriding them.
  Both keep their signatures and read the mode themselves.
* FunctionalTestCase gained four final protected methods, which are
  fatal for test cases that already have a method of the same name.
  The mode check is private now, and the helpers tests need are moved
  to the new TestInstance class.
* TestingBootstrapRunner extends Bootstrap as a readonly class, which
  is a fatal error before TYPO3 v14.3. Enabling composer mode on an
  older version now fails with a message instead.

Each instance's package set is now written to its own package
artifact, the composer mode counterpart of PackageStates.php, and
read by TYPO3's ComposerPackageArtifact. That replaces the in-memory
package cache and the static state it needed, and the console entry
point no longer has to carry the extension list. Package objects are
unserialized freshly on every boot, so they can no longer be bound to
the wrong instance. The narrowed set keeps the dependency order of the
shared installation's artifact.

Also: the duplicate exception code is replaced, and the default
extension list exists once.
@bmack
bmack force-pushed the composer-mode-standalone branch from b1cefe1 to ac1e364 Compare November 14, 2026 14:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant