Skip to content

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

Open
bmack wants to merge 1 commit into
TYPO3:mainfrom
bmack:composer-mode-standalone
Open

[FEATURE] Provision functional test instances in composer mode#747
bmack wants to merge 1 commit into
TYPO3:mainfrom
bmack:composer-mode-standalone

Conversation

@bmack

@bmack bmack commented Sep 8, 2026

Copy link
Copy Markdown
Member

Adds composer mode to functional test instances. Today every instance is a classic
mode installation, even though composer mode is what nearly every project runs, so a
whole installation layout goes untested.

TYPO3_TESTING_INSTANCE_MODE=composer

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

This replaces the stacked version

An earlier form of this work sat on top of #740 and needed its configuration-keyed
instance sharing. It does not any more. This branch applies to main on its own,
keeps getInstanceIdentifier() / getInstancePath() static exactly as they are today,
and introduces no breaking change — it is a plain [FEATURE].

That reordering is backed by a measurement rather than a preference. One chunk of the
TYPO3 core functional corpus (2117 tests), four arms, same machine:

instance sharing on sharing off
classic 160 s 207 s ← behaviour today
composer 113 s 134 s ← this PR

Composer mode without any instance sharing is faster than classic mode with it.
Sharing is worth a further ~16 %; composer mode itself is worth 35 %. So the sharing
mechanism is an optimisation on top, not a prerequisite, and it should be reviewed on
its own merits instead of blocking this.

How a composer mode instance is built

An instance is not its own composer installation. One shared installation containing
every system extension and every fixture extension is built once per run, and each
instance borrows its vendor tree and 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, expressed by narrowing the artifact
in memory. A real installation per test configuration measures ~3.4 s, which across the
~142 distinct extension sets in core would cost more than this saves — though that
trade-off is worth revisiting, see below.

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 loaded.

What to look at

Most of the diff is mechanism. Two things deserve real scrutiny:

  1. ComposerModeInstance::createPackageCache() — the narrowing. It hands the package
    manager every installed package while marking only some active. In a real composer
    installation installed and active are always the same set; there is no
    installed-but-inactive state. This is the one place the model departs from reality,
    and the direction of travel is to replace it with one real installation per extension
    set (measured: ~3.4 s each, ~1.8 MB of distinct bytes per set, the third-party tree
    being byte-identical across all of them).

  2. SystemEnvironmentBuilder::determineCurrentScript() — on the CLI, composer mode
    reports the console binary as the entry script, because in a real installation it sits
    outside the document root. Functional tests are CLI processes simulating web requests,
    and NormalizedParams derives the site URL by subtracting the entry script's directory
    from the request. Left alone it subtracted typo3/sysext/core/bin/, the site path
    collapsed to an empty string, and every generated link silently lost its leading slash.

Everything else in the commit message is a bug that was hit on the way, each of which
failed far from its cause.

Verification

Both modes on the same corpus chunk, run back to back:

classic   2117 tests, 9088 assertions, 0 failures
composer  2117 tests, 9087 assertions, 0 failures, 1 skip

The full corpus has been run in both modes against all four DBMS with matching test
counts. CGL, lint, PHPStan and the unit suite are green locally.

Core needs a companion patch for tests that assume the classic layout; it is prepared and
waits on this.

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.
@bmack
bmack force-pushed the composer-mode-standalone branch from 465ac8e to b1cefe1 Compare September 8, 2026 10:49
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