diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5a579d6..02c82cb 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -61,17 +61,17 @@ jobs: working-directory: app/vendor/playwright-php/playwright/bin run: npx --no-install playwright install-deps chromium - # Boots its own postgres in docker (tests/e2e/fixture.php), so there is no service to - # declare here. Linux only, and that is the whole story: the checks drive frankenphp - # and a headless chromium, never the native shell, so a second platform would re-run - # the same assertions — at 10x on macOS. + # Boots its own postgres and mysql in docker (tests/e2e/harness/fixture.php), so there is + # no service to declare here. Linux only, and that is the whole story: the scenarios drive + # frankenphp and a headless chromium, never the native shell, so a second platform would + # re-run the same assertions — at 10x on macOS. - run: make e2e FRANKEN_ASSET=frankenphp-linux-x86_64 - # A failed assertion names the check and the selector; the screenshot is the only way - # to see what the page actually looked like on a runner nobody was watching. + # A failed step names the scenario and the step; the picture and the HTML beside it are the + # only way to see what the page actually looked like on a runner nobody was watching. - if: failure() uses: actions/upload-artifact@v7 with: - name: e2e-screenshots - path: tests/e2e/screenshots + name: e2e-artifacts + path: tests/e2e/artifacts if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index a164755..098c87f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,8 +11,8 @@ # truth; the trees themselves are built by `mise run install`, not committed. /node_modules /app/vendor/ -# e2e artifacts: screenshots the browser check writes. -/tests/e2e/screenshots/ +# What a failed step left behind: the picture and the HTML of the page it failed on. +/tests/e2e/artifacts/ /app/adminer.php /app/editor.php /app/src/Settings/Plugins/available diff --git a/Makefile b/Makefile index a8c3a8f..40cf02f 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ else EXE = .exe endif -.PHONY: help install linux-deps fetch verify qa phpstan phpcs golangci biome security check check-app e2e i18n i18n-check build run dev editor debug demo down destroy bundle zip dist tarball windows deb logs serve clean checksums +.PHONY: help install linux-deps fetch verify qa phpstan phpcs golangci biome security check check-app e2e e2e-visual i18n i18n-check build run dev editor debug demo down destroy bundle zip dist tarball windows deb logs serve clean checksums .DEFAULT_GOAL := help @@ -305,13 +305,18 @@ qa: bin/frankenphp$(EXE) app/vendor i18n ## Run every static check (php, go, js check: fetch app/vendor ## Boot the app, assert before-login behaviour (prefill, design, plugins) ./check.sh -# Browser end-to-end check: logs in, asserts the theme applies in light and dark, and -# writes screenshots to tests/e2e/screenshots/. Needs docker (a throwaway postgres) and -# the Playwright browser from `mise run install`. Kept out of `qa` because it is slow and -# needs docker; run it on its own. -e2e: fetch ## Browser check: login + theme in light and dark (needs docker) +# The end-to-end scenarios: Behat driving a real browser through the app, against a throwaway +# postgres and mysql in docker. app/vendor because behat and playwright live there. Kept out of +# `qa` because it is slow and needs docker; run it on its own. A failed step leaves the page it +# failed on in tests/e2e/artifacts/, which the workflow uploads. +e2e: fetch app/vendor ## Every scenario, against both drivers (needs docker) mise run e2e +# The same, in a browser you can watch: this is how a scenario is written, and how a failing one +# is understood. `make e2e-visual SUITE=mysql` or `ARGS='--name "Sorting"'` narrows it down. +e2e-visual: fetch app/vendor ## The same, in a browser you can watch (needs docker) + mise run e2e-visual + ##@ Build & run # About reads these, so it can never disagree with what is actually bundled. @@ -360,29 +365,33 @@ debug: build ## Run with Safari's Web Inspector attached # a shipped build never auto-logs-in. `make down` kills the container when you are done. # vendor/ because dev serves app/ and Latte renders from it. The seed drops and recreates, # so re-running just refreshes the data. +# The postgres one is shared with the e2e (tests/e2e/harness/fixture.php names it), so `make demo` +# and a run reuse each other's database rather than each leaving one behind. DEMO_MYSQL is only +# ever started by the e2e; it is named here so `down` and `destroy` can clean up after it. DEMO_PG = adminer-demo-pg +DEMO_MYSQL = adminer-demo-mysql demo: build app/vendor ## Run against seeded demo data, opened logged in (needs docker) @docker start $(DEMO_PG) >/dev/null 2>&1 || docker run -d --name $(DEMO_PG) \ -e POSTGRES_PASSWORD=demo -e POSTGRES_DB=demo -p 55432:5432 postgres:18-alpine >/dev/null @echo "waiting for postgres ..." && until docker exec $(DEMO_PG) pg_isready -U postgres >/dev/null 2>&1; do sleep 1; done - @docker exec -i $(DEMO_PG) psql -U postgres -d demo -v ON_ERROR_STOP=1 < tests/e2e/seed.sql >/dev/null + @docker exec -i $(DEMO_PG) psql -U postgres -d demo -v ON_ERROR_STOP=1 < tests/e2e/seed/pgsql.sql >/dev/null @echo "demo data ready on 127.0.0.1:55432 (postgres / demo / demo)" ADMINER_DESKTOP_DEMO='pgsql 127.0.0.1:55432 postgres demo demo' ./build/adminer-desktop$(EXE) -dev -# Kill the demo database container `make demo` left running. -down: ## Stop the demo database container - -docker rm -f $(DEMO_PG) +# Kill the database containers `make demo` and the e2e left running. +down: ## Stop the demo database containers + -docker rm -f $(DEMO_PG) $(DEMO_MYSQL) -# The same, plus the anonymous volume postgres keeps its data in — which plain `rm -f` leaves -# behind, dangling and named after nothing. Reach for this when tests/e2e/seed.sql changed: the -# e2e fixture reuses a container that is already up and never reseeds, so a new table only -# reaches the database when one is created from scratch. +# The same, plus the anonymous volumes the databases keep their data in — which plain `rm -f` +# leaves behind, dangling and named after nothing. Rarely needed now that the fixture reseeds on +# every run: an edited seed reaches the database without this. Reach for it when a container is +# wedged, or to get the disk back. # # `rm -v` and not `volume prune`, which would take every other project's anonymous volumes on -# this machine with it — this removes the ones attached to our container and nothing else. -destroy: ## Remove the demo database container and its data volume - -docker rm -fv $(DEMO_PG) +# this machine with it — this removes the ones attached to our containers and nothing else. +destroy: ## Remove the demo database containers and their data volumes + -docker rm -fv $(DEMO_PG) $(DEMO_MYSQL) # Same startup path as `run`, minus the window — so it works over ssh and in CI. check-app: build diff --git a/app/composer.json b/app/composer.json index 7dcdb87..8d7631f 100644 --- a/app/composer.json +++ b/app/composer.json @@ -4,6 +4,7 @@ "license": "Apache-2.0", "type": "project", "require-dev": { + "behat/behat": "^3.32", "dg/composer-cleaner": "^2.2", "nette/tester": "^2.5", "playwright-php/playwright": "^1.2", @@ -25,5 +26,16 @@ "psr-4": { "Desktop\\": "src/" } + }, + "autoload-dev": { + "psr-4": { + "Desktop\\Tests\\": "../tests/e2e/bootstrap/" + } + }, + "extra": { + "cleaner-ignore": { + "behat/behat": true, + "behat/gherkin": true + } } } diff --git a/app/composer.lock b/app/composer.lock index b52f3e6..7d5795f 100644 --- a/app/composer.lock +++ b/app/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "228e7253495d04f7cf68446ddf2e8ac1", + "content-hash": "4147f60758c4b07599e54d075bb68ed1", "packages": [ { "name": "latte/latte", @@ -179,6 +179,335 @@ } ], "packages-dev": [ + { + "name": "behat/behat", + "version": "v3.32.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Behat.git", + "reference": "b9b89cf7a3b24c04d6e2d2865ed51bc5e1700de9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Behat/zipball/b9b89cf7a3b24c04d6e2d2865ed51bc5e1700de9", + "reference": "b9b89cf7a3b24c04d6e2d2865ed51bc5e1700de9", + "shasum": "" + }, + "require": { + "behat/gherkin": "^4.17.0", + "composer-runtime-api": "^2.2", + "composer/xdebug-handler": "^1.4 || ^2.0 || ^3.0", + "ext-mbstring": "*", + "nikic/php-parser": "^4.19.2 || ^5.2", + "php": ">=8.2 <8.6", + "psr/container": "^1.0 || ^2.0", + "symfony/config": "^5.4 || ^6.4 || ^7.0", + "symfony/console": "^5.4.9 || ^6.4 || ^7.0", + "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0", + "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0", + "symfony/translation": "^5.4 || ^6.4 || ^7.0", + "symfony/yaml": "^5.4 || ^6.4 || ^7.0" + }, + "require-dev": { + "opis/json-schema": "^2.5", + "php-cs-fixer/shim": "^3.89", + "phpstan/phpstan": "2.1.46", + "phpunit/phpunit": "^9.6", + "rector/rector": "2.3.9", + "sebastian/diff": "^4.0", + "symfony/filesystem": "^5.4 || ^6.4 || ^7.0", + "symfony/polyfill-php84": "^1.31", + "symfony/process": "^5.4 || ^6.4 || ^7.0" + }, + "suggest": { + "ext-dom": "Needed to output test results in JUnit format." + }, + "bin": [ + "bin/behat" + ], + "type": "library", + "autoload": { + "psr-4": { + "Behat\\Hook\\": "src/Behat/Hook/", + "Behat\\Step\\": "src/Behat/Step/", + "Behat\\Behat\\": "src/Behat/Behat/", + "Behat\\Config\\": "src/Behat/Config/", + "Behat\\Testwork\\": "src/Behat/Testwork/", + "Behat\\Transformation\\": "src/Behat/Transformation/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + } + ], + "description": "Scenario-oriented BDD framework for PHP", + "homepage": "https://behat.org/", + "keywords": [ + "Agile", + "BDD", + "ScenarioBDD", + "Scrum", + "StoryBDD", + "User story", + "business", + "development", + "documentation", + "examples", + "symfony", + "testing" + ], + "support": { + "issues": "https://github.com/Behat/Behat/issues", + "source": "https://github.com/Behat/Behat/tree/v3.32.0" + }, + "funding": [ + { + "url": "https://github.com/acoulton", + "type": "github" + }, + { + "url": "https://github.com/carlos-granados", + "type": "github" + }, + { + "url": "https://github.com/stof", + "type": "github" + } + ], + "time": "2026-06-20T08:34:52+00:00" + }, + { + "name": "behat/gherkin", + "version": "v4.17.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Gherkin.git", + "reference": "5c8b3149fac39b5a79942b64eeec59a5ee4001c0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Gherkin/zipball/5c8b3149fac39b5a79942b64eeec59a5ee4001c0", + "reference": "5c8b3149fac39b5a79942b64eeec59a5ee4001c0", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "php": ">=8.1 <8.6" + }, + "require-dev": { + "cucumber/gherkin-monorepo": "dev-gherkin-v39.1.0", + "friendsofphp/php-cs-fixer": "^3.77", + "mikey179/vfsstream": "^1.6", + "phpstan/extension-installer": "^1", + "phpstan/phpstan": "^2", + "phpstan/phpstan-phpunit": "^2", + "phpunit/phpunit": "^10.5", + "symfony/yaml": "^5.4 || ^6.4 || ^7.0" + }, + "suggest": { + "symfony/yaml": "If you want to parse features, represented in YAML files" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-4": { + "Behat\\Gherkin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "https://everzet.com" + } + ], + "description": "Gherkin DSL parser for PHP", + "homepage": "https://behat.org/", + "keywords": [ + "BDD", + "Behat", + "Cucumber", + "DSL", + "gherkin", + "parser" + ], + "support": { + "issues": "https://github.com/Behat/Gherkin/issues", + "source": "https://github.com/Behat/Gherkin/tree/v4.17.0" + }, + "funding": [ + { + "url": "https://github.com/acoulton", + "type": "github" + }, + { + "url": "https://github.com/carlos-granados", + "type": "github" + }, + { + "url": "https://github.com/stof", + "type": "github" + } + ], + "time": "2026-05-18T09:33:47+00:00" + }, + { + "name": "composer/pcre", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<2.2.2" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.4.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-06-07T11:47:49+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, { "name": "dealerdirect/phpcodesniffer-composer-installer", "version": "v1.2.1", @@ -405,6 +734,63 @@ }, "time": "2026-05-11T18:11:12+00:00" }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, { "name": "phpstan/phpdoc-parser", "version": "2.3.3", @@ -641,6 +1027,56 @@ }, "time": "2021-11-05T16:47:00+00:00" }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -835,51 +1271,128 @@ ], "time": "2025-11-10T16:43:36+00:00" }, + { + "name": "symfony/config", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "b18e33881ef402ad940f36e85935420624009bf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/b18e33881ef402ad940f36e85935420624009bf4", + "reference": "b18e33881ef402ad940f36e85935420624009bf4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T12:54:40+00:00" + }, { "name": "symfony/console", - "version": "v8.1.1", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d" + "reference": "088ec6fe0ef6819cbc301174093b6bfa4ad26930" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", - "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", + "url": "https://api.github.com/repos/symfony/console/zipball/088ec6fe0ef6819cbc301174093b6bfa4ad26930", + "reference": "088ec6fe0ef6819cbc301174093b6bfa4ad26930", "shasum": "" }, "require": { - "php": ">=8.4.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php85": "^1.32", + "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.4.6|^8.0.6" + "symfony/string": "^7.2|^8.0" }, "conflict": { - "symfony/dependency-injection": "<8.1", - "symfony/event-dispatcher": "<8.1" + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^8.1", - "symfony/event-dispatcher": "^8.1", - "symfony/filesystem": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/lock": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0", - "symfony/uid": "^7.4|^8.0", - "symfony/validator": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -913,7 +1426,91 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.1" + "source": "https://github.com/symfony/console/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T13:51:00+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "b7825671c553af46a98c744e23f37f972aee6427" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/b7825671c553af46a98c744e23f37f972aee6427", + "reference": "b7825671c553af46a98c744e23f37f972aee6427", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.15" }, "funding": [ { @@ -933,7 +1530,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T12:55:20+00:00" + "time": "2026-07-22T08:40:50+00:00" }, { "name": "symfony/deprecation-contracts", @@ -1007,42 +1604,50 @@ "time": "2026-06-05T06:23:12+00:00" }, { - "name": "symfony/polyfill-ctype", - "version": "v1.37.0", + "name": "symfony/event-dispatcher", + "version": "v7.4.15", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "336e7f3b9e95aba04f93ea9143920c2186abfbb9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", - "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/336e7f3b9e95aba04f93ea9143920c2186abfbb9", + "reference": "336e7f3b9e95aba04f93ea9143920c2186abfbb9", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" }, "provide": { - "ext-ctype": "*" + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" }, - "suggest": { - "ext-ctype": "For best performance" + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1050,24 +1655,18 @@ ], "authors": [ { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for ctype functions", + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" - ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.15" }, "funding": [ { @@ -1087,41 +1686,39 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-07-21T15:13:06+00:00" }, { - "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { - "php": ">=7.2" - }, - "suggest": { - "ext-intl": "For best performance" + "php": ">=8.1", + "psr/event-dispatcher": "^1" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + "Symfony\\Contracts\\EventDispatcher\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -1138,18 +1735,576 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's grapheme_* functions", + "description": "Generic abstractions related to dispatching event", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "grapheme", - "intl", - "polyfill", - "portable", - "shim" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v8.1.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "17856b7a222664a26a5ea1cb06ee0721c2438217" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/17856b7a222664a26a5ea1cb06ee0721c2438217", + "reference": "17856b7a222664a26a5ea1cb06ee0721c2438217", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.1.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-22T15:42:13+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-deepclone", + "version": "v1.40.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-deepclone.git", + "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/dca4ccba5f360070b574414dce4c1e7a559844fa", + "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "provide": { + "ext-deepclone": "*" + }, + "suggest": { + "ext-deepclone": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\DeepClone\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the deepclone extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "deepclone", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.40.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-12T07:27:17+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/process", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/process/tree/v8.1.0" }, "funding": [ { @@ -1169,44 +2324,46 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.38.0", + "name": "symfony/service-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", - "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, - "suggest": { - "ext-intl": "For best performance" + "conflict": { + "ext-psr": "<1.1|>=2" }, "type": "library", "extra": { "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + "Symfony\\Contracts\\Service\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Test/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1223,18 +2380,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", + "description": "Generic abstractions related to writing services", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "intl", - "normalizer", - "polyfill", - "portable", - "shim" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -1254,46 +2411,50 @@ "type": "tidelift" } ], - "time": "2026-05-25T13:48:31+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { - "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", + "name": "symfony/string", + "version": "v8.1.2", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + "url": "https://github.com/symfony/string.git", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", "shasum": "" }, "require": { - "ext-iconv": "*", - "php": ">=7.2" + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" }, - "provide": { - "ext-mbstring": "*" + "conflict": { + "symfony/translation-contracts": "<2.5" }, - "suggest": { - "ext-mbstring": "For best performance" + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, "autoload": { "files": [ - "bootstrap.php" + "Resources/functions.php" ], "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1309,17 +2470,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the Mbstring extension", + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + "source": "https://github.com/symfony/string/tree/v8.1.2" }, "funding": [ { @@ -1339,41 +2501,67 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:59:30+00:00" + "time": "2026-07-28T07:35:25+00:00" }, { - "name": "symfony/polyfill-php85", - "version": "v1.38.1", + "name": "symfony/translation", + "version": "v7.4.14", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + "url": "https://github.com/symfony/translation.git", + "reference": "a1af4dacb24eb7ef4f1ca71b94da8ddbce572281" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "url": "https://api.github.com/repos/symfony/translation/zipball/a1af4dacb24eb7ef4f1ca71b94da8ddbce572281", + "reference": "a1af4dacb24eb7ef4f1ca71b94da8ddbce572281", "shasum": "" }, "require": { - "php": ">=7.2" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" }, + "type": "library", "autoload": { "files": [ - "bootstrap.php" + "Resources/functions.php" ], "psr-4": { - "Symfony\\Polyfill\\Php85\\": "" + "Symfony\\Component\\Translation\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1382,24 +2570,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + "source": "https://github.com/symfony/translation/tree/v7.4.14" }, "funding": [ { @@ -1419,32 +2601,41 @@ "type": "tidelift" } ], - "time": "2026-05-26T02:25:22+00:00" + "time": "2026-06-06T09:33:19+00:00" }, { - "name": "symfony/process", - "version": "v8.1.0", + "name": "symfony/translation-contracts", + "version": "v3.7.1", "source": { "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5" + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", - "reference": "c4a9e58f235a6bf7f97ffbfedae2687353ac79e5", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", "shasum": "" }, "require": { - "php": ">=8.4.1" + "php": ">=8.1" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, "autoload": { "psr-4": { - "Symfony\\Component\\Process\\": "" + "Symfony\\Contracts\\Translation\\": "" }, "exclude-from-classmap": [ - "/Tests/" + "/Test/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1453,18 +2644,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Executes commands in sub-processes", + "description": "Generic abstractions related to translation", "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "support": { - "source": "https://github.com/symfony/process/tree/v8.1.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" }, "funding": [ { @@ -1484,46 +2683,39 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { - "name": "symfony/service-contracts", - "version": "v3.7.1", + "name": "symfony/var-exporter", + "version": "v8.1.3", "source": { "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "url": "https://github.com/symfony/var-exporter.git", + "reference": "766dac532a04d8980b4d83183fd3d1ea284bac11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/766dac532a04d8980b4d83183fd3d1ea284bac11", + "reference": "766dac532a04d8980b4d83183fd3d1ea284bac11", "shasum": "" }, "require": { - "php": ">=8.1", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-deepclone": "^1.40" }, - "conflict": { - "ext-psr": "<1.1|>=2" + "require-dev": { + "symfony/property-access": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } - }, "autoload": { "psr-4": { - "Symfony\\Contracts\\Service\\": "" + "Symfony\\Component\\VarExporter\\": "" }, "exclude-from-classmap": [ - "/Test/" + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1540,18 +2732,21 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Generic abstractions related to writing services", + "description": "Provides tools to export, instantiate, hydrate, clone and lazy-load PHP objects", "homepage": "https://symfony.com", "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" + "clone", + "construct", + "deep-clone", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/var-exporter/tree/v8.1.3" }, "funding": [ { @@ -1571,46 +2766,40 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-29T16:43:23+00:00" }, { - "name": "symfony/string", - "version": "v8.1.0", + "name": "symfony/yaml", + "version": "v7.4.15", "source": { "type": "git", - "url": "https://github.com/symfony/string.git", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + "url": "https://github.com/symfony/yaml.git", + "reference": "e101850ded5d2c0d44bf32abb8996404afec2dec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "url": "https://api.github.com/repos/symfony/yaml/zipball/e101850ded5d2c0d44bf32abb8996404afec2dec", + "reference": "e101850ded5d2c0d44bf32abb8996404afec2dec", "shasum": "" }, "require": { - "php": ">=8.4.1", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-intl-grapheme": "^1.33", - "symfony/polyfill-intl-normalizer": "^1.0", - "symfony/polyfill-mbstring": "^1.0" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/translation-contracts": "<2.5" + "symfony/console": "<6.4" }, "require-dev": { - "symfony/emoji": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/intl": "^7.4|^8.0", - "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^7.4|^8.0" + "symfony/console": "^6.4|^7.0|^8.0" }, + "bin": [ + "Resources/bin/yaml-lint" + ], "type": "library", "autoload": { - "files": [ - "Resources/functions.php" - ], "psr-4": { - "Symfony\\Component\\String\\": "" + "Symfony\\Component\\Yaml\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -1622,26 +2811,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", - "keywords": [ - "grapheme", - "i18n", - "string", - "unicode", - "utf-8", - "utf8" - ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.0" + "source": "https://github.com/symfony/yaml/tree/v7.4.15" }, "funding": [ { @@ -1661,7 +2842,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-21T15:13:06+00:00" } ], "aliases": [], diff --git a/mise.toml b/mise.toml index 9134da0..3471025 100644 --- a/mise.toml +++ b/mise.toml @@ -55,6 +55,15 @@ run = "npx --no-install @biomejs/biome format --write ." description = "Check formatting and lint with Biome" run = "npx --no-install @biomejs/biome check ." +# Behat, on the bundled frankenphp like everything else here. SUITE picks one driver +# (`make e2e SUITE=mysql`) and ARGS is passed through, so a single feature or a scenario by name +# needs no second task: `make e2e ARGS=--name=Sorting`. [tasks.e2e] -description = "Run the browser end-to-end tests" -run = "./bin/frankenphp php-cli tests/e2e/run.php" +description = "Run the end-to-end scenarios" +run = './bin/frankenphp php-cli app/vendor/bin/behat -c tests/e2e/behat.yml ${SUITE:+--suite=$SUITE} $ARGS' + +# The browser, shown and slowed down enough to follow. The fixture reads the variable. +[tasks.e2e-visual] +description = "Run the end-to-end scenarios in a browser you can watch" +env = { ADMINER_DESKTOP_E2E_HEADED = "1" } +run = './bin/frankenphp php-cli app/vendor/bin/behat -c tests/e2e/behat.yml ${SUITE:+--suite=$SUITE} $ARGS' diff --git a/tests/e2e/behat.yml b/tests/e2e/behat.yml new file mode 100644 index 0000000..3d4da55 --- /dev/null +++ b/tests/e2e/behat.yml @@ -0,0 +1,43 @@ +# One suite per driver, so `make e2e` covers both in one run. +# +# What decides where a feature goes is whether the driver could change the answer: +# +# features/data/ the data list and the edit form — Adminer talking to a database through us. +# A column type, a JSON value or a foreign key reads differently per driver, so +# these run against every one of them. +# features/shell/ the desktop's own chrome: the theme, the settings dialog, the sidebar, the +# import dropzone, the MCP endpoint. None of it asks the database anything, so a +# second suite would spend a second browser proving the same CSS. pgsql only. +# +# A feature that only one driver has would go in features//, which is the same rule one +# step further. There is none yet. +# +# The driver is handed to the contexts; each suite serves the app on a port of its own, because +# the suites share a process (harness/fixture.php picks the next free one from there). + +# The contexts are autoloaded by composer, not by Behat's own loader: app/composer.json maps +# Desktop\Tests\ to bootstrap/ under autoload-dev, so they resolve the same way every other class +# in this repository does — and phpstan and the IDE see them too. +default: + suites: + pgsql: + paths: + - "%paths.base%/features/data" + - "%paths.base%/features/shell" + - "%paths.base%/features/pgsql" + contexts: + - Desktop\Tests\DesktopContext: + driver: pgsql + - Desktop\Tests\McpContext: + driver: pgsql + + mysql: + paths: + - "%paths.base%/features/data" + contexts: + - Desktop\Tests\DesktopContext: + driver: mysql + + formatters: + pretty: + paths: false # the steps all live in three files, naming them after each step says nothing diff --git a/tests/e2e/bootstrap/DesktopContext.php b/tests/e2e/bootstrap/DesktopContext.php new file mode 100644 index 0000000..f495672 --- /dev/null +++ b/tests/e2e/bootstrap/DesktopContext.php @@ -0,0 +1,1397 @@ + */ + private array $fix; + + private PageInterface $page; + + private string $driver; + + /** @var array what the last measured action started from */ + private array $before = []; + + /** @var array and what it ended at */ + private array $after = []; + + /** @var ?string the column the last drag grabbed, so the assertions need not name it twice */ + private ?string $dragged = null; + + public function __construct(string $driver) + { + $this->driver = $driver; + } + + #[BeforeScenario] + public function open(): void + { + putenv("ADMINER_DESKTOP_E2E_DRIVER=$this->driver"); // read by e2e_driver() + $this->fix = e2e_fixture(); + $this->page = $this->newPage(); + } + + #[AfterScenario] + public function close(): void + { + $this->page->context()->close(); + } + + /** Save the page a step failed on, so a failure in CI is more than a line of text. + * + * isset() because booting the fixture can fail before there is a page, and a second error + * thrown from here would be the only one reported. + */ + #[AfterStep] + public function report(AfterStepScope $scope): void + { + if (!$scope->getTestResult()->isPassed() && isset($this->page)) { + $name = basename($scope->getFeature()->getFile(), '.feature') . '-' . $scope->getStep()->getLine(); + e2e_report($this->page, $this->fix, $name); + } + } + + // ── arranging ──────────────────────────────────────────────────────────────────────────── + + #[Given('the settings are at their defaults')] + public function settingsAreDefault(): void + { + @unlink($this->fix['data'] . '/settings.json'); + } + + /** Straight to settings.json rather than through the dialog: check.sh already proves the POST + * path, and a plugin feature is about what the plugin does once it is on. One at a time, + * because several hook the same thing and the first non-empty return wins. + */ + #[Given('only the :plugin plugin is on')] + public function onlyPluginIsOn(string $plugin): void + { + file_put_contents( + $this->fix['data'] . '/settings.json', + (string) json_encode(['plugins' => [$plugin => true]]), + ); + } + + /** The scheme the OS is pretending to be is a browser-context option, so this opens a new one — + * which is why it comes before logging in rather than after. + */ + #[Given('the browser is in the :scheme scheme')] + public function browserIsInScheme(string $scheme): void + { + $this->page->context()->close(); + $this->page = $this->newPage(['colorScheme' => $scheme]); + } + + #[Given('I am logged in')] + public function logIn(): void + { + e2e_login($this->page, $this->fix); + } + + // ── going places ───────────────────────────────────────────────────────────────────────── + + #[When('I open the :table table')] + public function openTable(string $table): void + { + $this->go(['select' => $table]); + } + + #[When('I open the :table table :limit rows to a page')] + public function openTableWithLimit(string $table, string $limit): void + { + $this->go(['select' => $table, 'limit' => $limit]); + } + + #[When('I open the edit form for :table row :id')] + public function openEditForm(string $table, string $id): void + { + $this->go(['edit' => $table, 'where[id]' => $id]); + } + + /** The import page is the sql page in import mode: `import=` is what flips it (adminer.php sets + * $_GET["sql"] from it), and that is the page carrying the sql_file[] upload. + */ + #[When('I open the import page')] + public function openImportPage(): void + { + $this->go(['import' => '']); + } + + #[When('I reload the page')] + public function reload(): void + { + $this->goto($this->page->url()); + } + + // ── the data list ──────────────────────────────────────────────────────────────────────── + + /** Adminer's heading link, addressed by the column it sorts rather than by its position: the + * span beside it holds the search and descending links, and the count differs per driver. + */ + #[When('I sort by the :column column')] + public function sortBy(string $column): void + { + $this->before = $this->list(); + $this->page->evaluate("() => document.querySelector('[id=\"th[$column]\"] a').click()"); + $this->after = $this->settled($this->before, fn (): array => $this->list()); + } + + #[When('I step to the next page')] + public function stepToNextPage(): void + { + $this->before = $this->list() + $this->pager(); + // The first control that is a link: on page one, first and previous are spans. + $this->page->evaluate("() => [...document.querySelectorAll('a.ad-page-step')][0].click()"); + $this->after = $this->settled($this->before, fn (): array => $this->list() + $this->pager()); + } + + #[When('I pick page :number from the list')] + public function pickPage(string $number): void + { + $this->before = $this->list() + $this->pager(); + // By value, not by label: the option labelled 10 is page 9, and a bare string would match + // the label. Adminer numbers pages from zero. + $value = (int) $number - 1; + $this->page->evaluate("() => { + const list = document.querySelector('.ad-page-select'); + list.value = '$value'; + list.dispatchEvent(new Event('change')); + }"); + $this->after = $this->settled($this->before, fn (): array => $this->list() + $this->pager()); + } + + #[When('I pick :limit rows a page')] + public function pickPageSize(string $limit): void + { + $this->page->locator("#form select[name='limit']")->selectOption($limit); + $this->page->waitForURL("**limit=$limit**"); + $this->page->waitForLoadState('networkidle'); + } + + #[Then('the rows came back in a different order')] + public function rowsReordered(): void + { + if ($this->after['first'] === $this->before['first']) { + throw new RuntimeException("the rows did not move, '{$this->after['first']}' is still first"); + } + } + + #[Then('the rows moved')] + public function rowsMoved(): void + { + $this->rowsReordered(); + } + + #[Then('the row count is unchanged')] + public function rowCountUnchanged(): void + { + if ($this->after['rows'] !== $this->before['rows']) { + throw new RuntimeException("the swap left {$this->after['rows']} rows, was {$this->before['rows']}"); + } + } + + /** Only a new document loses the marker, which is the thing paging and sorting exist not to do. */ + #[Then('the document was not rebuilt')] + public function documentNotRebuilt(): void + { + if ($this->after['sameDocument'] !== true) { + throw new RuntimeException('the page was rebuilt instead of the rows being swapped'); + } + } + + /** Adminer colours the values once at load, so anything swapped in afterwards is plain text + * unless it is asked for again. + */ + #[Then('the values are still highlighted')] + public function valuesStillHighlighted(): void + { + if ($this->after['highlighted'] < $this->before['highlighted']) { + throw new RuntimeException( + "the rows that arrived have {$this->after['highlighted']} highlighted spans, was {$this->before['highlighted']}", + ); + } + } + + #[Then('the URL contains :text')] + public function urlContains(string $text): void + { + if (!str_contains($this->page->url(), $text)) { + throw new RuntimeException('the URL is ' . $this->page->url()); + } + } + + #[Then('the URL says page :number')] + public function urlSaysPage(string $number): void + { + $at = $this->pager(); + if ($at['page'] !== $number || $at['at'] !== $number) { + throw new RuntimeException("the url says page={$at['page']} and the list says {$at['at']}"); + } + } + + #[Then('the first row is unchanged')] + public function firstRowUnchanged(): void + { + $now = $this->list(); + if ($now['first'] !== $this->after['first']) { + throw new RuntimeException("the first row is '{$now['first']}', not the '{$this->after['first']}' it was"); + } + } + + #[Then('/^(\d+) rows are listed$/')] + public function rowsAreListed(int $count): void + { + $now = $this->list(); + if ($now['rows'] !== $count) { + throw new RuntimeException("{$now['rows']} rows are listed, not $count"); + } + } + + // ── the pager ──────────────────────────────────────────────────────────────────────────── + + #[Then('the pager offers first, previous, next and last')] + public function pagerOffersFourSteps(): void + { + $pager = $this->pager(); + if ($pager['steps'] !== 4) { + throw new RuntimeException("the pager has {$pager['steps']} step controls, not four"); + } + } + + #[Then('the page list offers :count pages')] + public function pageListOffers(int $count): void + { + $pager = $this->pager(); + if ($pager['pages'] !== $count) { + throw new RuntimeException("the page list offers {$pager['pages']} pages, not $count"); + } + } + + #[Then('the count beside it reads :rows rows')] + public function countReads(string $rows): void + { + $pager = $this->pager(); + if (!str_contains((string) $pager['total'], $rows)) { + throw new RuntimeException("the count reads '{$pager['total']}', which is not the $rows rows"); + } + } + + #[Then('the chip reads :range')] + public function chipReads(string $range): void + { + $pager = $this->pager(); + if ($pager['range'] !== $range) { + throw new RuntimeException("the chip reads '{$pager['range']}', not '$range'"); + } + } + + /** Every mark is an icon file, masked so it takes the row's colour. A path that stopped + * resolving would leave the buttons blank and everything else here still passing. + */ + #[Then('every step control is drawn from an icon file')] + public function stepsAreDrawn(): void + { + $pager = $this->pager(); + if ($pager['drawn'] !== 4 || $pager['chevron'] !== true) { + throw new RuntimeException( + "{$pager['drawn']} of 4 marks are drawn from icons/, chevron: " . ($pager['chevron'] ? 'yes' : 'no'), + ); + } + } + + /** A step with nowhere to go is a , so it neither invites a click nor moves the rows. */ + #[Then('first and previous lead nowhere')] + public function endsLeadNowhere(): void + { + $ends = (array) $this->pager()['ends']; + if (count($ends) !== 2) { + throw new RuntimeException('on the first page, first and previous still lead somewhere'); + } + } + + #[Then('both ends lead somewhere')] + public function endsLeadSomewhere(): void + { + $ends = (array) $this->pager()['ends']; + if ($ends !== []) { + throw new RuntimeException('an end control still leads nowhere: ' . implode(', ', $ends)); + } + } + + // ── rows per page ──────────────────────────────────────────────────────────────────────── + + #[Then('Limit is a list, not a field to type in')] + public function limitIsAList(): void + { + $limit = $this->limit(); + if ($limit['tag'] !== 'SELECT') { + throw new RuntimeException("Limit is a {$limit['tag']}, not a list to pick from"); + } + } + + #[Then('it opened on :value')] + public function limitOpenedOn(string $value): void + { + $limit = $this->limit(); + if ($limit['value'] !== $value) { + throw new RuntimeException("the list opened on '{$limit['value']}', not the $value the page was showing"); + } + } + + #[Then('it offers at least :count sizes')] + public function limitOffers(int $count): void + { + $limit = $this->limit(); + if (count((array) $limit['options']) < $count) { + throw new RuntimeException('the sizes offered are ' . implode(', ', (array) $limit['options'])); + } + } + + #[Then('the list came back on :value')] + public function limitCameBackOn(string $value): void + { + $this->limitOpenedOn($value); + } + + // ── resizing a column ──────────────────────────────────────────────────────────────────── + + /** Grabbed beside a data row rather than on the heading: the grip runs the height of the + * column, and that is the point of it. + */ + #[When('I drag the :column column :pixels pixels wider')] + public function dragColumn(string $column, int $pixels): void + { + $this->dragged = $column; + $this->before = $this->columns($column); + if ($this->before['grip'] === null) { + throw new RuntimeException("no resize grip was added to the $column column"); + } + $this->drag((array) $this->before['grip'], $pixels); + $this->after = $this->columns($column); + } + + /** Widening a column whose values were already cut re-runs the query, in place: there is a url + * to wait for but no new document. + */ + #[When('I drag the :column column :pixels pixels wider and the query runs again')] + public function dragColumnAndRefetch(string $column, int $pixels): void + { + $this->dragged = $column; + $this->before = $this->columns($column); + $this->drag((array) $this->before['grip'], $pixels); + $this->page->waitForURL('**text_length=**'); + $this->after = $this->columns($column); + } + + #[Then('the column is at least :pixels pixels wider')] + public function columnIsWider(int $pixels): void + { + $grew = $this->after['width'] - $this->before['width']; + if ($grew < $pixels) { + throw new RuntimeException(sprintf( + 'the drag widened the column by %d, not %d (%d -> %d)', + $grew, + $pixels, + $this->before['width'], + $this->after['width'], + )); + } + } + + /** The table grows instead of the neighbours shrinking, which is what a table-layout that + * distributes would do. + */ + #[Then('the other columns kept their width')] + public function otherColumnsKeptWidth(): void + { + foreach ((array) $this->after['others'] as $name => $width) { + $was = ((array) $this->before['others'])[$name] ?? null; + if ($was !== null && abs($width - $was) > 2) { + throw new RuntimeException("the $name column moved with the drag ($was -> $width)"); + } + } + } + + #[Then('the table grew with it')] + public function tableGrew(): void + { + if ($this->after['table'] - $this->before['table'] < 120) { + throw new RuntimeException(sprintf( + 'the table did not grow with the column (%d -> %d)', + $this->before['table'], + $this->after['table'], + )); + } + } + + #[Then('the table scrolls inside the content panel')] + public function tableScrollsInPanel(): void + { + if ($this->after['contentScrolls'] !== true || $this->after['windowScrolls'] !== false) { + throw new RuntimeException('the widened table pushed the whole window sideways instead'); + } + } + + /** Adminer's tableClick is bound to the table, so a click reaching it from the grip ticks the + * heading row's box — which is every row selected. + */ + #[Then('no rows were selected')] + public function noRowsSelected(): void + { + if ($this->after['checked'] > 0) { + throw new RuntimeException("the drag selected rows ({$this->after['checked']} checkboxes ticked)"); + } + } + + #[Then('the grip runs the height of the column')] + public function gripRunsTheColumn(): void + { + if ($this->before['gripHeight'] < 200) { + throw new RuntimeException("the grip is only {$this->before['gripHeight']}px tall, not the column"); + } + } + + /** It stops where the list does rather than running down over adminer's sticky row actions — + * margin included, because that gap is the footer's own background shadow. + */ + #[Then('the grip stops where the list does')] + public function gripStopsAtTheList(): void + { + if ($this->before['pastFooter'] > 1) { + throw new RuntimeException("the grip runs {$this->before['pastFooter']}px past the row actions"); + } + } + + #[Then('Text length was left alone')] + public function textLengthUnchanged(): void + { + if ($this->after['textLength'] !== $this->before['textLength']) { + throw new RuntimeException(sprintf( + 'a column that already fits raised Text length anyway (%d -> %d)', + $this->before['textLength'], + $this->after['textLength'], + )); + } + } + + #[Then('Text length was raised to cover the column')] + public function textLengthRaised(): void + { + if ($this->after['textLength'] <= $this->before['textLength']) { + throw new RuntimeException("the widened column did not raise Text length (still {$this->after['textLength']})"); + } + // The number is the column's width in its own characters, so it has to clear that width in + // the widest plausible ones — measuring the wrong column's font reads as a pass at 101. + if ($this->after['textLength'] < $this->after['width'] / 12) { + throw new RuntimeException(sprintf( + 'Text length %d is too small for a %dpx column', + $this->after['textLength'], + $this->after['width'], + )); + } + } + + /** Raising the number is no use unless the query runs again, and the proof of that is on + * screen: the values in the widened column are longer than the ones they replaced. + */ + #[Then('longer values arrived')] + public function longerValuesArrived(): void + { + if ($this->after['longestValue'] <= $this->before['longestValue']) { + throw new RuntimeException( + "the re-run fetched no more text (longest value still {$this->after['longestValue']} characters)", + ); + } + } + + #[Then('the column kept the width it was dragged to')] + public function columnKeptDraggedWidth(): void + { + $now = $this->columns((string) $this->dragged); + if (abs($now['width'] - $this->after['width']) > 4) { + throw new RuntimeException("the column is {$now['width']}px, not the {$this->after['width']}px it was dragged to"); + } + } + + #[Then('the column is still at the dragged width')] + public function columnStillAtDraggedWidth(): void + { + $this->columnKeptDraggedWidth(); + } + + /** Column widths are the session's, not the durable file's — which is the whole point of where + * they are kept. + */ + /** The durable file is where a dragged sidebar and edit field land, under user_resized_px. A + * column is not one of them, and looking there rather than for the word "column" anywhere in + * the file is what keeps this from matching the json-column plugin's own name. + */ + #[Then('no column width reached the stored settings')] + public function noColumnWidthStored(): void + { + /** @var array $resized */ + $resized = e2e_settings($this->fix)['user_resized_px'] ?? []; + foreach (array_keys($resized) as $key) { + if (str_contains((string) $key, 'column')) { + throw new RuntimeException('a column width reached settings.json: ' . json_encode($resized)); + } + } + } + + // ── resizing a field, and the sidebar ──────────────────────────────────────────────────── + + /** The field's own native resize grip, in its bottom-right corner. */ + #[When('I drag the first field :pixels pixels wider')] + public function dragFirstField(int $pixels): void + { + $this->before = $this->fields(); + if (count((array) $this->before['widths']) < 2) { + throw new RuntimeException('the edit form has fewer than two visible fields to resize'); + } + $this->drag((array) $this->before['grip'], $pixels); + $this->after = $this->fields(); + } + + #[Then('the field is at least :pixels pixels wider')] + public function fieldIsWider(int $pixels): void + { + $widths = (array) $this->after['widths']; + $was = (array) $this->before['widths']; + if ($widths[0] - $was[0] < $pixels) { + throw new RuntimeException(sprintf('the drag did not widen the field (%.0f -> %.0f)', $was[0], $widths[0])); + } + } + + /** The point of the property: the fields nobody touched moved with it. A few pixels of slack, + * because JUSH's
 carries its own border and padding outside the width.
+	 */
+	#[Then('every other field on the form followed it')]
+	public function everyFieldFollowed(): void
+	{
+		$widths = (array) $this->after['widths'];
+		foreach ($widths as $i => $width) {
+			if (abs($width - $widths[0]) > 12) {
+				throw new RuntimeException(sprintf('field %d stayed at %.0f, not the dragged %.0f', $i, $width, $widths[0]));
+			}
+		}
+	}
+
+	#[When('I drag the sidebar handle :pixels pixels right')]
+	public function dragSidebar(int $pixels): void
+	{
+		$handle = $this->page->evaluate(/** @lang JavaScript */ "() => {
+			const h = document.querySelector('#ad-sidebar-resizer');
+			if (!h) { return null; }
+			const r = h.getBoundingClientRect();
+			return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
+		}");
+		if (!is_array($handle)) {
+			throw new RuntimeException('the resize handle was not inserted');
+		}
+		$this->before = ['sidebar' => $this->sidebarWidth()];
+		$this->drag($handle, $pixels);
+		$this->after = ['sidebar' => $this->sidebarWidth()];
+	}
+
+	#[Then('the sidebar is at least :pixels pixels wider')]
+	public function sidebarIsWider(int $pixels): void
+	{
+		if ($this->after['sidebar'] - $this->before['sidebar'] < $pixels) {
+			throw new RuntimeException(sprintf(
+				'the drag did not widen the sidebar (%.0f -> %.0f)',
+				$this->before['sidebar'],
+				$this->after['sidebar'],
+			));
+		}
+	}
+
+	/** The accessible way to move a splitter, and it has to move it too. */
+	#[When('I nudge the sidebar handle left with the keyboard')]
+	public function nudgeSidebar(): void
+	{
+		$this->page->locator('#ad-sidebar-resizer')->focus();
+		$this->before = ['sidebar' => $this->sidebarWidth()];
+		for ($i = 0; $i < 5; $i++) {
+			$this->page->keyboard()->press('ArrowLeft');
+		}
+		$this->after = ['sidebar' => $this->sidebarWidth()];
+	}
+
+	#[Then('the sidebar is narrower')]
+	public function sidebarIsNarrower(): void
+	{
+		if ($this->after['sidebar'] >= $this->before['sidebar']) {
+			throw new RuntimeException('ArrowLeft did not narrow the sidebar');
+		}
+	}
+
+	/** sendBeacon is fire-and-forget, so the file appears a beat after mouseup with no response to
+	 * await: poll for it rather than reading once.
+	 */
+	#[Then('the :what width is stored, matching what is on screen')]
+	public function widthIsStored(string $what): void
+	{
+		$rendered = $what === 'sidebar' ? $this->after['sidebar'] : ((array) $this->after['widths'])[0];
+		$stored = null;
+		for ($i = 0; $i < 30; $i++) {
+			$settings = e2e_settings($this->fix);
+			/** @var array $resized */
+			$resized = $settings['user_resized_px'] ?? [];
+			if (isset($resized[$what])) {
+				$stored = (int) $resized[$what];
+				break;
+			}
+			usleep(100_000);
+		}
+		if ($stored === null) {
+			throw new RuntimeException("the $what width was not persisted to settings.json");
+		}
+		// The 
 JUSH swaps in carries its own border and padding outside the width, so the
+		// slack here is the same as the one the fields are compared with.
+		if (abs($stored - $rendered) > 12) {
+			throw new RuntimeException(sprintf('the stored width %d does not match the rendered %.0f', $stored, $rendered));
+		}
+	}
+
+	/** A fresh page must open at the stored width before any script runs — head() emits it into
+	 * the initial HTML, so the property is already set on load.
+	 */
+	#[Then('a fresh page opens the :what at the stored width')]
+	public function freshPageOpensAtStoredWidth(string $what): void
+	{
+		/** @var array $resized */
+		$resized = e2e_settings($this->fix)['user_resized_px'] ?? [];
+		$stored = (int) ($resized[$what] ?? 0);
+		// A page in the same browser context, so it carries the same session: a fresh context would
+		// arrive at the login form, where the element being measured does not exist and the width
+		// reads as zero — which looks exactly like the stored width never being applied.
+		$cold = $this->page->context()->newPage();
+		$cold->goto($this->page->url());
+		$cold->waitForLoadState('networkidle');
+		$width = (float) $cold->evaluate($what === 'sidebar'
+			? "() => document.querySelector('#foot').getBoundingClientRect().width"
+			: "() => document.querySelector('#form > table.layout textarea').getBoundingClientRect().width");
+		$cold->close();
+		if ($width <= 0) {
+			throw new RuntimeException("nothing to measure on the fresh page — is it the $what page at all?");
+		}
+		if (abs($width - $stored) > 12) {
+			throw new RuntimeException(sprintf('a fresh page opened at %.0f, not the stored %d', $width, $stored));
+		}
+	}
+
+	// ── the theme ────────────────────────────────────────────────────────────────────────────
+
+	/** The theme's own token is only defined by our stylesheet, so a non-empty value proves the
+	 * Adminer Desktop CSS actually loaded and applied — not merely that a page rendered.
+	 */
+	#[Then('the theme is applied')]
+	public function themeIsApplied(): void
+	{
+		$accent = $this->page->evaluate(
+			"() => getComputedStyle(document.documentElement).getPropertyValue('--ad-accent').trim()",
+		);
+		if (!is_string($accent) || $accent === '') {
+			throw new RuntimeException('the theme is not applied, --ad-accent is empty');
+		}
+	}
+
+	/** Both schemes are one set of light-dark() tokens resolved by color-scheme, so a real surface
+	 * has to have resolved to this scheme's side. A non-empty token alone would pass even if
+	 * resolution silently fell back to light on every run.
+	 */
+	#[Then('the surface resolves to the :scheme scheme')]
+	public function surfaceResolvesTo(string $scheme): void
+	{
+		if ($this->surfaceIsDark() !== ($scheme === 'dark')) {
+			throw new RuntimeException("the surface did not resolve to the $scheme scheme");
+		}
+	}
+
+	#[Then('the emulated scheme is :scheme')]
+	public function emulatedSchemeIs(string $scheme): void
+	{
+		$isDark = (bool) $this->page->evaluate("() => matchMedia('(prefers-color-scheme: dark)').matches");
+		if ($isDark !== ($scheme === 'dark')) {
+			throw new RuntimeException("prefers-color-scheme was not emulated as $scheme");
+		}
+	}
+
+	/** The gear sits in the sidebar's scroll flow, by the logo. position: fixed would leave it
+	 * hanging over the panel while everything it belongs to scrolls away.
+	 */
+	#[Then('the settings gear scrolls with the sidebar')]
+	public function gearScrollsWithSidebar(): void
+	{
+		$moved = $this->page->evaluate(/** @lang JavaScript */ "() => {
+			const menu = document.querySelector('#menu'), gear = document.querySelector('#desktop-gear');
+			const top = gear.getBoundingClientRect().top;
+			menu.scrollTop = 200;
+			return top - gear.getBoundingClientRect().top;
+		}");
+		if ((float) $moved < 150) {
+			throw new RuntimeException("the settings gear did not scroll with the sidebar (moved {$moved}px)");
+		}
+	}
+
+	// ── the settings dialog ──────────────────────────────────────────────────────────────────
+
+	#[When('I open the settings dialog')]
+	public function openSettingsDialog(): void
+	{
+		// Only if it is closed. Changing the language reopens it, and clicking the gear while the
+		// modal is up means clicking an element behind the backdrop, which never becomes
+		// actionable — the whole check timed out there rather than failing on anything it asserts.
+		if (!$this->page->evaluate("() => document.querySelector('#desktop-settings').open")) {
+			$this->page->locator('#desktop-gear')->click();
+			usleep(300_000); // showModal() animates, and its contents are display:none until it lands
+		}
+	}
+
+	#[When('I pick the :density row density')]
+	public function pickDensity(string $density): void
+	{
+		$this->page->locator("input[name=\"density\"][value=\"$density\"]")->check(['force' => true]);
+	}
+
+	#[When('I force the :appearance appearance')]
+	public function forceAppearance(string $appearance): void
+	{
+		$this->page->locator("input[name=\"appearance\"][value=\"$appearance\"]")->check(['force' => true]);
+	}
+
+	/** Whichever gallery design is offered first, so this does not break when the catalogue changes. */
+	#[When('I pick the first gallery design')]
+	public function pickFirstDesign(): void
+	{
+		$design = $this->page->evaluate(
+			'() => { const r = [...document.querySelectorAll("input[name=design_light]")].find((x) => x.value); return r ? r.value : null; }',
+		);
+		if (!is_string($design) || $design === '') {
+			throw new RuntimeException('no gallery design was offered to pick');
+		}
+		$this->dragged = $design; // reused as "what the last step picked"
+		$this->page->locator("input[name=\"design_light\"][value=\"$design\"]")->check(['force' => true]);
+	}
+
+	/** Set on the checkbox directly: what this guards is that Save persists it, not the browser's
+	 * own checkbox toggle.
+	 */
+	#[When('I tick the :plugin plugin')]
+	public function tickPlugin(string $plugin): void
+	{
+		$this->togglePlugin($plugin, true);
+	}
+
+	#[When('I untick the :plugin plugin')]
+	public function untickPlugin(string $plugin): void
+	{
+		$this->togglePlugin($plugin, false);
+	}
+
+	#[When('I save the settings')]
+	public function saveSettings(): void
+	{
+		$this->page->locator('#desktop-save')->click();
+		$this->page->waitForLoadState('networkidle');
+	}
+
+	/** The language  is relocated into the settings form for layout, and while it still
+  carried name="lang", Save posted lang too — Adminer's lang.inc.php treats any request carrying it
+  as a language switch and redirects before the settings are applied, so nothing saved at all.
+
+  Background:
+    Given the settings are at their defaults
+    And I am logged in
+    When I open the "users" table
+    And I open the settings dialog
+
+  Scenario: Row density reaches the body and the durable file
+    When I pick the "compact" row density
+    And I save the settings
+    Then the body carries the "density-compact" class
+    And "density" is stored as "compact"
+
+  Scenario: A gallery design is linked once it is saved
+    When I pick the first gallery design
+    And I save the settings
+    Then the chosen design is linked
+
+  Scenario: A plugin is remembered when it is ticked, and forgotten when it is not
+    When I tick the "row-numbers" plugin
+    And I save the settings
+    Then the "row-numbers" plugin is stored as enabled
+    When I open the settings dialog
+    And I untick the "row-numbers" plugin
+    And I save the settings
+    Then the "row-numbers" plugin is no longer stored
+
+  Scenario: The language switch still reloads the page on its own
+    When I switch the language to "de"
+    Then the page comes back in "de"
+
+  Scenario: Forcing Dark pins the dark scheme under a light OS
+    When I force the "dark" appearance
+    And I save the settings
+    Then the body carries the "theme-dark" class
+    And the override renders dark under a light OS
+
+  Scenario: Reset forgets the dialog's own fields and what the api stored
+    Given a dragged sidebar width has been stored
+    When I pick the "compact" row density
+    And I save the settings
+    And I open the settings dialog
+    And I reset the settings to their defaults
+    Then nothing is stored any more
+    And the body carries the "density-cozy" class
diff --git a/tests/e2e/features/shell/sidebar.feature b/tests/e2e/features/shell/sidebar.feature
new file mode 100644
index 0000000..a8efe69
--- /dev/null
+++ b/tests/e2e/features/shell/sidebar.feature
@@ -0,0 +1,21 @@
+Feature: Resizing the sidebar
+  The handle between the panels widens the sidebar, the width is persisted to the durable file, and
+  a fresh page opens at it before any script runs — head() emits it into the initial HTML, which is
+  the cold-start path. The keyboard has to move it too: that is what a splitter is for anyone not
+  using a mouse.
+
+  Background:
+    Given the settings are at their defaults
+    And I am logged in
+    When I open the "users" table
+
+  Scenario: A drag widens the sidebar, and a fresh page opens where it was left
+    When I drag the sidebar handle 120 pixels right
+    Then the sidebar is at least 90 pixels wider
+    And the "sidebar" width is stored, matching what is on screen
+    And a fresh page opens the "sidebar" at the stored width
+
+  Scenario: The keyboard moves the splitter too
+    When I drag the sidebar handle 120 pixels right
+    And I nudge the sidebar handle left with the keyboard
+    Then the sidebar is narrower
diff --git a/tests/e2e/features/shell/theme.feature b/tests/e2e/features/shell/theme.feature
new file mode 100644
index 0000000..1bdc597
--- /dev/null
+++ b/tests/e2e/features/shell/theme.feature
@@ -0,0 +1,23 @@
+Feature: The Adminer Desktop theme
+  Both schemes are one set of light-dark() tokens, resolved by the color-scheme Adminer takes from
+  our meta. So it is not enough that a token is defined: a real surface has to have resolved to the
+  side the OS asked for, and the scheme has to have been emulated at all — otherwise a dark run
+  renders light and the screenshot is the only tell.
+
+  Scenario Outline: The theme applies and follows the scheme the OS asks for
+    Given the browser is in the  scheme
+    And I am logged in
+    When I open the "users" table
+    Then the theme is applied
+    And the emulated scheme is 
+    And the surface resolves to the  scheme
+
+    Examples:
+      | scheme |
+      | light  |
+      | dark   |
+
+  Scenario: The settings gear scrolls with the sidebar it sits in
+    Given I am logged in
+    When I open the "users" table
+    Then the settings gear scrolls with the sidebar
diff --git a/tests/e2e/fixture.php b/tests/e2e/fixture.php
deleted file mode 100644
index 22d32db..0000000
--- a/tests/e2e/fixture.php
+++ /dev/null
@@ -1,135 +0,0 @@
-/dev/null"));
-	if ($running === '') {
-		(new Process([
-			'docker', 'run', '-d', '--name', 'adminer-demo-pg',
-			'-e', 'POSTGRES_PASSWORD=demo', '-e', 'POSTGRES_DB=demo',
-			'-p', "$pgPort:5432", 'postgres:18-alpine',
-		]))->mustRun();
-
-		$deadline = time() + 30;
-		while (true) {
-			$ready = new Process(['docker', 'exec', 'adminer-demo-pg', 'pg_isready', '-U', 'postgres']);
-			$ready->run();
-			if ($ready->isSuccessful()) {
-				break;
-			}
-			if (time() > $deadline) {
-				throw new RuntimeException('postgres did not become ready');
-			}
-			sleep(1);
-		}
-		$seed = new Process(['docker', 'exec', '-i', 'adminer-demo-pg', 'psql', '-U', 'postgres', '-d', 'demo', '-v', 'ON_ERROR_STOP=1']);
-		$seed->setInput((string) file_get_contents(__DIR__ . '/seed.sql'));
-		$seed->mustRun();
-	}
-
-	$server = new Process(
-		[$root . '/bin/frankenphp', 'php-server', '--root', $root . '/app', '--listen', "127.0.0.1:$appPort", '--no-compress'],
-		null,
-		['ADMINER_DESKTOP_DATA' => $data],
-	);
-	$server->start();
-
-	$base = "http://127.0.0.1:$appPort/adminer.php";
-	$deadline = time() + 15;
-	while (true) {
-		$ctx = stream_context_create(['http' => ['timeout' => 1, 'ignore_errors' => true]]);
-		if (@file_get_contents($base, false, $ctx) !== false) {
-			break;
-		}
-		if (time() > $deadline) {
-			$server->stop();
-			throw new RuntimeException('the app did not start');
-		}
-		usleep(200_000);
-	}
-
-	$select = "http://127.0.0.1:$appPort/adminer.php?" . http_build_query([
-		'pgsql' => "127.0.0.1:$pgPort",
-		'username' => 'postgres',
-		'db' => 'demo',
-		'ns' => 'public',
-		'select' => 'users',
-	]);
-
-	// $data too: the preferences a check saves land in $data/settings.json, which is where
-	// asserting that Save persisted anything has to look.
-	return compact('root', 'pgPort', 'shots', 'server', 'base', 'select', 'data');
-}
-
-/** Log a page into the demo database.
- *
- * Verified and retried, because the 400ms below is a guess: adminer rebuilds the driver's
- * fields on change, and a fill that lands mid-rebuild posts an empty field and comes back as
- * the login page. Whatever the check does next then fails on an empty page, which reads as
- * anything but a login problem — the failure it produced was "the edit form has no fields".
- */
-function e2e_login($page, string $base, int $pgPort): void
-{
-	for ($attempt = 1; $attempt <= 3; $attempt++) {
-		$page->goto($base);
-		$page->locator('select[name="auth[driver]"]')->selectOption('pgsql');
-		usleep(400_000 * $attempt); // let the rebuild settle, and wait longer each time round
-		$page->locator('input[name="auth[server]"]')->fill("127.0.0.1:$pgPort");
-		$page->locator('input[name="auth[username]"]')->fill('postgres');
-		$page->locator('input[name="auth[password]"]')->fill('demo');
-		$page->locator('input[name="auth[db]"]')->fill('demo');
-		// Submit the login form directly rather than clicking: headless Adminer rebuilds the
-		// driver's fields on change, which leaves the submit button intermittently "not
-		// actionable", and this is independent of the button's markup and label.
-		$page->evaluate("() => document.querySelector('[name=\"auth[driver]\"]').form.requestSubmit()");
-		$page->waitForLoadState('networkidle');
-		// A rejected login comes back as the login page, and its title says so. The title is a
-		// driver call, unlike an evaluate, which throws when it lands while adminer's answer to
-		// a good login is still committing.
-		if (!str_starts_with($page->title(), 'Login')) {
-			return;
-		}
-	}
-	throw new RuntimeException('could not log in after 3 attempts');
-}
-
-/** Stop the server and report one check file's result, exiting with its status. */
-function e2e_done(Process $server, array $failures, string $name): never
-{
-	$server->stop();
-	if ($failures) {
-		fwrite(STDERR, implode("\n", $failures) . "\n");
-		echo "$name: " . count($failures) . " failure(s)\n";
-		exit(1);
-	}
-	echo "$name ok — screenshots in tests/e2e/screenshots/\n";
-	exit(0);
-}
diff --git a/tests/e2e/harness/fixture.php b/tests/e2e/harness/fixture.php
new file mode 100644
index 0000000..109838a
--- /dev/null
+++ b/tests/e2e/harness/fixture.php
@@ -0,0 +1,350 @@
+ [
+			'key' => 'pgsql',
+			'container' => 'adminer-demo-pg',
+			'image' => 'postgres:18-alpine',
+			'port' => 5432,
+			'hostPort' => 55432,
+			'username' => 'postgres',
+			'password' => 'demo',
+			'env' => ['POSTGRES_PASSWORD=demo', 'POSTGRES_DB=' . E2E_DATABASE],
+			'appPort' => 18080,
+		],
+		'mysql' => [
+			'key' => 'server',
+			'container' => 'adminer-demo-mysql',
+			'image' => 'mysql:8',
+			'port' => 3306,
+			'hostPort' => 53306,
+			'username' => 'root',
+			'password' => 'demo',
+			'env' => ['MYSQL_ROOT_PASSWORD=demo', 'MYSQL_DATABASE=' . E2E_DATABASE],
+			'appPort' => 18090,
+		],
+	];
+	$name = getenv('ADMINER_DESKTOP_E2E_DRIVER') ?: 'pgsql';
+	if (!isset($drivers[$name])) {
+		throw new RuntimeException("unknown driver '$name', use " . implode(' or ', array_keys($drivers)));
+	}
+	return ['name' => $name] + $drivers[$name];
+}
+
+/** Start the database and seed it, or reuse one that is already running.
+ *
+ * @param array $driver what e2e_driver() returned
+ * @return string hostname:port to connect to
+ */
+function e2e_database(array $driver): string
+{
+	$name = (string) $driver['container'];
+	$running = new Process(['docker', 'ps', '--filter', "name=^/$name$", '--format', '{{.Names}}']);
+	$running->run();
+	if (trim($running->getOutput()) !== $name) {
+		$run = new Process(array_merge(
+			['docker', 'run', '-d', '--name', $name],
+			array_merge(...array_map(fn (string $env): array => ['-e', $env], (array) $driver['env'])),
+			['-p', "{$driver['hostPort']}:{$driver['port']}", (string) $driver['image']],
+		));
+		$run->run();
+		if (!$run->isSuccessful()) {
+			// Started but stopped, rather than absent: `docker start` is what revives it, and a
+			// second `docker run` on the same name only ever fails.
+			(new Process(['docker', 'start', $name]))->mustRun();
+		}
+	}
+	e2e_wait_for_database($driver);
+	// Always, reused or not. The seed drops what it creates, so this is what a rerun starts from —
+	// and editing seed/*.sql then reaches the database without `make destroy` first, which is the
+	// papercut the old fixture documented rather than fixed.
+	e2e_seed($driver);
+	return '127.0.0.1:' . $driver['hostPort'];
+}
+
+/** Wait until the server inside the container answers, not merely until the container exists.
+ * @param array $driver
+ */
+function e2e_wait_for_database(array $driver): void
+{
+	$name = (string) $driver['container'];
+	$probe = $driver['name'] === 'pgsql'
+		? ['docker', 'exec', $name, 'pg_isready', '-U', (string) $driver['username']]
+		: ['docker', 'exec', $name, 'mysqladmin', 'ping', '-u', (string) $driver['username'], '-p' . $driver['password']];
+	$deadline = time() + 60;
+	while (true) {
+		$ready = new Process($probe);
+		$ready->run();
+		if ($ready->isSuccessful()) {
+			return;
+		}
+		if (time() > $deadline) {
+			throw new RuntimeException("{$driver['name']} did not become ready");
+		}
+		sleep(1);
+	}
+}
+
+/** Apply seed/.sql through the client inside the container, so nothing has to be installed.
+ * @param array $driver
+ */
+function e2e_seed(array $driver): void
+{
+	$name = (string) $driver['container'];
+	$client = $driver['name'] === 'pgsql'
+		? ['docker', 'exec', '-i', $name, 'psql', '-U', (string) $driver['username'], '-d', E2E_DATABASE, '-v', 'ON_ERROR_STOP=1', '-q']
+		: ['docker', 'exec', '-i', $name, 'mysql', '-u', (string) $driver['username'], '-p' . $driver['password'], E2E_DATABASE];
+	$seed = new Process($client);
+	$seed->setTimeout(120);
+	$seed->setInput((string) file_get_contents(dirname(__DIR__) . "/seed/{$driver['name']}.sql"));
+	$seed->mustRun();
+}
+
+/** Run one statement against the demo database without a browser, and return what it printed.
+ *
+ * The client inside the container again, for the same reason: a scenario that arranges a row or
+ * checks one that should not exist needs no driver on this side.
+ * @param array $driver
+ */
+function e2e_sql(array $driver, string $sql): string
+{
+	$name = (string) $driver['container'];
+	$run = new Process($driver['name'] === 'pgsql'
+		? ['docker', 'exec', $name, 'psql', '-U', (string) $driver['username'], '-d', E2E_DATABASE, '-tAc', $sql]
+		: ['docker', 'exec', $name, 'mysql', '-N', '-B', '-u', (string) $driver['username'], '-p' . $driver['password'], E2E_DATABASE, '-e', $sql]);
+	$run->run();
+	return trim($run->getOutput());
+}
+
+/** Serve the app and return everything a scenario needs.
+ *
+ * @return array{root:string, data:string, artifacts:string, server:Process, base:string,
+ *     database:string, driver:array}
+ */
+function e2e_boot(): array
+{
+	$root = dirname(__DIR__, 3);
+	$artifacts = dirname(__DIR__) . '/artifacts';
+	$driver = e2e_driver();
+	// One data dir per driver: the suites run in one process and settings.json is what half the
+	// scenarios assert on, so a shared one would have them writing over each other.
+	$data = sys_get_temp_dir() . "/adminer-desktop-e2e-{$driver['name']}";
+	@mkdir($data, 0700, true);
+	@mkdir($artifacts, 0777, true);
+	$database = e2e_database($driver);
+
+	// The next free port rather than the one asked for: a killed run leaves its server behind, and
+	// every scenario would then wait on a port it is never going to get.
+	$port = e2e_free_port((int) $driver['appPort']);
+	$server = new Process(
+		[$root . '/bin/frankenphp', 'php-server', '--root', $root . '/app', '--listen', "127.0.0.1:$port", '--no-compress'],
+		null,
+		['ADMINER_DESKTOP_DATA' => $data],
+	);
+	// frankenphp logs every request it serves and nothing here ever reads that pipe, so it fills:
+	// sixty-four kilobytes in, the server blocks writing to it and stops answering. From the
+	// browser that looks like a page which commits and then never finishes loading, a scenario or
+	// two into the run — and the log is of no use to a scenario anyway.
+	$server->disableOutput();
+	$server->start();
+
+	$base = "http://127.0.0.1:$port/adminer.php";
+	$deadline = time() + 30;
+	$context = stream_context_create(['http' => ['timeout' => 1, 'ignore_errors' => true]]);
+	// The login form, not merely an answer: anything else listening on the port would answer too,
+	// and every scenario would then fail at logging in to a page that is not Adminer.
+	while (!str_contains((string) @file_get_contents($base, false, $context), 'auth[driver]')) {
+		if (time() > $deadline) {
+			$server->stop();
+			throw new RuntimeException("the app is not answering on port $port, is something else using it?");
+		}
+		usleep(200_000);
+	}
+	return compact('root', 'data', 'artifacts', 'server', 'base', 'database', 'driver');
+}
+
+/** The fixture for the driver this process is testing, booted once.
+ *
+ * Every context calls this rather than booting its own: the suites run in one process, and a
+ * second server would mean a second data dir, which is the file half the scenarios assert on.
+ *
+ * @return array
+ */
+function e2e_fixture(): array
+{
+	static $fixtures = [];
+	$name = getenv('ADMINER_DESKTOP_E2E_DRIVER') ?: 'pgsql';
+	if (!isset($fixtures[$name])) {
+		$fixtures[$name] = e2e_boot();
+	}
+	return $fixtures[$name];
+}
+
+/** Get a port nothing is listening on yet, starting from the one asked for. */
+function e2e_free_port(int $port): int
+{
+	for ($i = 0; $i < 20; $i++) {
+		$socket = @stream_socket_server('tcp://127.0.0.1:' . ($port + $i), $errno, $error);
+		if ($socket) {
+			fclose($socket);
+			return $port + $i;
+		}
+	}
+	throw new RuntimeException("no free port between $port and " . ($port + 19));
+}
+
+/** Build a link into the demo database.
+ *
+ * @param array $fix what e2e_boot() returned
+ * @param array $params e.g. ['select' => 'users']
+ */
+function e2e_url(array $fix, array $params): string
+{
+	/** @var array $driver */
+	$driver = $fix['driver'];
+	$connection = [
+		(string) $driver['key'] => (string) $fix['database'],
+		'username' => (string) $driver['username'],
+		'db' => E2E_DATABASE,
+	];
+	if ($driver['name'] === 'pgsql') {
+		$connection['ns'] = 'public'; // PostgreSQL addresses tables in a schema
+	}
+	// array_merge, so a scenario can point somewhere else than the defaults above.
+	return $fix['base'] . '?' . http_build_query(array_merge($connection, $params));
+}
+
+/** Log a page into the demo database.
+ *
+ * Verified and retried, because the wait below is a guess: Adminer rebuilds the driver's fields on
+ * change, and a value filled in the middle of that is posted empty, which comes back as the login
+ * page. Whatever the scenario does next then fails on an empty page, which reads as anything but a
+ * login problem — the failure it produced was "the edit form has no fields".
+ *
+ * @param array $fix
+ */
+function e2e_login(PageInterface $page, array $fix): void
+{
+	/** @var array $driver */
+	$driver = $fix['driver'];
+	for ($attempt = 1; $attempt <= 3; $attempt++) {
+		$page->goto((string) $fix['base']);
+		$page->locator('select[name="auth[driver]"]')->selectOption((string) $driver['key']);
+		usleep(400_000 * $attempt); // let the rebuild settle, and wait longer each time round
+		$page->locator('input[name="auth[server]"]')->fill((string) $fix['database']);
+		$page->locator('input[name="auth[username]"]')->fill((string) $driver['username']);
+		$page->locator('input[name="auth[password]"]')->fill((string) $driver['password']);
+		$page->locator('input[name="auth[db]"]')->fill(E2E_DATABASE);
+		// Submit the form rather than clicking: the rebuild leaves the button intermittently "not
+		// actionable", and this depends on neither its markup nor its label.
+		$page->evaluate('() => document.querySelector(\'[name="auth[driver]"]\').form.requestSubmit()');
+		$page->waitForLoadState('networkidle');
+		// A rejected login comes back as the login page, and its title says so. The title is a
+		// driver call, unlike an evaluate, which throws when it lands while adminer's answer to a
+		// good login is still committing.
+		if (!str_starts_with($page->title(), 'Login')) {
+			return;
+		}
+	}
+	throw new RuntimeException('could not log in after 3 attempts');
+}
+
+/** The browser every scenario opens a context in.
+ *
+ * One browser for the whole run, one context per scenario: the context is what holds the cookies,
+ * so a scenario still logs in on a session nobody else has touched, while the browser and the node
+ * process driving it are started once instead of forty times.
+ *
+ * `make e2e-visual` sets ADMINER_DESKTOP_E2E_HEADED, which shows the browser and slows it down
+ * enough to follow — how a scenario is written, and how a failing one is understood.
+ */
+function e2e_browser(): BrowserInterface
+{
+	// The client as well as the browser: it closes the connection to the node process when it is
+	// collected, and the browser is then talking to nobody.
+	static $client = null, $browser = null;
+	if (!$browser) {
+		$headed = (bool) getenv('ADMINER_DESKTOP_E2E_HEADED');
+		// Longer than the 30 seconds Playwright gives an action, and deliberately not equal to it:
+		// both sides default to 30, so a step that ran out of time raced the answer against our own
+		// giving up on it.
+		$client = PlaywrightFactory::create(new PlaywrightConfig(timeoutMs: 60000));
+		$browser = $client->chromium()->withHeadless(!$headed)->withSlowMo($headed ? 300 : 0)->launch();
+		register_shutdown_function(function () use (&$client): void {
+			$client->close();
+		});
+	}
+	return $browser;
+}
+
+/** Save the page a step failed on, as a picture and as HTML.
+ *
+ * A failure in CI is otherwise a line of text about a page nobody can open any more; the workflow
+ * uploads this directory. Nothing here may throw — it runs while a scenario is already failing,
+ * and its own exception would replace the message saying what went wrong.
+ *
+ * @param array $fix
+ */
+function e2e_report(PageInterface $page, array $fix, string $name): void
+{
+	try {
+		/** @var array $driver */
+		$driver = $fix['driver'];
+		$path = "{$fix['artifacts']}/{$driver['name']}-$name";
+		$page->screenshot("$path.png");
+		file_put_contents("$path.html", $page->content());
+	} catch (Throwable $e) {
+		fwrite(STDERR, 'could not save the failed page: ' . $e->getMessage() . "\n");
+	}
+}
+
+/** What the app has persisted, which is what survives a cold start.
+ *
+ * @param array $fix
+ * @return array
+ */
+function e2e_settings(array $fix): array
+{
+	$file = $fix['data'] . '/settings.json';
+	clearstatcache(true, $file);
+	if (!is_file($file)) {
+		return [];
+	}
+	$stored = json_decode((string) file_get_contents($file), true);
+	return is_array($stored) ? $stored : [];
+}
diff --git a/tests/e2e/mcp-bridge.test.php b/tests/e2e/mcp-bridge.test.php
deleted file mode 100644
index ab6b488..0000000
--- a/tests/e2e/mcp-bridge.test.php
+++ /dev/null
@@ -1,147 +0,0 @@
-path();
-if ($found === null) {
-    $failures[] = 'with no ADMINER_DESKTOP_DATA the bridge finds no data dir at all';
-} elseif (!str_ends_with($found, 'Adminer Desktop/mcp.json')) {
-    $failures[] = 'the fallback data dir is not the launcher’s: ' . $found;
-}
-
-$request = (string) json_encode(['jsonrpc' => '2.0', 'id' => 7, 'method' => 'tools/list']);
-$notification = (string) json_encode(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']);
-$unreachable = fn(): bool => false;
-
-// 1. No handshake at all: the app is not running, or the feature is off. The connection must
-//    still come up — failing initialize is reported by clients as a dead server, printing the
-//    JSON-RPC code and dropping the message, so the sentence saying what to do never arrives.
-$handshake->clear();
-$stdio = new Stdio($handshake, $unreachable);
-$init = json_decode((string) $stdio->exchange((string) json_encode(
-    ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize'],
-)), true);
-$is('initialize succeeds while unavailable', $init['result']['protocolVersion'] ?? null, '2025-11-25');
-// Not an empty list: "connected, no tools" leaves nothing to call, so the reason can never be
-// reached. The one tool advertised is the reason, readable in the client's tool list.
-$list = json_decode((string) $stdio->exchange($request), true);
-$is('advertises exactly one tool while unavailable', count($list['result']['tools'] ?? []), 1);
-$is('named for the state', $list['result']['tools'][0]['name'] ?? null, 'need_attention_read_me');
-$contains('described with what to do', $list['result']['tools'][0]['description'] ?? null, 'AI access');
-
-// The explanation belongs where a client renders it: a tool result, not a transport error.
-$call = json_decode((string) $stdio->exchange((string) json_encode(
-    ['jsonrpc' => '2.0', 'id' => 3, 'method' => 'tools/call', 'params' => ['name' => 'list_tables']],
-)), true);
-$is('a call while unavailable is a tool error', $call['result']['isError'] ?? null, true);
-$contains('and it says what to do', $call['result']['content'][0]['text'] ?? null, 'AI access');
-
-// 2. Same, but the message was a notification. JSON-RPC answers those with silence — an error
-//    here would be a protocol violation that strict clients complain about.
-$is('notification with no handshake stays silent', $stdio->exchange($notification), null);
-
-// From here on there is a handshake to read.
-$handshake->write('http://127.0.0.1:1/adminer.php?pgsql=x&db=demo&', ['adminer_sid' => 'abc']);
-
-// 3. The window was closed: the transport fails outright.
-$closed = json_decode((string) (new Stdio($handshake, $unreachable))->exchange((string) json_encode(
-    ['jsonrpc' => '2.0', 'id' => 4, 'method' => 'tools/call', 'params' => ['name' => 'list_tables']],
-)), true);
-$contains('window closed', $closed['result']['content'][0]['text'] ?? null, 'stopped answering');
-
-// 4. A 204: the app had nothing to say, and neither have we.
-$is('empty body forwards nothing', (new Stdio($handshake, fn(): string => ''))->exchange($request), null);
-
-// 5. HTML back means the session behind the handshake expired. The agent must be told that,
-//    not handed a login page to parse.
-$html = new Stdio($handshake, fn(): string => "Login");
-$expired = json_decode((string) $html->exchange((string) json_encode(
-    ['jsonrpc' => '2.0', 'id' => 5, 'method' => 'tools/call', 'params' => ['name' => 'list_tables']],
-)), true);
-$contains('expired session', $expired['result']['content'][0]['text'] ?? null, 'expired');
-
-// 6. A JSON answer is forwarded verbatim — the bridge must not reshape what the server said.
-$answer = '{"jsonrpc":"2.0","id":7,"result":{"tools":[]}}';
-$is('json forwarded unchanged', (new Stdio($handshake, fn(): string => $answer))->exchange($request), $answer);
-
-// 7. The URL it posts to carries the connection from the handshake, plus mcp=1 — the bug that
-//    made every tool call reach an adminer with no driver.
-$seen = null;
-$spy = new Stdio($handshake, function (string $url, string $body, array $cookies) use (&$seen): string {
-    $seen = ['url' => $url, 'body' => $body, 'cookies' => $cookies];
-    return '{}';
-});
-$spy->exchange($request);
-$contains('posts with mcp=1', $seen['url'] ?? null, 'mcp=1');
-$contains('posts to the connected url', $seen['url'] ?? null, 'db=demo');
-$is('forwards the message unchanged', $seen['body'] ?? null, $request);
-$is('replays the session cookie', $seen['cookies']['adminer_sid'] ?? null, 'abc');
-
-// 8. run() pumps a stream: blank lines skipped, one answer per request, silence stays silent.
-$in = fopen('php://memory', 'r+');
-fwrite($in, $request . "\n\n" . $notification . "\n" . $request . "\n");
-rewind($in);
-$out = fopen('php://memory', 'r+');
-// Like the app: a notification is answered 204, i.e. an empty body. The bridge does not parse
-// for that itself — it forwards everything and lets the server decide — so a stub that answers
-// every message alike would prove nothing about the silence.
-$likeTheApp = function (string $url, string $body) use ($answer): string {
-    $message = json_decode($body, true);
-    return isset($message['id']) ? $answer : '';
-};
-(new Stdio($handshake, $likeTheApp))->run($in, $out);
-rewind($out);
-$written = (string) stream_get_contents($out);
-$is('run answers each request once, notifications never', substr_count($written, '"result"'), 2);
-$is('run terminates every line', substr_count($written, "\n"), 2);
-
-array_map('unlink', glob("$dir/*") ?: []);
-@rmdir($dir);
-
-if ($failures !== []) {
-    echo implode("\n", $failures), "\n";
-    echo 'mcp-bridge: ' . count($failures) . " failure(s)\n";
-    exit(1);
-}
-echo "mcp-bridge ok\n";
-exit(0);
diff --git a/tests/e2e/mcp-endpoint.test.php b/tests/e2e/mcp-endpoint.test.php
deleted file mode 100644
index 380bff7..0000000
--- a/tests/e2e/mcp-endpoint.test.php
+++ /dev/null
@@ -1,82 +0,0 @@
- '127.0.0.1:18080', 'SCRIPT_NAME' => '/adminer.php'];
-
-// 1. The connection has to survive into the recorded URL, or the agent reaches a driverless
-//    adminer. This is the regression.
-$url = $endpoint->url($server, true, $me);
-$is('records the connected url', $url, 'http://127.0.0.1:18080/' . $me);
-if ($url === null || !str_contains($url, 'db=demo')) {
-    $failures[] = 'the recorded url lost the database: ' . json_encode($url);
-}
-
-// 2. Not connected is not logged in: nothing worth borrowing, so nothing recorded.
-$is('records nothing when disconnected', $endpoint->url($server, false, $me), null);
-
-// 3. No host, nothing to build an absolute URL from.
-$is('records nothing without a host', $endpoint->url(['SCRIPT_NAME' => '/adminer.php'], true, $me), null);
-
-// 4. Served from a subdirectory, the URL keeps it — and does not double the slash.
-$is(
-    'keeps a subdirectory',
-    $endpoint->url(['HTTP_HOST' => 'h', 'SCRIPT_NAME' => '/tools/adminer.php'], true, $me),
-    'http://h/tools/' . $me,
-);
-
-// 5. Windows can hand back a backslashed SCRIPT_NAME; those are not URL separators.
-$is(
-    'normalises windows separators',
-    $endpoint->url(['HTTP_HOST' => 'h', 'SCRIPT_NAME' => '\\tools\\adminer.php'], true, $me),
-    'http://h/tools/' . $me,
-);
-
-// 6. A request that arrives with a stale handshake must be told so in JSON. Falling through
-//    would reach Server and crash on a null driver, and answering HTML would leave the agent
-//    parsing a login page.
-$answer = $endpoint->answer('{"jsonrpc":"2.0","id":1,"method":"tools/list"}', false);
-$decoded = json_decode((string) $answer, true);
-$is('disconnected answers a json-rpc error', $decoded['error']['code'] ?? null, -32000);
-if (!str_contains((string) ($decoded['error']['message'] ?? ''), 'Log in again')) {
-    $failures[] = 'the disconnected message does not say what to do: ' . json_encode($answer);
-}
-
-if ($failures !== []) {
-    echo implode("\n", $failures), "\n";
-    echo 'mcp-endpoint: ' . count($failures) . " failure(s)\n";
-    exit(1);
-}
-echo "mcp-endpoint ok\n";
-exit(0);
diff --git a/tests/e2e/mcp-log.test.php b/tests/e2e/mcp-log.test.php
deleted file mode 100644
index 26417fb..0000000
--- a/tests/e2e/mcp-log.test.php
+++ /dev/null
@@ -1,83 +0,0 @@
-path($day1)), 'mcp-' . gmdate('Y-m-d', $day1) . '.log');
-$is('a later day is a different file', $log->path($day2) === $log->path($day1), false);
-
-// 2. A request lands, tab separated, one line.
-$log->append('tools/call', 'execute_query', "SELECT *\n  FROM users", $day1);
-$lines = file((string) $log->path($day1), FILE_IGNORE_NEW_LINES) ?: [];
-$is('one line per request', count($lines), 1);
-$fields = explode("\t", $lines[0] ?? '');
-$is('four fields', count($fields), 4);
-$is('method logged', $fields[1] ?? '', 'tools/call');
-$is('tool logged', $fields[2] ?? '', 'execute_query');
-// Newlines in SQL must not become extra lines, or a single call reads as several.
-$is('sql flattened to one line', $fields[3] ?? '', 'SELECT * FROM users');
-
-// 3. Appends, never overwrites — yesterday's evidence has to survive today's writing.
-$log->append('tools/list', '', '', $day1);
-$is('appends', count(file((string) $log->path($day1), FILE_IGNORE_NEW_LINES) ?: []), 2);
-
-// 4. Crossing midnight writes a new file and leaves the old one alone.
-$log->append('tools/call', 'list_tables', '', $day2);
-$is('yesterday untouched', count(file((string) $log->path($day1), FILE_IGNORE_NEW_LINES) ?: []), 2);
-$is('today is its own file', count(file((string) $log->path($day2), FILE_IGNORE_NEW_LINES) ?: []), 1);
-
-// 5. Old days are pruned on write; recent ones are not. 20 days back is beyond the window,
-//    5 is inside it.
-$old = $dir . '/mcp-' . gmdate('Y-m-d', $day2 - (20 * 86400)) . '.log';
-$recent = $dir . '/mcp-' . gmdate('Y-m-d', $day2 - (5 * 86400)) . '.log';
-file_put_contents($old, "old\n");
-file_put_contents($recent, "recent\n");
-$log->append('ping', '', '', $day2);
-$is('prunes beyond the window', is_file($old), false);
-$is('keeps what is inside it', is_file($recent), true);
-
-// 6. It names tables and queries from the database, so it must not be world-readable.
-$perms = substr(sprintf('%o', (int) fileperms((string) $log->path($day2))), -3);
-$is('kept private', $perms, '600');
-
-// 7. No log directory (served without the launcher) is silence, not a crash.
-$none = new RequestLog(null);
-$is('no directory means no path', $none->path($day1), null);
-$none->append('ping', '', '', $day1); // must not throw
-
-array_map('unlink', glob("$dir/*") ?: []);
-@rmdir($dir);
-
-if ($failures !== []) {
-    echo implode("\n", $failures), "\n";
-    echo 'mcp-log: ' . count($failures) . " failure(s)\n";
-    exit(1);
-}
-echo "mcp-log ok\n";
-exit(0);
diff --git a/tests/e2e/mcp.test.php b/tests/e2e/mcp.test.php
deleted file mode 100644
index 29614bf..0000000
--- a/tests/e2e/mcp.test.php
+++ /dev/null
@@ -1,292 +0,0 @@
- true,
-        CURLOPT_POST => true,
-        CURLOPT_POSTFIELDS => json_encode($message),
-        CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
-        CURLOPT_COOKIE => implode('; ', array_map(fn($k, $v) => "$k=$v", array_keys($cookies), $cookies)),
-    ]);
-    $body = (string) curl_exec($ch);
-    return [json_decode($body, true), $body];
-};
-
-try {
-    $base = $fix['base'];
-    $data = $fix['data'];
-
-    // Feature on. Written straight to settings.json rather than through the dialog: check.sh
-    // already proves the POST path, and this check is about what happens after it.
-    file_put_contents($data . '/settings.json', (string) json_encode(['mcp' => true]));
-    @unlink($data . '/mcp.json');
-
-    // Log in with curl and keep the cookies — the same borrow the shim performs.
-    $cookies = [];
-    $collect = function ($ch) use (&$cookies): void {
-        foreach ((array) curl_getinfo($ch, CURLINFO_COOKIELIST) as $line) {
-            $f = explode("\t", $line);
-            if (count($f) >= 7) {
-                $cookies[$f[5]] = $f[6];
-            }
-        }
-    };
-    $ch = curl_init($base);
-    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_COOKIEFILE => '']);
-    $page = (string) curl_exec($ch);
-    $collect($ch);
-    preg_match("~name='token' value='([^']*)'~", $page, $m);
-
-    $ch = curl_init($base);
-    curl_setopt_array($ch, [
-        CURLOPT_RETURNTRANSFER => true,
-        CURLOPT_COOKIEFILE => '',
-        CURLOPT_FOLLOWLOCATION => true,
-        CURLOPT_POST => true,
-        CURLOPT_COOKIE => implode('; ', array_map(fn($k, $v) => "$k=$v", array_keys($cookies), $cookies)),
-        CURLOPT_POSTFIELDS => http_build_query([
-            'auth' => [
-                'driver' => 'pgsql', 'server' => '127.0.0.1:' . $fix['pgPort'],
-                'username' => 'postgres', 'password' => 'demo', 'db' => 'demo',
-            ],
-            'token' => $m[1] ?? '',
-        ]),
-    ]);
-    $after = (string) curl_exec($ch);
-    $collect($ch);
-    if (!str_contains($after, 'Logout') && !str_contains($after, 'logout=1')) {
-        throw new RuntimeException('could not log in with curl — the rest of the check would be meaningless');
-    }
-
-    // A connected request, so the handshake records a URL that carries the connection. A bare
-    // adminer.php would reach an adminer with no driver, which is the bug this check caught.
-    $ch = curl_init($fix['select']);
-    curl_setopt_array($ch, [
-        CURLOPT_RETURNTRANSFER => true,
-        CURLOPT_COOKIE => implode('; ', array_map(fn($k, $v) => "$k=$v", array_keys($cookies), $cookies)),
-    ]);
-    curl_exec($ch);
-
-    // 1. The handshake exists, and points at this window with this session's cookies.
-    $mcpUrl = $base;
-    if (!is_file($data . '/mcp.json')) {
-        $failures[] = 'no handshake written after a connected request';
-    } else {
-        $handshake = json_decode((string) file_get_contents($data . '/mcp.json'), true);
-        $mcpUrl = is_string($handshake['url'] ?? null) ? $handshake['url'] : $base;
-        if (!isset($handshake['url']) || !str_contains((string) $handshake['url'], '18082')) {
-            $failures[] = 'handshake url does not point at the running app: ' . json_encode($handshake['url'] ?? null);
-        }
-        if (!isset($handshake['cookies']['adminer_sid'])) {
-            $failures[] = 'handshake carries no adminer_sid, so nothing could borrow the session';
-        }
-        $perms = substr(sprintf('%o', (int) fileperms($data . '/mcp.json')), -3);
-        if ($perms !== '600') {
-            $failures[] = "handshake is $perms, expected 600 — it holds session cookies";
-        }
-    }
-
-    // 2. initialize and tools/list, straight at the endpoint.
-    [$init] = $rpc(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => []], $cookies, $mcpUrl);
-    $ours = $init['result']['protocolVersion'] ?? '';
-    if ($ours === '') {
-        $failures[] = 'initialize returned no protocolVersion: ' . json_encode($init);
-    }
-
-    // Negotiation, both directions. A client newer than us must be told what we actually
-    // implement rather than agreeably echoed — otherwise we promise whatever that revision
-    // added. A client older than us gets met where it is.
-    [$newer] = $rpc([
-        'jsonrpc' => '2.0', 'id' => 11, 'method' => 'initialize',
-        'params' => ['protocolVersion' => '2099-01-01'],
-    ], $cookies, $mcpUrl);
-    if (($newer['result']['protocolVersion'] ?? '') !== $ours) {
-        $failures[] = 'a newer client was echoed its own version instead of ours: '
-            . json_encode($newer['result']['protocolVersion'] ?? null);
-    }
-
-    [$older] = $rpc([
-        'jsonrpc' => '2.0', 'id' => 12, 'method' => 'initialize',
-        'params' => ['protocolVersion' => '2024-11-05'],
-    ], $cookies, $mcpUrl);
-    if (($older['result']['protocolVersion'] ?? '') !== '2024-11-05') {
-        $failures[] = 'an older client was not met at its own version: '
-            . json_encode($older['result']['protocolVersion'] ?? null);
-    }
-
-    [$list] = $rpc(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list', 'params' => []], $cookies, $mcpUrl);
-    $names = array_column($list['result']['tools'] ?? [], 'name');
-    foreach (['current_connection', 'list_tables', 'describe_table', 'preview_table_data', 'execute_query'] as $want) {
-        if (!in_array($want, $names, true)) {
-            $failures[] = "tools/list is missing $want (got: " . implode(', ', $names) . ')';
-        }
-    }
-
-    // 3. A tool call reaches the seeded database.
-    [$tables] = $rpc([
-        'jsonrpc' => '2.0', 'id' => 3, 'method' => 'tools/call',
-        'params' => ['name' => 'list_tables', 'arguments' => []],
-    ], $cookies, $mcpUrl);
-    $text = $tables['result']['content'][0]['text'] ?? '';
-    if (!str_contains($text, 'users')) {
-        $failures[] = 'list_tables did not mention the seeded users table: ' . substr($text, 0, 200);
-    }
-
-    // 4. The shim: the same call, but through stdin/stdout as an agent would run it.
-    $shim = new Process(
-        [$fix['root'] . '/bin/frankenphp', 'php-cli', $fix['root'] . '/app/mcp.php'],
-        null,
-        ['ADMINER_DESKTOP_DATA' => $data],
-    );
-    $shim->setInput(implode("\n", [
-        (string) json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => []]),
-        (string) json_encode(['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => [
-            'name' => 'execute_query',
-            'arguments' => ['sql' => 'SELECT count(*) AS n FROM users'],
-        ]]),
-    ]) . "\n");
-    $shim->run();
-    $out = $shim->getOutput();
-    if (!str_contains($out, 'protocolVersion')) {
-        $failures[] = 'the stdio shim did not answer initialize: ' . substr($out . $shim->getErrorOutput(), 0, 300);
-    }
-    // Decode rather than string-match: the tool's payload is JSON *inside* a JSON string, so a
-    // naive contains('"n"') looks for a quote that is escaped by the time it reaches the wire.
-    $rows = null;
-    foreach (explode("\n", trim($out)) as $line) {
-        $message = json_decode($line, true);
-        if (($message['id'] ?? null) === 2) {
-            $payload = json_decode($message['result']['content'][0]['text'] ?? '', true);
-            $rows = $payload['rows'] ?? null;
-        }
-    }
-    if (!is_array($rows) || count($rows) !== 1 || !isset($rows[0]['n'])) {
-        $failures[] = 'the stdio shim did not return the counted row: ' . substr($out . $shim->getErrorOutput(), 0, 300);
-    }
-
-    // 5. THE ONE THAT MATTERS: a write through execute_query must leave nothing behind.
-    $marker = 'mcp-rollback-probe';
-    $rpc([
-        'jsonrpc' => '2.0', 'id' => 4, 'method' => 'tools/call',
-        'params' => ['name' => 'execute_query', 'arguments' => [
-            'sql' => "INSERT INTO users (name) VALUES ('$marker')",
-        ]],
-    ], $cookies, $mcpUrl);
-
-    // A different marker: the cleanup below must not also delete the row the MCP call
-    // might have left behind, or the final count reads 0 whether rollback worked or not.
-    $probe = $marker . '-direct';
-    // Prove the INSERT was well-formed first, or "no row survived" would pass just as happily
-    // for a statement that never ran. Same SQL, straight at postgres: it must land, and then we
-    // take it back out.
-    $direct = new Process([
-        'docker', 'exec', 'adminer-demo-pg', 'psql', '-U', 'postgres', '-d', 'demo',
-        '-tAc', "INSERT INTO users (name) VALUES ('$probe'); SELECT count(*) FROM users WHERE name = '$probe'",
-    ]);
-    $direct->run();
-    // psql -tAc with two statements prints a line per statement; the count is the last one.
-    $lines = array_values(array_filter(array_map('trim', explode("\n", $direct->getOutput())), 'strlen'));
-    if (end($lines) !== '1') {
-        $failures[] = 'the probe INSERT is not valid SQL, so the rollback assertion below proves nothing: '
-            . trim($direct->getErrorOutput() ?: $direct->getOutput());
-    }
-    (new Process([
-        'docker', 'exec', 'adminer-demo-pg', 'psql', '-U', 'postgres', '-d', 'demo',
-        '-tAc', "DELETE FROM users WHERE name = '$probe'",
-    ]))->run();
-
-    // The answer must also *say* it was rolled back. RETURNING makes an INSERT produce an
-    // ordinary result set, so without this the caller sees rows and concludes it wrote — which
-    // is exactly what happened in use, and is worse than refusing the write outright.
-    [$written] = $rpc([
-        'jsonrpc' => '2.0', 'id' => 5, 'method' => 'tools/call',
-        'params' => ['name' => 'execute_query', 'arguments' => [
-            'sql' => "INSERT INTO users (name) VALUES ('$marker-returning') RETURNING id",
-        ]],
-    ], $cookies, $mcpUrl);
-    $payload = json_decode($written['result']['content'][0]['text'] ?? '', true);
-    if (($payload['rolled_back'] ?? null) !== true) {
-        $failures[] = 'a write answered without saying it was rolled back: ' . json_encode($payload);
-    }
-
-    $check = new Process([
-        'docker', 'exec', 'adminer-demo-pg', 'psql', '-U', 'postgres', '-d', 'demo',
-        '-tAc', "SELECT count(*) FROM users WHERE name LIKE '$marker%'",
-    ]);
-    $check->run();
-    $left = trim($check->getOutput());
-    if ($left !== '0') {
-        $failures[] = "READ-ONLY BROKEN: the INSERT survived (found $left row(s) named $marker)";
-    }
-
-    // 5b. Writes on: the same statement must now persist, and the answer must stop claiming a
-    //     rollback. Both directions matter — a read-only mode that silently commits is the
-    //     dangerous failure, and a write mode that silently discards is a confusing one.
-    file_put_contents($data . '/settings.json', (string) json_encode(['mcp' => true, 'mcp_write' => true]));
-    $kept = $marker . '-committed';
-    [$w] = $rpc([
-        'jsonrpc' => '2.0', 'id' => 6, 'method' => 'tools/call',
-        'params' => ['name' => 'execute_query', 'arguments' => [
-            'sql' => "INSERT INTO users (name) VALUES ('$kept') RETURNING id",
-        ]],
-    ], $cookies, $mcpUrl);
-    $wp = json_decode($w['result']['content'][0]['text'] ?? '', true);
-    if (($wp['rolled_back'] ?? null) !== false) {
-        $failures[] = 'with writes on the answer still claims a rollback: ' . json_encode($wp);
-    }
-    $kc = new Process([
-        'docker', 'exec', 'adminer-demo-pg', 'psql', '-U', 'postgres', '-d', 'demo',
-        '-tAc', "SELECT count(*) FROM users WHERE name = '$kept'",
-    ]);
-    $kc->run();
-    if (trim($kc->getOutput()) !== '1') {
-        $failures[] = 'writes were enabled but the row did not persist: ' . trim($kc->getOutput());
-    }
-    (new Process([
-        'docker', 'exec', 'adminer-demo-pg', 'psql', '-U', 'postgres', '-d', 'demo',
-        '-tAc', "DELETE FROM users WHERE name = '$kept'",
-    ]))->run();
-
-    // 6. Turning it off must retract the handshake, not just stop honouring it.
-    file_put_contents($data . '/settings.json', (string) json_encode(['mcp' => false]));
-    $ch = curl_init($base);
-    curl_setopt_array($ch, [
-        CURLOPT_RETURNTRANSFER => true,
-        CURLOPT_COOKIE => implode('; ', array_map(fn($k, $v) => "$k=$v", array_keys($cookies), $cookies)),
-    ]);
-    curl_exec($ch);
-    if (is_file($data . '/mcp.json')) {
-        $failures[] = 'handshake still on disk after the setting was turned off';
-    }
-} catch (\Throwable $e) {
-    $failures[] = 'mcp: ' . $e->getMessage();
-}
-
-@unlink($fix['data'] . '/settings.json');
-@unlink($fix['data'] . '/mcp.json');
-e2e_done($fix['server'], $failures, 'mcp');
diff --git a/tests/e2e/page-size.test.php b/tests/e2e/page-size.test.php
deleted file mode 100644
index 64f0098..0000000
--- a/tests/e2e/page-size.test.php
+++ /dev/null
@@ -1,75 +0,0 @@
- {
-	const field = document.querySelector(\"#form [name='limit']\");
-	return {
-		tag: field ? field.tagName : 'none',
-		value: field ? field.value : '',
-		options: field && field.options ? [...field.options].map((o) => o.value) : [],
-		rows: document.querySelectorAll('#table tbody tr').length,
-	};
-}";
-
-try {
-	$context = Playwright::chromium(['headless' => true]);
-	$page = $context->newPage();
-	$page->setViewportSize(1400, 900);
-
-	e2e_login($page, $fix['base'], $fix['pgPort']);
-	$page->goto($select);
-	$page->waitForLoadState('networkidle');
-
-	$before = $page->evaluate($measure);
-	if ($before['tag'] !== 'SELECT') {
-		$failures[] = "Limit is still a {$before['tag']}, not a list to pick from";
-		e2e_done($fix['server'], $failures, 'page-size');
-	}
-	// The size the page arrived with is the one showing, whether or not it is one of ours.
-	if ($before['value'] !== '50') {
-		$failures[] = "the list opened on '{$before['value']}', not the 50 the page was showing";
-	}
-	if (!in_array('50', $before['options'], true) || count($before['options']) < 5) {
-		$failures[] = 'the sizes offered look wrong: ' . implode(',', $before['options']);
-	}
-
-	// Pick a smaller one: no Select to press, and the rows follow.
-	$page->locator("#form select[name='limit']")->selectOption('10');
-	$page->waitForURL('**limit=10**');
-	$page->waitForLoadState('networkidle');
-	$after = $page->evaluate($measure);
-
-	if ($after['rows'] !== 10) {
-		$failures[] = sprintf('picking 10 left %d rows on screen, not 10', $after['rows']);
-	}
-	if ($after['value'] !== '10') {
-		$failures[] = "the list came back on '{$after['value']}', not the 10 that was picked";
-	}
-
-	$page->screenshot($fix['shots'] . '/page-size.png');
-	$context->close();
-} catch (\Throwable $e) {
-	$failures[] = 'page-size: ' . $e->getMessage();
-}
-
-e2e_done($fix['server'], $failures, 'page-size');
diff --git a/tests/e2e/plugins/edit-foreign.test.php b/tests/e2e/plugins/edit-foreign.test.php
deleted file mode 100644
index f700446..0000000
--- a/tests/e2e/plugins/edit-foreign.test.php
+++ /dev/null
@@ -1,74 +0,0 @@
- of the referenced table's rows, and upstream's default
- * limit of 0 means no LIMIT at all. We pass 100, and past it the plugin returns nothing so
- * Adminer's plain input stands.
- *
- * So both sides are the check: orders.user_id (six users) has to become a dropdown, and
- * big_child.lookup_id (150 rows) has to stay an input. A dropdown there would mean the
- * argument never arrived and every edit form on a big table reads it whole.
- *
- * Run via `make e2e` (tests/e2e/run.php runs it), or on its own with
- * ./bin/frankenphp php-cli tests/e2e/plugins/edit-foreign.test.php.
- */
-
-require dirname(__DIR__) . '/fixture.php';
-
-use Playwright\Playwright;
-
-$fix = e2e_boot();
-$failures = [];
-
-// Straight to settings.json: the POST path that writes it is check.sh's business, and this
-// check is about what the plugin does once it is on.
-file_put_contents($fix['data'] . '/settings.json', json_encode(['plugins' => ['edit-foreign' => true]]));
-
-$edit = fn(string $table): string => "{$fix['base']}?" . http_build_query([
-	'pgsql' => "127.0.0.1:{$fix['pgPort']}",
-	'username' => 'postgres',
-	'db' => 'demo',
-	'ns' => 'public',
-	'edit' => $table,
-	'where[id]' => 1,
-]);
-
-try {
-	$context = Playwright::chromium(['headless' => true]);
-	$page = $context->newPage();
-
-	e2e_login($page, $fix['base'], $fix['pgPort']);
-
-	// [table, field, has to be a dropdown, why]
-	$cases = [
-		['orders', 'user_id', true, 'six rows in users, well under the limit'],
-		['big_child', 'lookup_id', false, '150 rows in big_lookup, over the limit'],
-	];
-	foreach ($cases as [$table, $field, $wantSelect, $why]) {
-		$page->goto($edit($table));
-		$page->waitForLoadState('networkidle');
-		$tag = $page->evaluate("() => {
-			const el = document.querySelector('form [name=\"fields[$field]\"]');
-			return el ? el.tagName : null;
-		}");
-		$isSelect = ($tag === 'SELECT');
-		if ($tag === null) {
-			$failures[] = "edit-foreign: $table.$field is not on the edit form at all";
-		} elseif ($isSelect !== $wantSelect) {
-			$failures[] = $wantSelect
-				? "edit-foreign: $table.$field stayed a $tag — the plugin is not applying ($why)"
-				: "edit-foreign: $table.$field became a dropdown — the limit did not arrive, so the whole table was read ($why)";
-		}
-		$page->screenshot($fix['shots'] . "/plugins-edit-foreign-$table.png");
-	}
-} catch (Throwable $e) {
-	$failures[] = 'edit-foreign: ' . $e->getMessage();
-}
-
-@unlink($fix['data'] . '/settings.json');
-e2e_done($fix['server'], $failures, 'edit-foreign');
diff --git a/tests/e2e/plugins/json-column.test.php b/tests/e2e/plugins/json-column.test.php
deleted file mode 100644
index 51d0530..0000000
--- a/tests/e2e/plugins/json-column.test.php
+++ /dev/null
@@ -1,138 +0,0 @@
- "127.0.0.1:{$fix['pgPort']}",
-	'username' => 'postgres',
-	'db' => 'demo',
-	'ns' => 'public',
-	'edit' => 'documents',
-	'where[id]' => 1,
-]);
-
-/** Turn one plugin on, alone, and open the edit form under it.
- *
- * Straight to settings.json: the POST path that writes it is check.sh's business, and this check
- * is about what the plugin does once it is on.
- */
-$only = function (string $name) use ($fix, $edit): callable {
-	file_put_contents($fix['data'] . '/settings.json', json_encode(['plugins' => [$name => true]]));
-	return function ($page) use ($edit): void {
-		$page->goto($edit);
-		$page->waitForLoadState('networkidle');
-	};
-};
-
-/** Evaluate an expression with `el` bound to a field's editor and `td` to the cell around it. */
-$field = fn(string $name, string $expression): string => "() => {
-	const el = document.querySelector('form [name=\"fields[$name]\"]');
-	if (!el) { return 'MISSING'; }
-	const td = el.closest('td');
-	return String($expression);
-}";
-
-try {
-	$context = Playwright::chromium(['headless' => true]);
-	$page = $context->newPage();
-
-	e2e_login($page, $fix['base'], $fix['pgPort']);
-
-	// ── pretty-json-column: its own textarea, holding the value indented ──
-	$only('pretty-json-column')($page);
-
-	// [column, has to be taken over, why]
-	foreach ([
-		// payload passes with the plugin off too — Adminer marks a jsonb column jush-js itself,
-		// so this line only catches the plugin turning it into something else. notes is the proof.
-		['payload', true, 'jsonb'],
-		['notes', true, 'text holding JSON — the plugin sniffs the value, not the column type'],
-		['title', false, 'plain text, nothing for the plugin to do'],
-	] as [$name, $wantOurs, $why]) {
-		// The tag alone would say nothing — Adminer renders a text column as a textarea anyway.
-		// The plugin's marker is the jush-js class on the one it builds.
-		$marker = $page->evaluate($field($name, "el.tagName + (el.classList.contains('jush-js') ? '.jush-js' : '')"));
-		if ($marker === 'MISSING') {
-			$failures[] = "pretty-json-column: documents.$name is not on the edit form at all";
-		} elseif (($marker === 'TEXTAREA.jush-js') !== $wantOurs) {
-			$failures[] = $wantOurs
-				? "pretty-json-column: documents.$name is a $marker — the plugin is not applying ($why)"
-				: "pretty-json-column: documents.$name is a $marker — the plugin took over a value it should not have ($why)";
-		}
-	}
-
-	// The point of the plugin, and what a lost json_encode flag would cost while the textarea
-	// above still passed. On notes, because nothing but the plugin formats a text column.
-	$notes = (string) $page->evaluate($field('notes', 'el.value'));
-	if (!str_contains($notes, "\n    \"")) {
-		$failures[] = 'pretty-json-column: documents.notes is not pretty-printed — ' . var_export(substr($notes, 0, 120), true);
-	}
-	if (!str_contains($notes, 'Dvořáková')) {
-		$failures[] = 'pretty-json-column: documents.notes lost its unicode (JSON_UNESCAPED_UNICODE)';
-	}
-	$page->screenshot($fix['shots'] . '/plugins-pretty-json-column.png');
-
-	// ── json-column: a table of the keys, echoed beside Adminer's own input ──
-	$only('json-column')($page);
-
-	// [column, the keys its table has to list]
-	foreach ([
-		['payload', 'customer,paid'],
-		['notes', 'author,revision'],
-	] as [$name, $wantKeys]) {
-		$keys = $page->evaluate($field($name, "[...td.querySelectorAll('table > tbody > tr > th')].map(th => th.textContent).sort().join(',')"));
-		if ($keys === 'MISSING') {
-			$failures[] = "json-column: documents.$name is not on the edit form at all";
-			continue;
-		}
-		foreach (explode(',', $wantKeys) as $key) {
-			if (!str_contains((string) $keys, $key)) {
-				$failures[] = "json-column: documents.$name has no $key in its table — the plugin is not applying (keys: " . var_export($keys, true) . ')';
-			}
-		}
-	}
-	if ($page->evaluate($field('title', "!!td.querySelector('table')")) === 'true') {
-		$failures[] = 'json-column: documents.title got a table — the plugin took over a value that is not JSON';
-	}
-	$page->screenshot($fix['shots'] . '/plugins-json-column.png');
-} catch (Throwable $e) {
-	$failures[] = 'json-column: ' . $e->getMessage();
-}
-
-@unlink($fix['data'] . '/settings.json');
-e2e_done($fix['server'], $failures, 'json-column');
diff --git a/tests/e2e/run.php b/tests/e2e/run.php
deleted file mode 100644
index 207eaff..0000000
--- a/tests/e2e/run.php
+++ /dev/null
@@ -1,44 +0,0 @@
-setTimeout(300);
-	$p->run(function ($type, $buffer) {
-		echo $buffer;
-	});
-	if (!$p->isSuccessful()) {
-		$failed[] = $check;
-	}
-}
-
-if ($failed) {
-	echo "\ne2e FAILED: " . implode(', ', $failed) . "\n";
-	exit(1);
-}
-echo "\ne2e ok — screenshots in tests/e2e/screenshots/\n";
diff --git a/tests/e2e/seed/mysql.sql b/tests/e2e/seed/mysql.sql
new file mode 100644
index 0000000..3bce111
--- /dev/null
+++ b/tests/e2e/seed/mysql.sql
@@ -0,0 +1,168 @@
+-- Demo data for the MySQL suite (tests/e2e/behat.yml, `make e2e`).
+--
+-- The same tables as seed/pgsql.sql, in this driver's own types, because features/data/ runs
+-- against both and a scenario cannot know which one it is on. What the two files do not share is
+-- how they are built: no generate_series, no DO block, and JSON rather than jsonb.
+--
+-- Applied on every run — the drops below are what makes that idempotent — so editing this file
+-- reaches the database without dropping the container first.
+
+DROP TABLE IF EXISTS orders;
+DROP TABLE IF EXISTS users;
+DROP TABLE IF EXISTS big_child;
+DROP TABLE IF EXISTS big_lookup;
+DROP TABLE IF EXISTS documents;
+
+CREATE TABLE users (
+	id         int AUTO_INCREMENT PRIMARY KEY,
+	name       text,
+	email      text,
+	created_at date,
+	active     tinyint(1)
+);
+
+INSERT INTO users (name, email, created_at, active) VALUES
+	('Anna Nováková',  'anna@example.com',  '2026-01-04', 1),
+	('Bára Dvořáková', 'bara@example.com',  '2026-02-11', 1),
+	('Cyril Kučera',   'cyril@example.com', '2026-03-19', 0),
+	('Dana Marková',   'dana@example.com',  '2026-04-27', 1),
+	('Emil Horák',     'emil@example.com',  '2026-05-30', 1),
+	('Filip Beneš',    'filip@example.com', '2026-06-08', 0);
+
+CREATE TABLE orders (
+	id      int AUTO_INCREMENT PRIMARY KEY,
+	user_id int,
+	total   decimal(10,2),
+	status  text,
+	FOREIGN KEY (user_id) REFERENCES users(id)
+);
+
+INSERT INTO orders (user_id, total, status) VALUES
+	(1, 1299.00, 'paid'),
+	(1,   49.90, 'paid'),
+	(2,  320.50, 'pending'),
+	(3,   15.00, 'cancelled'),
+	(4,  880.00, 'paid');
+
+-- A foreign key pointing at more rows than AdminerEditForeign is allowed to put in a dropdown
+-- (PluginList::ARGUMENTS caps it at 100), and orders.user_id is the other side of that.
+CREATE TABLE big_lookup (
+	id    int AUTO_INCREMENT PRIMARY KEY,
+	label text
+);
+INSERT INTO big_lookup (label)
+WITH RECURSIVE seq (n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 150)
+SELECT CONCAT('label ', n) FROM seq;
+
+CREATE TABLE big_child (
+	id        int AUTO_INCREMENT PRIMARY KEY,
+	lookup_id int,
+	FOREIGN KEY (lookup_id) REFERENCES big_lookup(id)
+);
+INSERT INTO big_child (lookup_id) VALUES (1);
+
+-- Fifty documents, because the data-list features count on it: ten pages at five to a page, and a
+-- payload long enough that the column arrives cut off, which is what makes widening it fetch more.
+-- The titles are deliberately not in id order, so sorting by one visibly reorders the rows, and
+-- they carry accents, which is what a broken encoding loses.
+CREATE TABLE documents (
+	id      int AUTO_INCREMENT PRIMARY KEY,
+	title   text,
+	payload json,
+	notes   text
+);
+
+INSERT INTO documents (title, payload, notes) VALUES
+	('Smlouva', '{"customer":{"name":"Anna Nováková","id":1},"items":[{"sku":"A-1","qty":2}],"paid":true}', '{"author":{"name":"Bára Dvořáková"},"revision":3}'),
+	('Faktura', '{"total":1299,"currency":"CZK"}', 'not json at all');
+
+INSERT INTO documents (title, payload, notes)
+WITH RECURSIVE seq (n) AS (SELECT 3 UNION ALL SELECT n + 1 FROM seq WHERE n < 50)
+SELECT
+	CONCAT('Objednávka ', LPAD(n, 3, '0'), '/2026'),
+	JSON_OBJECT(
+		'order_no', CONCAT('OBJ-2026-', LPAD(n, 4, '0')),
+		'paid', n % 3 = 0,
+		'currency', ELT(1 + n % 3, 'CZK', 'EUR', 'USD'),
+		'customer', JSON_OBJECT(
+			'id', n,
+			'name', ELT(1 + n % 5, 'Anna Nováková', 'Bára Dvořáková', 'Cyril Kučera', 'Dana Marková', 'Emil Horák'),
+			'vat_id', CONCAT('CZ', 10000000 + n * 7919),
+			'address', JSON_OBJECT(
+				'street', CONCAT('Náměstí Míru ', n),
+				'city', ELT(1 + n % 5, 'Praha', 'Brno', 'Ostrava', 'Plzeň', 'Olomouc'),
+				'zip', CONCAT(100 + n, ' 00'),
+				'country', 'CZ'
+			),
+			'contacts', JSON_ARRAY(
+				JSON_OBJECT('kind', 'email', 'value', CONCAT('zakaznik', n, '@example.com')),
+				JSON_OBJECT('kind', 'phone', 'value', CONCAT('+420 ', 600000000 + n * 131))
+			)
+		),
+		-- The part that makes the value long, and that a pretty-printer has to indent several
+		-- levels. Three items rather than pgsql's three-to-ten: MySQL has no jsonb_agg, and a
+		-- correlated aggregate here would be a subquery per row for no assertion's benefit.
+		'items', JSON_ARRAY(
+			JSON_OBJECT(
+				'sku', CONCAT('KBD-', LPAD(n * 10 + 1, 5, '0')),
+				'name', 'Klávesnice mechanická',
+				'qty', 1 + n % 6,
+				'unit_price', ROUND((199 + (n * 37) % 24000) / 10, 2),
+				'warehouse', JSON_OBJECT('code', CONCAT('W', 1 + n % 4), 'shelf', CONCAT(CHAR(65 + n % 6), '-', 10 + n))
+			),
+			JSON_OBJECT(
+				'sku', CONCAT('MON-', LPAD(n * 10 + 2, 5, '0')),
+				'name', 'Monitor 27" QHD',
+				'qty', 1 + (n + 1) % 6,
+				'unit_price', ROUND((199 + (n * 71) % 24000) / 10, 2),
+				'warehouse', JSON_OBJECT('code', CONCAT('W', 1 + (n + 1) % 4), 'shelf', CONCAT(CHAR(65 + (n + 1) % 6), '-', 11 + n))
+			),
+			JSON_OBJECT(
+				'sku', CONCAT('GPU-', LPAD(n * 10 + 3, 5, '0')),
+				'name', 'Grafická karta',
+				'qty', 1 + (n + 2) % 6,
+				'unit_price', ROUND((199 + (n * 113) % 24000) / 10, 2),
+				'warehouse', JSON_OBJECT('code', CONCAT('W', 1 + (n + 2) % 4), 'shelf', CONCAT(CHAR(65 + (n + 2) % 6), '-', 12 + n))
+			)
+		),
+		'note', REPEAT(CONCAT('Poznámka k objednávce ', n, '. '), 1 + n % 4)
+	),
+	-- Half text holding JSON, half plain text, so both cases are in the table rather than only in
+	-- the two rows written out above.
+	IF(n % 2 = 0,
+		-- CAST, not JSON_UNQUOTE: the column is text holding JSON, and what goes in it is the
+		-- object's own serialisation rather than a string that once was one.
+		CAST(JSON_OBJECT(
+			'author', JSON_OBJECT('name', ELT(1 + n % 2, 'Bára Dvořáková', 'Emil Horák'), 'role', 'fakturace'),
+			'revision', n % 7,
+			'checks', JSON_ARRAY('vat', 'address', 'stock')
+		) AS CHAR),
+		CONCAT('Vyřízeno telefonicky, ', 3 + n % 8, '. položek')
+	)
+FROM seq;
+
+-- Filler tables. Two tables fit in the sidebar without scrolling, which hides every problem that
+-- only shows on a real database. Forty is enough to overflow the panel at any window size, and
+-- they sort ahead of users and orders so those sit below the fold.
+--
+-- A procedure, because MySQL has no anonymous DO block: written, called and dropped again, so the
+-- database is left holding only tables.
+DROP PROCEDURE IF EXISTS make_filler;
+DELIMITER //
+CREATE PROCEDURE make_filler()
+BEGIN
+	DECLARE i INT DEFAULT 1;
+	WHILE i <= 40 DO
+		SET @filler = CONCAT('filler_', LPAD(i, 2, '0'));
+		SET @sql = CONCAT('DROP TABLE IF EXISTS ', @filler);
+		PREPARE run FROM @sql; EXECUTE run; DEALLOCATE PREPARE run;
+		SET @sql = CONCAT('CREATE TABLE ', @filler, ' (id int AUTO_INCREMENT PRIMARY KEY, note text)');
+		PREPARE run FROM @sql; EXECUTE run; DEALLOCATE PREPARE run;
+		SET @sql = CONCAT('INSERT INTO ', @filler, " (note) VALUES ('first row'), ('second row')");
+		PREPARE run FROM @sql; EXECUTE run; DEALLOCATE PREPARE run;
+		SET i = i + 1;
+	END WHILE;
+END //
+DELIMITER ;
+CALL make_filler();
+DROP PROCEDURE make_filler;
diff --git a/tests/e2e/seed.sql b/tests/e2e/seed/pgsql.sql
similarity index 100%
rename from tests/e2e/seed.sql
rename to tests/e2e/seed/pgsql.sql
diff --git a/tests/e2e/settings.test.php b/tests/e2e/settings.test.php
deleted file mode 100644
index 02f2234..0000000
--- a/tests/e2e/settings.test.php
+++ /dev/null
@@ -1,199 +0,0 @@
- is relocated into the settings form for layout, and while it still
- * carried name="lang", Save posted lang too — Adminer's lang.inc.php treats any request
- * carrying lang as a language switch and redirects before handlePost applies the settings,
- * so nothing saved. Each block changes one thing and asserts it came back.
- *
- * Run via `make e2e` (tests/e2e/run.php runs it), or on its own with
- * ./bin/frankenphp php-cli tests/e2e/settings.test.php.
- */
-
-require __DIR__ . '/fixture.php';
-
-use Playwright\Playwright;
-
-$fix = e2e_boot();
-$failures = [];
-
-/** Open the settings dialog and let showModal() settle — its contents are display:none
- * until the modal is actually open, so anything reaching inside races the animation.
- *
- * Only if it is closed. Changing the language reopens it (settings-dialog.js restores the
- * dialog across the reload the switch causes), and clicking the gear while the modal is up
- * means clicking an element behind the backdrop, which never becomes actionable — the whole
- * check timed out there rather than failing on anything it asserts. */
-$openDialog = function ($page) {
-	if (!$page->evaluate("() => document.querySelector('#desktop-settings').open")) {
-		$page->locator('#desktop-gear')->click();
-		usleep(300_000);
-	}
-};
-
-try {
-	$context = Playwright::chromium(['headless' => true]);
-	$page = $context->newPage();
-	e2e_login($page, $fix['base'], $fix['pgPort']);
-	$page->goto($fix['select']);
-	$page->waitForLoadState('networkidle');
-
-	// 1. Row density -> a body class the theme keys off.
-	$openDialog($page);
-	$page->locator('input[name="density"][value="compact"]')->check(['force' => true]);
-	$page->locator('#desktop-save')->click();
-	$page->waitForLoadState('networkidle');
-	$body = (string) $page->evaluate("() => document.body.className");
-	if (!str_contains($body, 'density-compact')) {
-		$failures[] = "density: did not save (body class: $body)";
-	}
-	// And it landed in the persistent store, not just this session — settings.json is what
-	// survives a cold start (issue #10) and what the debug panel reads.
-	$settingsFile = sys_get_temp_dir() . '/adminer-desktop-e2e/settings.json';
-	clearstatcache(true, $settingsFile);
-	$stored = is_file($settingsFile) ? json_decode((string) file_get_contents($settingsFile), true) : [];
-	if (!is_array($stored) || ($stored['density'] ?? null) !== 'compact') {
-		$failures[] = 'density: not persisted to settings.json (got: ' . json_encode($stored) . ')';
-	}
-
-	// 2. A light design -> its stylesheet is linked. Whichever gallery design is offered
-	// first, so this does not break when the catalogue changes.
-	$openDialog($page);
-	$design = $page->evaluate("() => { const r = [...document.querySelectorAll('input[name=design_light]')].find(x => x.value); return r ? r.value : null; }");
-	if (!$design) {
-		$failures[] = "design: no gallery design was offered to pick";
-	} else {
-		$page->locator("input[name=\"design_light\"][value=\"$design\"]")->check(['force' => true]);
-		$page->locator('#desktop-save')->click();
-		$page->waitForLoadState('networkidle');
-		$linked = (bool) $page->evaluate("(d) => [...document.querySelectorAll('link[rel=stylesheet]')].some(l => (l.getAttribute('href') || '').includes(d))", $design);
-		if (!$linked) {
-			$failures[] = "design: chosen design ($design) did not save";
-		}
-	}
-
-	// 3. A plugin -> ticking it writes the name into settings.json, unticking takes it out,
-	// so the stored set is the assertion. The tick/untick is set on the checkbox directly:
-	// what this guards is that Save persists it, not the browser's own checkbox toggle.
-	// Enable then disable, so the working tree is left as it was found.
-	// row-numbers specifically: it only numbers rows in a select, so it cannot change
-	// anything this test looks at. Fall back to whatever is first if it is gone.
-	// No need to open the dialog to read this — the panel is in the DOM either way.
-	$plugin = $page->evaluate("() => {
-		const pick = document.querySelector('input[name=\"plugins[]\"][value=\"row-numbers\"]')
-			|| document.querySelector('input[name=\"plugins[]\"]');
-		return pick ? pick.value : null;
-	}");
-	$stored = fn(): string => (string) @file_get_contents($fix['data'] . "/settings.json");
-	if (!$plugin) {
-		$failures[] = "plugins: none were offered to toggle";
-	} else {
-		$setPlugin = function (bool $on) use ($page, $plugin, $openDialog) {
-			$openDialog($page);
-			$checked = $on ? 'true' : 'false';
-			$page->evaluate("() => { document.querySelector(\"input[name='plugins[]'][value='$plugin']\").checked = $checked; }");
-			$page->locator('#desktop-save')->click();
-			$page->waitForLoadState('networkidle');
-		};
-		$setPlugin(true);
-		if (!str_contains($stored(), "\"$plugin\"")) {
-			$failures[] = "plugins: enabling '$plugin' did not save (not in settings.json)";
-		}
-		$setPlugin(false);
-		if (str_contains($stored(), "\"$plugin\"")) {
-			$failures[] = "plugins: disabling '$plugin' did not save (still in settings.json)";
-		}
-	}
-
-	// 4. The language switch -> its own onchange posts and reloads in the new language.
-	// This is the control that broke Save; here it must still switch on its own, and the
-	// saves above prove it no longer breaks the form it sits in.
-	//
-	// The onchange navigates without Playwright starting the click that would wait for it,
-	// so poll  until the reload lands rather than racing it with one evaluate.
-	$switchLang = function ($page, string $to) use ($openDialog): string {
-		$openDialog($page);
-		$page->locator('#desktop-lang-slot select')->selectOption($to);
-		for ($i = 0; $i < 30; $i++) {
-			usleep(200_000);
-			try {
-				$lang = (string) $page->evaluate("() => document.documentElement.lang");
-			} catch (\Throwable $e) {
-				continue; // mid-navigation; the context was torn down, try again
-			}
-			if ($lang === $to) {
-				return $lang;
-			}
-		}
-		return $lang ?? '';
-	};
-	$htmlLang = $switchLang($page, 'de');
-	if ($htmlLang !== 'de') {
-		$failures[] = "language: switch did not apply (html lang: $htmlLang)";
-	}
-	$switchLang($page, 'en'); // back to English, so a rerun starts where this one did
-
-	// 5. Appearance override -> forcing Dark must pin the dark scheme even though this
-	// context's OS is light (no colorScheme emulation here). Proves the whole path end to
-	// end: the radio posts, cssMap hands adminer only the dark side, adminer's
-	// color-scheme meta flips the theme's light-dark() tokens to dark. Reset to Sync with
-	// OS after, so a rerun starts clean.
-	$readSurface = "() => {
-		const el = document.querySelector('#content') || document.body;
-		const [r, g, b] = getComputedStyle(el).backgroundColor.match(/\\d+/g).map(Number);
-		return r + g + b < 200; // dark surface?
-	}";
-	$osDark = (bool) $page->evaluate("() => matchMedia('(prefers-color-scheme: dark)').matches");
-	$openDialog($page);
-	$page->locator('input[name="appearance"][value="dark"]')->check(['force' => true]);
-	$page->locator('#desktop-save')->click();
-	$page->waitForLoadState('networkidle');
-	$appBody = (string) $page->evaluate("() => document.body.className");
-	$forcedDark = (bool) $page->evaluate($readSurface);
-	if ($osDark) {
-		$failures[] = "appearance: a light OS context is needed to prove the override";
-	} elseif (!str_contains($appBody, 'theme-dark')) {
-		$failures[] = "appearance: Dark did not save (body class: $appBody)";
-	} elseif (!$forcedDark) {
-		$failures[] = "appearance: Dark override did not render dark under a light OS";
-	}
-	$openDialog($page);
-	$page->locator('input[name="appearance"][value="auto"]')->check(['force' => true]);
-	$page->locator('#desktop-save')->click();
-	$page->waitForLoadState('networkidle');
-
-	$page->screenshot($fix['shots'] . '/settings.png');
-
-	// 6. Reset to defaults -> the file goes, and the page comes back at the defaults. Last,
-	// because it throws away everything the cases above saved. A dragged width goes in first:
-	// the reset has to forget what the api stored too, not only the dialog's own fields.
-	$page->evaluate("() => navigator.sendBeacon(window.desktopApi.resize, new URLSearchParams({what: 'sidebar', width: '420'}))");
-	$openDialog($page);
-	$page->locator('input[name="density"][value="compact"]')->check(['force' => true]);
-	$page->locator('#desktop-save')->click();
-	$page->waitForLoadState('networkidle');
-	// Playwright dismisses a native confirm() by default, which would answer no; this is the
-	// user pressing yes.
-	$page->evaluate("() => { window.confirm = () => true; }");
-	$openDialog($page);
-	$page->locator('#desktop-reset')->click();
-	$page->waitForLoadState('networkidle');
-	usleep(300_000); // the redirect lands before the unlink is visible to this process
-	clearstatcache(true, $settingsFile);
-	if (is_file($settingsFile)) {
-		$failures[] = 'reset: settings.json survived (' . (string) file_get_contents($settingsFile) . ')';
-	}
-	$resetBody = (string) $page->evaluate("() => document.body.className");
-	if (!str_contains($resetBody, 'density-cozy')) {
-		$failures[] = "reset: the page did not come back at the defaults (body class: $resetBody)";
-	}
-
-	$context->close();
-} catch (\Throwable $e) {
-	$failures[] = 'settings: ' . $e->getMessage();
-}
-
-e2e_done($fix['server'], $failures, 'settings');
diff --git a/tests/e2e/sidebar-resize.test.php b/tests/e2e/sidebar-resize.test.php
deleted file mode 100644
index c158a72..0000000
--- a/tests/e2e/sidebar-resize.test.php
+++ /dev/null
@@ -1,116 +0,0 @@
- true]);
-	$page = $context->newPage();
-
-	e2e_login($page, $fix['base'], $fix['pgPort']);
-	$page->goto($fix['select']);
-	$page->waitForLoadState('networkidle');
-
-	// The handle only exists under the islands layout; its absence is the first failure.
-	$rect = $page->evaluate(/** @lang JavaScript */ "() => {
-		const h = document.querySelector('#ad-sidebar-resizer');
-		if (!h) { return null; }
-		const r = h.getBoundingClientRect();
-		return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
-	}");
-	if (!is_array($rect)) {
-		$failures[] = 'the resize handle was not inserted';
-		e2e_done($fix['server'], $failures, 'sidebar-resize');
-	}
-
-	$footWidth = static fn () => (float) $page->evaluate(
-		/** @lang JavaScript */ "() => document.querySelector('#foot').getBoundingClientRect().width",
-	);
-	$before = $footWidth();
-
-	// Drag the handle 120px to the right; the sidebar should follow it.
-	$mouse = $page->mouse();
-	$mouse->move($rect['x'], $rect['y']);
-	$mouse->down();
-	$mouse->move($rect['x'] + 120, $rect['y'], ['steps' => 10]);
-	$mouse->up();
-
-	$after = $footWidth();
-	if ($after - $before < 90) {
-		$failures[] = sprintf('the drag did not widen the sidebar (%.0f -> %.0f)', $before, $after);
-	}
-
-	// The dragged width is stored, matching what the panel actually renders at.
-	$stored = $storedWidth();
-	if ($stored === null) {
-		$failures[] = 'the width was not persisted to settings.json';
-	} elseif (abs($stored - $after) > 3) {
-		$failures[] = sprintf('the stored width %d does not match the rendered %.0f', $stored, $after);
-	}
-
-	// Cold start: a fresh page must open at the stored width before any drag — head() emits
-	// it into the initial HTML, so the property is already set on load.
-	if ($stored !== null) {
-		$cold = $context->newPage();
-		$cold->goto($fix['select']);
-		$cold->waitForLoadState('networkidle');
-		$coldWidth = (float) $cold->evaluate(/** @lang JavaScript */ "() => document.querySelector('#foot').getBoundingClientRect().width");
-		if (abs($coldWidth - $stored) > 3) {
-			$failures[] = sprintf('cold start opened at %.0f, not the stored %d', $coldWidth, $stored);
-		}
-		$cold->close();
-	}
-
-	// Keyboard: focus the splitter and nudge it narrower; the accessible path must move it too.
-	$page->locator('#ad-sidebar-resizer')->focus();
-	$wide = $footWidth();
-	for ($i = 0; $i < 5; $i++) {
-		$page->keyboard()->press('ArrowLeft');
-	}
-	if ($footWidth() >= $wide) {
-		$failures[] = 'ArrowLeft did not narrow the sidebar';
-	}
-
-	$context->close();
-} catch (\Throwable $e) {
-	$failures[] = 'sidebar-resize: ' . $e->getMessage();
-}
-
-e2e_done($fix['server'], $failures, 'sidebar-resize');
diff --git a/tests/e2e/table-columns.test.php b/tests/e2e/table-columns.test.php
deleted file mode 100644
index 0692432..0000000
--- a/tests/e2e/table-columns.test.php
+++ /dev/null
@@ -1,202 +0,0 @@
- {
-	const ths = [...document.querySelectorAll('#table tr:first-child th')];
-	const grip = ths[column].querySelector('.ad-column-grip');
-	if (!grip) { return null; }
-	const box = grip.getBoundingClientRect();
-	const content = document.querySelector('#content');
-	const footer = document.querySelector('.footer');
-	return {
-		widths: ths.map((th) => Math.round(th.getBoundingClientRect().width)),
-		// Deliberately not the header: the grip runs the height of the column, and grabbing it
-		// beside a data row well below the header is the point of that.
-		grip: { x: box.right - 1, y: box.top + Math.min(200, box.height - 10) },
-		gripHeight: Math.round(box.height),
-		// How far it runs past adminer's sticky row actions, which float over the last rows —
-		// the list ends where they begin, margin included: that gap is the footer's own
-		// background shadow, painted over the rows behind it.
-		pastFooter: Math.round(
-			box.bottom - (footer.getBoundingClientRect().top - Number.parseFloat(getComputedStyle(footer).marginTop))
-		),
-		textLength: Number(document.querySelector(\"input[name='text_length']\").value),
-		table: Math.round(document.querySelector('#table').getBoundingClientRect().width),
-		contentScrolls: content.scrollWidth > content.clientWidth,
-		windowScrolls: document.documentElement.scrollWidth > document.documentElement.clientWidth,
-		checked: [...document.querySelectorAll('#table input[type=checkbox]')].filter((c) => c.checked).length,
-		// Highlighted values, so a swapped-in row is not plain text where the one it replaced
-		// was coloured.
-		highlighted: document.querySelectorAll('#table tbody code span.jush-js_val, #table tbody code span[class^=jush]').length,
-		// The longest value on show: what raising Text length is actually for.
-		longestValue: Math.max(...[...document.querySelectorAll('#table tbody tr')]
-			.map((tr) => (tr.cells[column + 1]?.textContent ?? '').length)),
-	};
-}";
-
-try {
-	$context = Playwright::chromium(['headless' => true]);
-	$page = $context->newPage();
-	$page->setViewportSize(1600, 900);
-
-	$drag = static function (array $at, int $by) use ($page): void {
-		$mouse = $page->mouse();
-		$mouse->move($at['x'], $at['y']);
-		$mouse->down();
-		$mouse->move($at['x'] + $by, $at['y'], ['steps' => 10]);
-		$mouse->up();
-	};
-
-	e2e_login($page, $fix['base'], $fix['pgPort']);
-	$page->goto($select);
-	$page->waitForLoadState('networkidle');
-
-	// 1. title: 150px wider is still fewer characters than Text length already allows, so this
-	// is the resize on its own, with no query behind it.
-	$before = $page->evaluate($measure, 1);
-	if (!is_array($before)) {
-		$failures[] = 'no resize grip was added to the column headers';
-		e2e_done($fix['server'], $failures, 'table-columns');
-	}
-	$drag($before['grip'], 150);
-	$after = $page->evaluate($measure, 1);
-
-	if ($after['widths'][1] - $before['widths'][1] < 120) {
-		$failures[] = sprintf('the drag did not widen the column (%d -> %d)', $before['widths'][1], $after['widths'][1]);
-	}
-	// The neighbours keep what they had: the table grows instead of them shrinking.
-	foreach ([0, 2, 3] as $other) {
-		if (abs($after['widths'][$other] - $before['widths'][$other]) > 2) {
-			$failures[] = sprintf(
-				'column %d moved with the drag (%d -> %d)',
-				$other,
-				$before['widths'][$other],
-				$after['widths'][$other],
-			);
-		}
-	}
-	if ($after['table'] - $before['table'] < 120) {
-		$failures[] = sprintf('the table did not grow with the column (%d -> %d)', $before['table'], $after['table']);
-	}
-	// And the wider table scrolls in the panel, not by pushing the whole window sideways.
-	if (!$after['contentScrolls'] || $after['windowScrolls']) {
-		$failures[] = 'the widened table did not scroll inside the content panel';
-	}
-	// The drag is not a click on the header: adminer's tableClick is bound to the table, and a
-	// click reaching it from the grip ticks the header row's box, which is every row selected.
-	if ($after['checked'] > 0) {
-		$failures[] = sprintf('the drag selected rows (%d checkboxes ticked)', $after['checked']);
-	}
-	// The grip is the column's, not the header's — this drag was grabbed beside a data row, so
-	// it only worked at all because of that, but the height says it plainly.
-	if ($before['gripHeight'] < 200) {
-		$failures[] = sprintf('the grip is only %dpx tall, not the column', $before['gripHeight']);
-	}
-	// And it stops where the list does rather than running down over the buttons.
-	if ($before['pastFooter'] > 1) {
-		$failures[] = sprintf('the grip runs %dpx past the row actions', $before['pastFooter']);
-	}
-	// This much text already fits, so nothing is re-fetched and nothing reloads.
-	if ($after['textLength'] !== $before['textLength']) {
-		$failures[] = sprintf(
-			'a column that already fits raised Text length anyway (%d -> %d)',
-			$before['textLength'],
-			$after['textLength'],
-		);
-	}
-
-	// A reload keeps it: sessionStorage lives as long as the window does.
-	$page->goto($select);
-	$page->waitForLoadState('networkidle');
-	$reloaded = $page->evaluate($measure, 1);
-	if (abs($reloaded['widths'][1] - $after['widths'][1]) > 2) {
-		$failures[] = sprintf('the reload lost the width (%d, not %d)', $reloaded['widths'][1], $after['widths'][1]);
-	}
-
-	// 2. payload: json, and already cut to fit the width it had. Widening it raises Text length
-	// and runs the query again — in place, so there is a url to wait for but no new document.
-	$wide = $page->evaluate($measure, 2);
-	$drag($wide['grip'], 200);
-	$page->waitForURL('**text_length=**');
-	$refetched = $page->evaluate($measure, 2);
-
-	if ($refetched['textLength'] <= $wide['textLength']) {
-		$failures[] = sprintf('the widened json column did not raise Text length (still %d)', $refetched['textLength']);
-	}
-	// The number is the column's width in its own characters, so it has to clear that width in
-	// the widest plausible ones — measuring the wrong column's font reads as a pass at 101.
-	if ($refetched['textLength'] < $refetched['widths'][2] / 12) {
-		$failures[] = sprintf(
-			'Text length %d is too small for a %dpx column',
-			$refetched['textLength'],
-			$refetched['widths'][2],
-		);
-	}
-	// Raising the number is no use unless the query runs again — and the proof of that is on
-	// screen: the values in the widened column are longer than the ones it replaced.
-	if ($refetched['longestValue'] <= $wide['longestValue']) {
-		$failures[] = sprintf(
-			'the re-run fetched no more text (longest value still %d characters)',
-			$refetched['longestValue'],
-		);
-	}
-	// The rows that arrived are highlighted like the ones they replaced — adminer colours the
-	// values once at load, so anything swapped in afterwards is plain text unless asked.
-	if ($refetched['highlighted'] < $wide['highlighted']) {
-		$failures[] = sprintf(
-			'the re-run lost the syntax highlighting (%d highlighted spans, was %d)',
-			$refetched['highlighted'],
-			$wide['highlighted'],
-		);
-	}
-	// And the column that caused it comes back at the width it was dragged to.
-	if (abs($refetched['widths'][2] - ($wide['widths'][2] + 200)) > 4) {
-		$failures[] = sprintf(
-			'the re-run lost the dragged width (%d, not %d)',
-			$refetched['widths'][2],
-			$wide['widths'][2] + 200,
-		);
-	}
-
-	// But it is only the session's: nothing about columns reaches the durable file.
-	clearstatcache(true, $settings);
-	$stored = is_file($settings) ? (string) file_get_contents($settings) : '';
-	if (str_contains($stored, 'column')) {
-		$failures[] = "a column width reached settings.json: $stored";
-	}
-
-	$page->screenshot($fix['shots'] . '/table-columns.png');
-	$context->close();
-} catch (\Throwable $e) {
-	$failures[] = 'table-columns: ' . $e->getMessage();
-}
-
-e2e_done($fix['server'], $failures, 'table-columns');
diff --git a/tests/e2e/table-pager.test.php b/tests/e2e/table-pager.test.php
deleted file mode 100644
index c5c2544..0000000
--- a/tests/e2e/table-pager.test.php
+++ /dev/null
@@ -1,134 +0,0 @@
- {
-	const steps = [...document.querySelectorAll('.ad-page-step')];
-	const list = document.querySelector('.ad-page-select');
-	return {
-		steps: steps.length,
-		// A step with nowhere to go is a , so it neither invites a click nor moves the row.
-		ends: steps.filter((s) => s.tagName !== 'A').map((s) => s.textContent),
-		pages: list ? list.options.length : 0,
-		at: list ? list.value : '',
-		total: (document.querySelector('.ad-page-total')?.textContent ?? '').trim(),
-		// What the chip reads: the rows this page holds, as adminer numbers them.
-		range: (list?.selectedOptions[0]?.textContent ?? '').trim(),
-		// Each mark is an icon file, masked so it takes the row's colour. A path that stopped
-		// resolving would leave the buttons blank and everything else here still passing.
-		drawn: steps.filter((s) => (getComputedStyle(s, '::before').maskImage || '').includes('icons/')).length,
-		chevron: (getComputedStyle(document.querySelector('.ad-page-chip'), '::after').maskImage || '').includes('icons/'),
-		firstRow: (document.querySelector('#table tbody tr td:nth-child(3)')?.textContent ?? '').trim(),
-		page: location.search.match(/[?&]page=(\\d+)/)?.[1] ?? '0',
-		// Only a new document loses this, which is the thing paging is not supposed to do.
-		sameDocument: window.__adSameDocument === true,
-	};
-}";
-
-try {
-	$context = Playwright::chromium(['headless' => true]);
-	$page = $context->newPage();
-	$page->setViewportSize(1400, 800);
-
-	e2e_login($page, $fix['base'], $fix['pgPort']);
-	$page->goto($select);
-	$page->waitForLoadState('networkidle');
-	$page->evaluate("() => { window.__adSameDocument = true; }");
-
-	/** Wait for the rows to change rather than for the url, which the swap corrects a beat
-	 * later. */
-	$wait = static function (array $before) use ($page, $measure): array {
-		$now = $before;
-		for ($i = 0; $i < 40 && $now['firstRow'] === $before['firstRow']; $i++) {
-			usleep(100_000);
-			$now = $page->evaluate($measure);
-		}
-		return $now;
-	};
-
-	$first = $page->evaluate($measure);
-	if ($first['steps'] !== 4) {
-		$failures[] = sprintf('the pager has %d step controls, not first/prev/next/last', $first['steps']);
-		e2e_done($fix['server'], $failures, 'table-pager');
-	}
-	// Fifty rows, five to a page.
-	if ($first['pages'] !== 10) {
-		$failures[] = sprintf('the page list offers %d pages, not 10', $first['pages']);
-	}
-	// Fifty rows in the table, five to a page: the chip counts rows, not pages.
-	if (!str_contains($first['total'], '50')) {
-		$failures[] = "the count beside it reads '{$first['total']}', which is not the 50 rows";
-	}
-	if ($first['range'] !== '1-5') {
-		$failures[] = "the first page reads '{$first['range']}', not the rows 1-5 it holds";
-	}
-	// Every mark is drawn from its own file, and so is the chip's chevron.
-	if ($first['drawn'] !== 4 || !$first['chevron']) {
-		$failures[] = sprintf('%d of 4 marks are drawn from icons/, chevron: %s', $first['drawn'], $first['chevron'] ? 'yes' : 'no');
-	}
-	// On page one there is no first and no previous.
-	if (count($first['ends']) !== 2) {
-		$failures[] = 'on the first page, first and previous still lead somewhere';
-	}
-
-	// The next arrow: rows move, and the document does not.
-	$page->evaluate("() => [...document.querySelectorAll('a.ad-page-step')][0].click()");
-	$next = $wait($first);
-	if ($next['firstRow'] === $first['firstRow']) {
-		$failures[] = 'the next arrow did not move the rows';
-	}
-	if (!$next['sameDocument']) {
-		$failures[] = 'paging rebuilt the page instead of swapping the rows';
-	}
-	if ($next['page'] !== '1' || $next['at'] !== '1') {
-		$failures[] = "after one step the url says page={$next['page']} and the list says {$next['at']}";
-	}
-	if ($next['range'] !== '6-10') {
-		$failures[] = "after one step the chip reads '{$next['range']}', not the rows 6-10";
-	}
-	// And now both ends lead somewhere, since there is a page on either side.
-	if ($next['ends'] !== []) {
-		$failures[] = 'off the first page, an end control still leads nowhere: ' . implode(',', $next['ends']);
-	}
-
-	// The list: jump straight to the last page. By value, not by label — the option labelled 9
-	// is page 8, and a bare string matches the label here.
-	$page->evaluate("() => {
-		const list = document.querySelector('.ad-page-select');
-		list.value = '9';
-		list.dispatchEvent(new Event('change'));
-	}");
-	$last = $wait($next);
-	if ($last['page'] !== '9' || $last['firstRow'] === $next['firstRow']) {
-		$failures[] = "picking page 10 left the url at page={$last['page']}";
-	}
-	if (!$last['sameDocument']) {
-		$failures[] = 'the page list rebuilt the document';
-	}
-
-	$page->screenshot($fix['shots'] . '/table-pager.png');
-	$context->close();
-} catch (\Throwable $e) {
-	$failures[] = 'table-pager: ' . $e->getMessage();
-}
-
-e2e_done($fix['server'], $failures, 'table-pager');
diff --git a/tests/e2e/table-sort.test.php b/tests/e2e/table-sort.test.php
deleted file mode 100644
index d62d3ef..0000000
--- a/tests/e2e/table-sort.test.php
+++ /dev/null
@@ -1,89 +0,0 @@
- ({
-	first: (document.querySelector('#table tbody tr td:nth-child(3)')?.textContent ?? '').trim(),
-	rows: document.querySelectorAll('#table tbody tr').length,
-	highlighted: document.querySelectorAll('#table tbody code span[class^=jush]').length,
-	// Set below and only ever lost by a new document, which is what this is here to catch.
-	sameDocument: window.__adSameDocument === true,
-})";
-
-try {
-	$context = Playwright::chromium(['headless' => true]);
-	$page = $context->newPage();
-	$page->setViewportSize(1600, 900);
-
-	e2e_login($page, $fix['base'], $fix['pgPort']);
-	$page->goto($select);
-	$page->waitForLoadState('networkidle');
-
-	$page->evaluate("() => { window.__adSameDocument = true; }");
-	$before = $page->evaluate($measure);
-
-	// Sort by title — the second column, so its heading link is the second sort link.
-	$page->evaluate("() => document.querySelectorAll('#table thead th a')[3].click()");
-	// Wait for the rows themselves rather than for the url: the swap corrects the url a beat
-	// after it puts them on screen, so waiting on that raced the measurement below.
-	$after = $before;
-	for ($i = 0; $i < 40 && $after['first'] === $before['first']; $i++) {
-		usleep(100_000);
-		$after = $page->evaluate($measure);
-	}
-
-	if (!$after['sameDocument']) {
-		$failures[] = 'sorting rebuilt the page instead of swapping the rows';
-	}
-	if ($after['first'] === $before['first']) {
-		$failures[] = "sorting did not reorder the rows (still '{$after['first']}' first)";
-	}
-	if ($after['rows'] !== $before['rows']) {
-		$failures[] = sprintf('the swap changed the row count (%d, was %d)', $after['rows'], $before['rows']);
-	}
-	// The values that arrived are coloured like the ones they replaced.
-	if ($after['highlighted'] < $before['highlighted']) {
-		$failures[] = sprintf(
-			'the sorted rows lost the syntax highlighting (%d spans, was %d)',
-			$after['highlighted'],
-			$before['highlighted'],
-		);
-	}
-	// And the url says what is on screen, so a reload does not undo it.
-	if (!str_contains($page->url(), 'order')) {
-		$failures[] = 'the url was not corrected to the sorted query: ' . $page->url();
-	}
-	$page->goto($page->url());
-	$page->waitForLoadState('networkidle');
-	$reloaded = $page->evaluate($measure);
-	if ($reloaded['first'] !== $after['first']) {
-		$failures[] = "a reload lost the sort ('{$reloaded['first']}', not '{$after['first']}')";
-	}
-
-	$page->screenshot($fix['shots'] . '/table-sort.png');
-	$context->close();
-} catch (\Throwable $e) {
-	$failures[] = 'table-sort: ' . $e->getMessage();
-}
-
-e2e_done($fix['server'], $failures, 'table-sort');
diff --git a/tests/e2e/theme.test.php b/tests/e2e/theme.test.php
deleted file mode 100644
index 2aeb4a8..0000000
--- a/tests/e2e/theme.test.php
+++ /dev/null
@@ -1,83 +0,0 @@
- true];
-		if ($scheme === 'dark') {
-			$options['context'] = ['colorScheme' => 'dark'];
-		}
-		$context = Playwright::chromium($options);
-		$page = $context->newPage();
-
-		e2e_login($page, $fix['base'], $fix['pgPort']);
-		$page->goto($fix['select']);
-		$page->waitForLoadState('networkidle');
-		$page->screenshot($fix['shots'] . "/users-$scheme.png");
-
-		$title = $page->title();
-		if (!str_contains($title, 'users')) {
-			$failures[] = "$scheme: not logged in (title: $title)";
-		}
-		// The theme's own token is only defined by our stylesheet, so a non-empty value
-		// proves the Adminer Desktop CSS actually loaded and applied — not just that a page
-		// rendered.
-		$accent = $page->evaluate("() => getComputedStyle(document.documentElement).getPropertyValue('--ad-accent').trim()");
-		if (!is_string($accent) || $accent === '') {
-			$failures[] = "$scheme: theme not applied (--ad-accent is empty)";
-		}
-		// Both schemes are one set of light-dark() tokens now, resolved by color-scheme, so
-		// assert a real surface actually resolved to this scheme's side. A non-empty token
-		// alone would pass even if resolution silently fell back to light on every run.
-		$bgIsDark = (bool) $page->evaluate("() => {
-			const el = document.querySelector('#content') || document.body;
-			const [r, g, b] = getComputedStyle(el).backgroundColor.match(/\\d+/g).map(Number);
-			return r + g + b < 200;
-		}");
-		if ($bgIsDark !== ($scheme === 'dark')) {
-			$failures[] = "$scheme: the surface did not resolve to the $scheme scheme";
-		}
-		// And that the scheme itself was emulated — otherwise a dark run silently renders
-		// light and the screenshot is the only tell.
-		$isDark = (bool) $page->evaluate("() => matchMedia('(prefers-color-scheme: dark)').matches");
-		if ($isDark !== ($scheme === 'dark')) {
-			$failures[] = "$scheme: prefers-color-scheme was not emulated";
-		}
-		// The gear sits in the sidebar's scroll flow, by the logo. position: fixed would
-		// leave it hanging over the panel while everything it belongs to scrolls away.
-		$moved = $page->evaluate("() => {
-			const menu = document.querySelector('#menu'), gear = document.querySelector('#desktop-gear');
-			const top = gear.getBoundingClientRect().top;
-			menu.scrollTop = 200;
-			return top - gear.getBoundingClientRect().top;
-		}");
-		if ($moved < 150) {
-			$failures[] = "$scheme: the settings gear did not scroll with the sidebar (moved {$moved}px)";
-		}
-
-		$context->close();
-	}
-} catch (\Throwable $e) {
-	$failures[] = 'theme: ' . $e->getMessage();
-}
-
-e2e_done($fix['server'], $failures, 'theme');