From 94b15df9d7fad7767678a87ce1559d30baaa8ebd Mon Sep 17 00:00:00 2001 From: Michal Landsman Date: Mon, 3 Aug 2026 18:39:42 +0200 Subject: [PATCH 1/3] test: rewrite the browser checks as Behat scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old checks were 2154 lines of PHP that each booted a server, a browser and a fixture of their own, and every assertion was an if() with a hand-written message. What they proved was buried in how they proved it. The same ground is now 28 scenarios in Gherkin, run against one server and one browser with a context per scenario: 59 seconds where the old runner took three minutes, and a failure names the scenario and the step rather than a line number. The steps are named for what the user is doing, so a new scenario is usually written without touching PHP at all. Three things came over from adminer's own suite along the way, each of which was a bug here: the version cookie, which stops Adminer asking adminer.org about a new release while networkidle waits for it; disableOutput() on the server, whose unread stderr pipe fills at 64 kB and takes the app down with it mid-run; and a free-port scan, so a killed run leaves nothing behind that the next one waits on forever. The old *.test.php files stay for now — the MCP ones have not moved yet, and run.php still runs all of them. --- app/composer.json | 12 + app/composer.lock | 1673 ++++++++++++++++++--- tests/e2e/behat.yml | 41 + tests/e2e/bootstrap/DesktopContext.php | 1403 +++++++++++++++++ tests/e2e/features/data/columns.feature | 40 + tests/e2e/features/data/edit-form.feature | 17 + tests/e2e/features/data/page-size.feature | 17 + tests/e2e/features/data/pager.feature | 30 + tests/e2e/features/data/sort.feature | 22 + tests/e2e/features/pgsql/plugins.feature | 34 + tests/e2e/features/shell/import.feature | 22 + tests/e2e/features/shell/settings.feature | 50 + tests/e2e/features/shell/sidebar.feature | 21 + tests/e2e/features/shell/theme.feature | 23 + tests/e2e/harness/fixture.php | 333 ++++ tests/e2e/{seed.sql => seed/pgsql.sql} | 0 16 files changed, 3492 insertions(+), 246 deletions(-) create mode 100644 tests/e2e/behat.yml create mode 100644 tests/e2e/bootstrap/DesktopContext.php create mode 100644 tests/e2e/features/data/columns.feature create mode 100644 tests/e2e/features/data/edit-form.feature create mode 100644 tests/e2e/features/data/page-size.feature create mode 100644 tests/e2e/features/data/pager.feature create mode 100644 tests/e2e/features/data/sort.feature create mode 100644 tests/e2e/features/pgsql/plugins.feature create mode 100644 tests/e2e/features/shell/import.feature create mode 100644 tests/e2e/features/shell/settings.feature create mode 100644 tests/e2e/features/shell/sidebar.feature create mode 100644 tests/e2e/features/shell/theme.feature create mode 100644 tests/e2e/harness/fixture.php rename tests/e2e/{seed.sql => seed/pgsql.sql} (100%) 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/tests/e2e/behat.yml b/tests/e2e/behat.yml new file mode 100644 index 0000000..1359a56 --- /dev/null +++ b/tests/e2e/behat.yml @@ -0,0 +1,41 @@ +# 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 + + 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..9785601 --- /dev/null +++ b/tests/e2e/bootstrap/DesktopContext.php @@ -0,0 +1,1403 @@ +> what e2e_boot() returned, per driver */ + private static array $fixtures = []; + + /** @var array */ + 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() + if (!isset(self::$fixtures[$this->driver])) { + self::$fixtures[$this->driver] = e2e_boot(); + } + $this->fix = self::$fixtures[$this->driver]; + $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/harness/fixture.php b/tests/e2e/harness/fixture.php
new file mode 100644
index 0000000..91b118a
--- /dev/null
+++ b/tests/e2e/harness/fixture.php
@@ -0,0 +1,333 @@
+ [
+			'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');
+}
+
+/** 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/seed.sql b/tests/e2e/seed/pgsql.sql
similarity index 100%
rename from tests/e2e/seed.sql
rename to tests/e2e/seed/pgsql.sql

From a26f144a69cb24cd0509a62dc79a2d9a1bceaa4e Mon Sep 17 00:00:00 2001
From: Michal Landsman 
Date: Mon, 3 Aug 2026 18:46:07 +0200
Subject: [PATCH 2/3] test: move the MCP checks to Behat too
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The last four, and the ones with no browser in them: an agent borrowing this
window's session, the bridge with no app behind it, the url the app records,
and the daily request log.

They read better as scenarios than the rest did, because what they assert is
already a sentence about behaviour — a write is rolled back, a closed window is
reported as one, a notification is answered with silence. The awkward part was
never the Gherkin, it was that "no browser" meant a second context: McpContext
drives curl and the classes directly, and both contexts now share one server
through e2e_fixture() rather than booting one each.
---
 tests/e2e/behat.yml                         |   2 +
 tests/e2e/bootstrap/DesktopContext.php      |   8 +-
 tests/e2e/bootstrap/McpContext.php          | 865 ++++++++++++++++++++
 tests/e2e/features/shell/mcp-bridge.feature |  49 ++
 tests/e2e/features/shell/mcp-log.feature    |  36 +
 tests/e2e/features/shell/mcp-url.feature    |  28 +
 tests/e2e/features/shell/mcp.feature        |  52 ++
 tests/e2e/harness/fixture.php               |  17 +
 8 files changed, 1050 insertions(+), 7 deletions(-)
 create mode 100644 tests/e2e/bootstrap/McpContext.php
 create mode 100644 tests/e2e/features/shell/mcp-bridge.feature
 create mode 100644 tests/e2e/features/shell/mcp-log.feature
 create mode 100644 tests/e2e/features/shell/mcp-url.feature
 create mode 100644 tests/e2e/features/shell/mcp.feature

diff --git a/tests/e2e/behat.yml b/tests/e2e/behat.yml
index 1359a56..3d4da55 100644
--- a/tests/e2e/behat.yml
+++ b/tests/e2e/behat.yml
@@ -28,6 +28,8 @@ default:
       contexts:
         - Desktop\Tests\DesktopContext:
             driver: pgsql
+        - Desktop\Tests\McpContext:
+            driver: pgsql
 
     mysql:
       paths:
diff --git a/tests/e2e/bootstrap/DesktopContext.php b/tests/e2e/bootstrap/DesktopContext.php
index 9785601..f495672 100644
--- a/tests/e2e/bootstrap/DesktopContext.php
+++ b/tests/e2e/bootstrap/DesktopContext.php
@@ -35,9 +35,6 @@
 class DesktopContext implements Context
 {
 
-	/** @var array> what e2e_boot() returned, per driver */
-	private static array $fixtures = [];
-
 	/** @var array */
 	private array $fix;
 
@@ -63,10 +60,7 @@ public function __construct(string $driver)
 	public function open(): void
 	{
 		putenv("ADMINER_DESKTOP_E2E_DRIVER=$this->driver"); // read by e2e_driver()
-		if (!isset(self::$fixtures[$this->driver])) {
-			self::$fixtures[$this->driver] = e2e_boot();
-		}
-		$this->fix = self::$fixtures[$this->driver];
+		$this->fix = e2e_fixture();
 		$this->page = $this->newPage();
 	}
 
diff --git a/tests/e2e/bootstrap/McpContext.php b/tests/e2e/bootstrap/McpContext.php
new file mode 100644
index 0000000..a1ff386
--- /dev/null
+++ b/tests/e2e/bootstrap/McpContext.php
@@ -0,0 +1,865 @@
+ */
+	private array $fix;
+
+	private string $driver;
+
+	/** @var array the session an agent borrows */
+	private array $cookies = [];
+
+	/** Where the handshake says to post, which is not the same as the login page. */
+	private string $mcpUrl = '';
+
+	/** @var array|null the last JSON-RPC answer, decoded */
+	private ?array $answer = null;
+
+	/** @var array|null what the tool put in it, decoded again out of its text */
+	private ?array $payload = null;
+
+	private ?Stdio $bridge = null;
+
+	private ?Handshake $handshake = null;
+
+	/** @var array{url:string, body:string, cookies:array}|null what the bridge posted */
+	private ?array $posted = null;
+
+	private ?string $raw = null;
+
+	private ?RequestLog $log = null;
+
+	private string $logDir = '';
+
+	public function __construct(string $driver)
+	{
+		$this->driver = $driver;
+	}
+
+	#[BeforeScenario]
+	public function open(): void
+	{
+		putenv("ADMINER_DESKTOP_E2E_DRIVER=$this->driver");
+		$this->fix = e2e_fixture();
+	}
+
+	// ── the endpoint, against a real window ──────────────────────────────────────────────────
+
+	#[Given('AI access is on')]
+	public function accessIsOn(): void
+	{
+		$this->writeSettings(['mcp' => true]);
+		@unlink($this->fix['data'] . '/mcp.json');
+	}
+
+	#[Given('AI access is on and allowed to write')]
+	public function accessIsOnWithWrites(): void
+	{
+		$this->writeSettings(['mcp' => true, 'mcp_write' => true]);
+	}
+
+	#[When('AI access is turned off')]
+	public function accessIsTurnedOff(): void
+	{
+		$this->writeSettings(['mcp' => false]);
+		$this->get((string) $this->fix['base']); // the next request is what retracts it
+	}
+
+	/** Log in with curl and keep the cookies — the same borrow the bridge performs. A *connected*
+	 * request afterwards, 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 caught.
+	 */
+	#[Given('a window is logged in to the demo database')]
+	public function windowIsLoggedIn(): void
+	{
+		/** @var array $driver */
+		$driver = $this->fix['driver'];
+		$base = (string) $this->fix['base'];
+		$page = $this->get($base);
+		preg_match("~name='token' value='([^']*)'~", $page, $token);
+
+		$after = $this->post($base, http_build_query([
+			'auth' => [
+				'driver' => (string) $driver['key'],
+				'server' => (string) $this->fix['database'],
+				'username' => (string) $driver['username'],
+				'password' => (string) $driver['password'],
+				'db' => E2E_DATABASE,
+			],
+			'token' => $token[1] ?? '',
+		]), follow: true);
+		if (!str_contains($after, 'Logout') && !str_contains($after, 'logout=1')) {
+			throw new RuntimeException('could not log in with curl, the rest of the scenario would be meaningless');
+		}
+		$this->get(e2e_url($this->fix, ['select' => 'users']));
+		// Where the handshake says to post, if there is one: a bare adminer.php would reach an
+		// adminer with no driver, and every tool call would die on a null.
+		$path = $this->fix['data'] . '/mcp.json';
+		clearstatcache(true, $path);
+		$handshake = is_file($path) ? (array) json_decode((string) file_get_contents($path), true) : [];
+		$this->mcpUrl = is_string($handshake['url'] ?? null) ? $handshake['url'] : $base;
+	}
+
+	#[Then('a handshake is written, pointing at this window')]
+	public function handshakeIsWritten(): void
+	{
+		$handshake = $this->readHandshake();
+		$url = (string) ($handshake['url'] ?? '');
+		$this->mcpUrl = $url !== '' ? $url : (string) $this->fix['base'];
+		if (!str_contains($url, (string) parse_url((string) $this->fix['base'], PHP_URL_PORT))) {
+			throw new RuntimeException('the handshake url does not point at the running app: ' . json_encode($url));
+		}
+		if (!str_contains($url, 'db=' . E2E_DATABASE)) {
+			throw new RuntimeException("the handshake url lost the connection: $url");
+		}
+	}
+
+	#[Then('it carries the session to borrow')]
+	public function handshakeCarriesSession(): void
+	{
+		$handshake = $this->readHandshake();
+		if (!isset($handshake['cookies']['adminer_sid'])) {
+			throw new RuntimeException('the handshake carries no adminer_sid, so nothing could borrow the session');
+		}
+	}
+
+	#[Then('it is readable only by its owner')]
+	public function handshakeIsPrivate(): void
+	{
+		$this->assertPrivate($this->fix['data'] . '/mcp.json');
+	}
+
+	#[Then('the handshake is retracted')]
+	public function handshakeIsRetracted(): void
+	{
+		if (is_file($this->fix['data'] . '/mcp.json')) {
+			throw new RuntimeException('the handshake is still on disk after the setting was turned off');
+		}
+	}
+
+	#[When('an agent initializes')]
+	public function agentInitializes(): void
+	{
+		$this->answer = $this->rpc(['id' => 1, 'method' => 'initialize', 'params' => []]);
+	}
+
+	#[When('a client newer than us initializes')]
+	public function newerClientInitializes(): void
+	{
+		$this->answer = $this->rpc([
+			'id' => 11,
+			'method' => 'initialize',
+			'params' => ['protocolVersion' => '2099-01-01'],
+		]);
+	}
+
+	#[When('a client at :version initializes')]
+	public function olderClientInitializes(string $version): void
+	{
+		$this->answer = $this->rpc([
+			'id' => 12,
+			'method' => 'initialize',
+			'params' => ['protocolVersion' => $version],
+		]);
+	}
+
+	#[Then('it is answered with a protocol version')]
+	public function answeredWithAProtocolVersion(): void
+	{
+		if (($this->answer['result']['protocolVersion'] ?? '') === '') {
+			throw new RuntimeException('initialize returned no protocolVersion: ' . json_encode($this->answer));
+		}
+	}
+
+	/** A client newer than us has to be told what we actually implement rather than agreeably
+	 * echoed, or we promise whatever that revision added.
+	 */
+	#[Then('it is told our version rather than its own')]
+	public function toldOurVersion(): void
+	{
+		$ours = $this->rpc(['id' => 1, 'method' => 'initialize', 'params' => []])['result']['protocolVersion'] ?? '';
+		if (($this->answer['result']['protocolVersion'] ?? '') !== $ours) {
+			throw new RuntimeException('a newer client was echoed its own version: '
+				. json_encode($this->answer['result']['protocolVersion'] ?? null));
+		}
+	}
+
+	#[Then('it is met at :version')]
+	public function metAtVersion(string $version): void
+	{
+		if (($this->answer['result']['protocolVersion'] ?? '') !== $version) {
+			throw new RuntimeException('an older client was not met at its own version: '
+				. json_encode($this->answer['result']['protocolVersion'] ?? null));
+		}
+	}
+
+	#[When('an agent lists the tools')]
+	public function agentListsTools(): void
+	{
+		$this->answer = $this->rpc(['id' => 2, 'method' => 'tools/list', 'params' => []]);
+	}
+
+	#[Then('the tools offered include :names')]
+	public function toolsInclude(string $names): void
+	{
+		$offered = array_column((array) ($this->answer['result']['tools'] ?? []), 'name');
+		foreach (array_map('trim', explode(',', $names)) as $want) {
+			if (!in_array($want, $offered, true)) {
+				throw new RuntimeException("tools/list is missing $want, it offers " . implode(', ', $offered));
+			}
+		}
+	}
+
+	#[When('an agent calls :tool')]
+	public function agentCallsTool(string $tool): void
+	{
+		$this->answer = $this->rpc(['id' => 3, 'method' => 'tools/call', 'params' => ['name' => $tool, 'arguments' => []]]);
+	}
+
+	#[When('an agent runs the query :sql')]
+	public function agentRunsQuery(string $sql): void
+	{
+		$this->answer = $this->rpc([
+			'id' => 4,
+			'method' => 'tools/call',
+			'params' => ['name' => 'execute_query', 'arguments' => ['sql' => $sql]],
+		]);
+		// The tool's payload is JSON inside a JSON string, so it is decoded twice — a naive
+		// contains('"rolled_back"') looks for a quote that is escaped by the time it reaches here.
+		$this->payload = (array) json_decode((string) ($this->answer['result']['content'][0]['text'] ?? ''), true);
+	}
+
+	#[Then('the answer mentions :text')]
+	public function answerMentions(string $text): void
+	{
+		$said = (string) ($this->answer['result']['content'][0]['text'] ?? '');
+		if (!str_contains($said, $text)) {
+			throw new RuntimeException("the answer does not mention $text: " . substr($said, 0, 200));
+		}
+	}
+
+	/** 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.
+	 */
+	#[Then('the answer says it was rolled back')]
+	public function answerSaysRolledBack(): void
+	{
+		if (($this->payload['rolled_back'] ?? null) !== true) {
+			throw new RuntimeException('a write answered without saying it was rolled back: ' . json_encode($this->payload));
+		}
+	}
+
+	#[Then('the answer no longer claims a rollback')]
+	public function answerDoesNotClaimRollback(): void
+	{
+		if (($this->payload['rolled_back'] ?? null) !== false) {
+			throw new RuntimeException('with writes on the answer still claims a rollback: ' . json_encode($this->payload));
+		}
+	}
+
+	/** Prove the statement is well-formed first, or "no row survived" would pass just as happily
+	 * for one that never ran at all. Straight at the database, then taken back out.
+	 */
+	#[Given('the same statement lands when it is run straight at the database')]
+	public function statementLandsDirectly(): void
+	{
+		$probe = 'mcp-probe-direct';
+		$landed = e2e_sql(
+			(array) $this->fix['driver'],
+			"INSERT INTO users (name) VALUES ('$probe'); SELECT count(*) FROM users WHERE name = '$probe'",
+		);
+		$lines = array_values(array_filter(array_map('trim', explode("\n", $landed)), 'strlen'));
+		if (end($lines) !== '1') {
+			throw new RuntimeException("the probe INSERT is not valid SQL, so the rollback assertion proves nothing: $landed");
+		}
+		e2e_sql((array) $this->fix['driver'], "DELETE FROM users WHERE name = '$probe'");
+	}
+
+	#[Then('no row named :marker survived')]
+	public function noRowSurvived(string $marker): void
+	{
+		$left = e2e_sql((array) $this->fix['driver'], "SELECT count(*) FROM users WHERE name LIKE '$marker%'");
+		if ($left !== '0') {
+			throw new RuntimeException("READ-ONLY BROKEN: the write survived, $left row(s) named $marker");
+		}
+	}
+
+	#[Then('the row named :marker is in the database')]
+	public function rowIsInTheDatabase(string $marker): void
+	{
+		$found = e2e_sql((array) $this->fix['driver'], "SELECT count(*) FROM users WHERE name = '$marker'");
+		e2e_sql((array) $this->fix['driver'], "DELETE FROM users WHERE name = '$marker'");
+		if ($found !== '1') {
+			throw new RuntimeException("writes were enabled but the row did not persist (found $found)");
+		}
+	}
+
+	/** The shim as an agent runs it: a bare child process, over its stdin and stdout. */
+	#[When('an agent runs :sql through the stdio bridge')]
+	public function throughTheStdioBridge(string $sql): void
+	{
+		$shim = new Process(
+			[$this->fix['root'] . '/bin/frankenphp', 'php-cli', $this->fix['root'] . '/app/mcp.php'],
+			null,
+			['ADMINER_DESKTOP_DATA' => $this->fix['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' => $sql],
+			]]),
+		]) . "\n");
+		$shim->run();
+		$this->raw = $shim->getOutput() . $shim->getErrorOutput();
+		$this->payload = null;
+		foreach (explode("\n", trim($shim->getOutput())) as $line) {
+			$message = (array) json_decode($line, true);
+			if (($message['id'] ?? null) === 2) {
+				$this->payload = (array) json_decode((string) ($message['result']['content'][0]['text'] ?? ''), true);
+			}
+		}
+	}
+
+	#[Then('the bridge answered the handshake')]
+	public function bridgeAnsweredHandshake(): void
+	{
+		if (!str_contains((string) $this->raw, 'protocolVersion')) {
+			throw new RuntimeException('the stdio bridge did not answer initialize: ' . substr((string) $this->raw, 0, 300));
+		}
+	}
+
+	#[Then('it returned the counted row')]
+	public function bridgeReturnedTheRow(): void
+	{
+		$rows = $this->payload['rows'] ?? null;
+		if (!is_array($rows) || count($rows) !== 1 || !isset($rows[0]['n'])) {
+			throw new RuntimeException('the bridge did not return the counted row: ' . substr((string) $this->raw, 0, 300));
+		}
+	}
+
+	// ── the bridge, with no app behind it ────────────────────────────────────────────────────
+
+	/** An agent spawns the bridge as a bare child process, so if finding the data dir ever depends
+	 * on our own environment again it reports the app as not running while it is open in front of
+	 * you — which is what it did, and the end-to-end scenario cannot see it, because that one sets
+	 * the variable itself.
+	 */
+	#[Then('the bridge finds the data dir with no environment of ours')]
+	public function bridgeFindsDataDir(): void
+	{
+		$was = getenv('ADMINER_DESKTOP_DATA');
+		putenv('ADMINER_DESKTOP_DATA');
+		$found = (new Handshake())->path();
+		if ($was !== false) {
+			putenv("ADMINER_DESKTOP_DATA=$was");
+		}
+		if ($found === null) {
+			throw new RuntimeException('with no ADMINER_DESKTOP_DATA the bridge finds no data dir at all');
+		}
+		if (!str_ends_with($found, 'Adminer Desktop/mcp.json')) {
+			throw new RuntimeException("the fallback data dir is not the launcher's: $found");
+		}
+	}
+
+	#[Given('the app is not running')]
+	public function appIsNotRunning(): void
+	{
+		$this->handshake = new Handshake($this->scratch());
+		$this->handshake->clear();
+		$this->bridge = new Stdio($this->handshake, fn (): bool => false);
+	}
+
+	#[Given('the window was closed')]
+	public function windowWasClosed(): void
+	{
+		$this->handshake = new Handshake($this->scratch());
+		$this->handshake->write('http://127.0.0.1:1/adminer.php?pgsql=x&db=demo&', ['adminer_sid' => 'abc']);
+		$this->bridge = new Stdio($this->handshake, fn (): bool => false);
+	}
+
+	#[Given('the app answers with :what')]
+	public function appAnswersWith(string $what): void
+	{
+		$this->handshake = new Handshake($this->scratch());
+		$this->handshake->write('http://127.0.0.1:1/adminer.php?pgsql=x&db=demo&', ['adminer_sid' => 'abc']);
+		$answers = [
+			'nothing' => '',
+			'a login page' => 'Login',
+			'a json answer' => '{"jsonrpc":"2.0","id":7,"result":{"tools":[]}}',
+		];
+		if (!isset($answers[$what])) {
+			throw new RuntimeException("no such answer to stub: $what");
+		}
+		$this->raw = $answers[$what];
+		$this->bridge = new Stdio($this->handshake, fn (): string => $answers[$what]);
+	}
+
+	#[When('a client initializes through the bridge')]
+	public function initializeThroughBridge(): void
+	{
+		$this->answer = (array) json_decode(
+			(string) $this->bridge?->exchange((string) json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize'])),
+			true,
+		);
+	}
+
+	#[When('a client lists the tools through the bridge')]
+	public function listToolsThroughBridge(): void
+	{
+		$this->raw = $this->bridge?->exchange($this->request());
+		$this->answer = (array) json_decode((string) $this->raw, true);
+	}
+
+	#[When('a client calls a tool through the bridge')]
+	public function callToolThroughBridge(): void
+	{
+		$this->answer = (array) json_decode((string) $this->bridge?->exchange((string) json_encode(
+			['jsonrpc' => '2.0', 'id' => 3, 'method' => 'tools/call', 'params' => ['name' => 'list_tables']],
+		)), true);
+	}
+
+	#[When('a notification arrives through the bridge')]
+	public function notificationThroughBridge(): void
+	{
+		$this->raw = $this->bridge?->exchange((string) json_encode(
+			['jsonrpc' => '2.0', 'method' => 'notifications/initialized'],
+		));
+	}
+
+	/** Failing initialize is reported by clients as a dead server: they print the JSON-RPC code and
+	 * drop the message, so the sentence saying what to do never arrives.
+	 */
+	#[Then('initialize still succeeds')]
+	public function initializeStillSucceeds(): void
+	{
+		if (($this->answer['result']['protocolVersion'] ?? null) === null) {
+			throw new RuntimeException('initialize failed while the app was unavailable: ' . json_encode($this->answer));
+		}
+	}
+
+	/** 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 own tool list.
+	 */
+	#[Then('exactly one tool is advertised, named :name')]
+	public function oneToolAdvertised(string $name): void
+	{
+		$tools = (array) ($this->answer['result']['tools'] ?? []);
+		if (count($tools) !== 1) {
+			throw new RuntimeException(count($tools) . ' tools are advertised while unavailable, not one');
+		}
+		if (($tools[0]['name'] ?? null) !== $name) {
+			throw new RuntimeException('the tool advertised is ' . json_encode($tools[0]['name'] ?? null));
+		}
+	}
+
+	#[Then('it is described with what to do about it')]
+	public function describedWithWhatToDo(): void
+	{
+		$description = (string) ($this->answer['result']['tools'][0]['description'] ?? '');
+		if (!str_contains($description, 'AI access')) {
+			throw new RuntimeException("the description does not say what to do: $description");
+		}
+	}
+
+	/** The explanation belongs where a client renders it: a tool result, not a transport error. */
+	#[Then('the call is a tool error saying :text')]
+	public function callIsToolError(string $text): void
+	{
+		if (($this->answer['result']['isError'] ?? null) !== true) {
+			throw new RuntimeException('a call while unavailable was not a tool error: ' . json_encode($this->answer));
+		}
+		$said = (string) ($this->answer['result']['content'][0]['text'] ?? '');
+		if (!str_contains($said, $text)) {
+			throw new RuntimeException("it does not say $text: $said");
+		}
+	}
+
+	#[Then('the answer says :text')]
+	public function answerSays(string $text): void
+	{
+		$said = (string) ($this->answer['result']['content'][0]['text'] ?? '');
+		if (!str_contains($said, $text)) {
+			throw new RuntimeException("the answer does not say $text: $said");
+		}
+	}
+
+	/** JSON-RPC answers a notification with silence; an error here is a protocol violation strict
+	 * clients complain about.
+	 */
+	#[Then('nothing is sent back')]
+	public function nothingIsSentBack(): void
+	{
+		if ($this->raw !== null) {
+			throw new RuntimeException('something was sent back: ' . json_encode($this->raw));
+		}
+	}
+
+	#[Then('it is forwarded unchanged')]
+	public function forwardedUnchanged(): void
+	{
+		$sent = (string) $this->bridge?->exchange($this->request());
+		if ($sent !== '{"jsonrpc":"2.0","id":7,"result":{"tools":[]}}') {
+			throw new RuntimeException("the bridge reshaped what the server said: $sent");
+		}
+	}
+
+	/** 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.
+	 */
+	#[When('a client asks the bridge for anything')]
+	public function bridgeIsAskedForAnything(): void
+	{
+		$this->handshake = new Handshake($this->scratch());
+		$this->handshake->write('http://127.0.0.1:1/adminer.php?pgsql=x&db=demo&', ['adminer_sid' => 'abc']);
+		$seen = null;
+		$spy = new Stdio($this->handshake, function (string $url, string $body, array $cookies) use (&$seen): string {
+			$seen = ['url' => $url, 'body' => $body, 'cookies' => $cookies];
+			return '{}';
+		});
+		$spy->exchange($this->request());
+		/** @var array{url:string, body:string, cookies:array}|null $seen */
+		$this->posted = $seen;
+	}
+
+	#[Then('it posts to the connected url with mcp=1, replaying the session cookie')]
+	public function postsToConnectedUrl(): void
+	{
+		$posted = (array) $this->posted;
+		foreach (['mcp=1', 'db=demo'] as $needle) {
+			if (!str_contains((string) ($posted['url'] ?? ''), $needle)) {
+				throw new RuntimeException("the bridge posted to " . json_encode($posted['url'] ?? null) . ", with no $needle");
+			}
+		}
+		if (($posted['body'] ?? null) !== $this->request()) {
+			throw new RuntimeException('the bridge changed the message on the way through');
+		}
+		if ((((array) ($posted['cookies'] ?? []))['adminer_sid'] ?? null) !== 'abc') {
+			throw new RuntimeException('the bridge did not replay the session cookie');
+		}
+	}
+
+	/** run() pumps a stream: blank lines skipped, one answer per request, silence stays silent. A
+	 * stub that answered every message alike would prove nothing about the silence, so this one
+	 * answers like the app — a notification with an empty body.
+	 */
+	#[Then('the bridge answers each request once and every notification never')]
+	public function bridgePumpsTheStream(): void
+	{
+		$handshake = new Handshake($this->scratch());
+		$handshake->write('http://127.0.0.1:1/adminer.php?pgsql=x&db=demo&', ['adminer_sid' => 'abc']);
+		$answer = '{"jsonrpc":"2.0","id":7,"result":{"tools":[]}}';
+		$in = fopen('php://memory', 'r+');
+		fwrite($in, $this->request() . "\n\n"
+			. (string) json_encode(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']) . "\n"
+			. $this->request() . "\n");
+		rewind($in);
+		$out = fopen('php://memory', 'r+');
+		(new Stdio($handshake, fn (string $url, string $body): string
+			=> isset(((array) json_decode($body, true))['id']) ? $answer : ''))->run($in, $out);
+		rewind($out);
+		$written = (string) stream_get_contents($out);
+		if (substr_count($written, '"result"') !== 2 || substr_count($written, "\n") !== 2) {
+			throw new RuntimeException("the stream was answered as: $written");
+		}
+	}
+
+	// ── the recorded url ─────────────────────────────────────────────────────────────────────
+
+	/** @var array the server variables a scenario is asking about */
+	private array $server = [];
+
+	private string $me = 'adminer.php?pgsql=127.0.0.1%3A55432&username=postgres&db=demo&';
+
+	#[When('the app records where it is, served from :host at :script')]
+	public function recordUrl(string $host, string $script): void
+	{
+		$this->server = $host === 'nowhere' ? ['SCRIPT_NAME' => $script] : ['HTTP_HOST' => $host, 'SCRIPT_NAME' => $script];
+	}
+
+	#[Then('the url recorded while connected is :expected')]
+	public function urlRecordedIs(string $expected): void
+	{
+		$url = (new \Desktop\Mcp\Endpoint(new UserSettings(null)))->url($this->server, true, $this->me);
+		$want = str_replace('', $this->me, $expected);
+		if ($url !== $want) {
+			throw new RuntimeException('the recorded url is ' . json_encode($url));
+		}
+	}
+
+	/** Not connected is not logged in: there is nothing worth borrowing, so nothing is recorded. */
+	#[Then('nothing is recorded while disconnected')]
+	public function nothingRecordedDisconnected(): void
+	{
+		$url = (new \Desktop\Mcp\Endpoint(new UserSettings(null)))->url($this->server, false, $this->me);
+		if ($url !== null) {
+			throw new RuntimeException('something was recorded while disconnected: ' . json_encode($url));
+		}
+	}
+
+	#[Then('nothing is recorded at all')]
+	public function nothingRecorded(): void
+	{
+		$url = (new \Desktop\Mcp\Endpoint(new UserSettings(null)))->url($this->server, true, $this->me);
+		if ($url !== null) {
+			throw new RuntimeException('something was recorded with nothing to build it from: ' . json_encode($url));
+		}
+	}
+
+	/** Falling through would reach the server and crash on a null driver, and answering HTML would
+	 * leave the agent parsing a login page.
+	 */
+	#[Then('a request on a stale handshake is told to log in again')]
+	public function staleHandshakeIsAnswered(): void
+	{
+		$answer = (new \Desktop\Mcp\Endpoint(new UserSettings(null)))
+			->answer('{"jsonrpc":"2.0","id":1,"method":"tools/list"}', false);
+		$decoded = (array) json_decode((string) $answer, true);
+		if (($decoded['error']['code'] ?? null) !== -32000) {
+			throw new RuntimeException('a stale handshake was not answered with a json-rpc error: ' . json_encode($answer));
+		}
+		if (!str_contains((string) ($decoded['error']['message'] ?? ''), 'Log in again')) {
+			throw new RuntimeException('the message does not say what to do: ' . json_encode($answer));
+		}
+	}
+
+	// ── the request log ──────────────────────────────────────────────────────────────────────
+
+	/** Rotation and retention only misbehave on a date boundary, which is not something a run can
+	 * wait for — so the day is an argument and the boundary is just a number.
+	 */
+	#[Given('a log directory of its own')]
+	public function aLogDirectory(): void
+	{
+		$this->logDir = $this->scratch();
+		$this->log = new RequestLog($this->logDir);
+	}
+
+	#[When('a request is logged on day :day')]
+	public function requestIsLogged(string $day): void
+	{
+		$this->log?->append('tools/call', 'execute_query', "SELECT *\n  FROM users", $this->day($day));
+	}
+
+	#[When('a second request is logged on day :day')]
+	public function secondRequestIsLogged(string $day): void
+	{
+		$this->log?->append('tools/list', '', '', $this->day($day));
+	}
+
+	#[Then('day :day has its own file, named for the date')]
+	public function dayHasItsOwnFile(string $day): void
+	{
+		$path = (string) $this->log?->path($this->day($day));
+		$want = 'mcp-' . gmdate('Y-m-d', $this->day($day)) . '.log';
+		if (basename($path) !== $want) {
+			throw new RuntimeException("the file is named " . basename($path) . ", not $want");
+		}
+	}
+
+	#[Then('day :day holds :count lines')]
+	public function dayHoldsLines(string $day, int $count): void
+	{
+		$path = (string) $this->log?->path($this->day($day));
+		$lines = is_file($path) ? (array) file($path, FILE_IGNORE_NEW_LINES) : [];
+		if (count($lines) !== $count) {
+			throw new RuntimeException('day ' . $day . ' holds ' . count($lines) . " lines, not $count");
+		}
+	}
+
+	/** Newlines in SQL must not become extra lines, or one call reads as several. */
+	#[Then('the line names the method, the tool and the query on one line')]
+	public function lineIsTabSeparated(): void
+	{
+		$path = (string) $this->log?->path($this->day('1'));
+		$lines = (array) file($path, FILE_IGNORE_NEW_LINES);
+		$fields = explode("\t", (string) ($lines[0] ?? ''));
+		if (count($fields) !== 4) {
+			throw new RuntimeException('the line has ' . count($fields) . ' fields, not four: ' . json_encode($lines[0] ?? null));
+		}
+		if ($fields[1] !== 'tools/call' || $fields[2] !== 'execute_query' || $fields[3] !== 'SELECT * FROM users') {
+			throw new RuntimeException('the line reads ' . json_encode($fields));
+		}
+	}
+
+	#[Given('a file from :days days before day :day')]
+	public function anOldFile(int $days, string $day): void
+	{
+		$path = $this->logDir . '/mcp-' . gmdate('Y-m-d', $this->day($day) - ($days * 86400)) . '.log';
+		file_put_contents($path, "old\n");
+	}
+
+	#[Then('files older than the window are gone and recent ones are kept')]
+	public function oldFilesArePruned(): void
+	{
+		$old = $this->logDir . '/mcp-' . gmdate('Y-m-d', $this->day('2') - (20 * 86400)) . '.log';
+		$recent = $this->logDir . '/mcp-' . gmdate('Y-m-d', $this->day('2') - (5 * 86400)) . '.log';
+		if (is_file($old)) {
+			throw new RuntimeException('a file beyond the window survived');
+		}
+		if (!is_file($recent)) {
+			throw new RuntimeException('a file inside the window was pruned');
+		}
+	}
+
+	#[Then('the log is readable only by its owner')]
+	public function logIsPrivate(): void
+	{
+		$this->assertPrivate((string) $this->log?->path($this->day('2')));
+	}
+
+	/** Served without the launcher there is no directory, and that is silence rather than a crash. */
+	#[Then('a log with nowhere to write is silent rather than broken')]
+	public function logWithNoDirectory(): void
+	{
+		$none = new RequestLog(null);
+		if ($none->path($this->day('1')) !== null) {
+			throw new RuntimeException('a log with no directory offered a path anyway');
+		}
+		$none->append('ping', '', '', $this->day('1')); // must not throw
+	}
+
+	// ── the plumbing ─────────────────────────────────────────────────────────────────────────
+
+	/** @param array $settings */
+	private function writeSettings(array $settings): void
+	{
+		file_put_contents($this->fix['data'] . '/settings.json', (string) json_encode($settings));
+	}
+
+	/** @return array */
+	private function readHandshake(): array
+	{
+		$path = $this->fix['data'] . '/mcp.json';
+		clearstatcache(true, $path);
+		if (!is_file($path)) {
+			throw new RuntimeException('no handshake was written after a connected request');
+		}
+		return (array) json_decode((string) file_get_contents($path), true);
+	}
+
+	/** It holds session cookies, and a log names tables and queries: neither may be world-readable. */
+	private function assertPrivate(string $path): void
+	{
+		$perms = substr(sprintf('%o', (int) fileperms($path)), -3);
+		if ($perms !== '600') {
+			throw new RuntimeException(basename($path) . " is $perms, expected 600");
+		}
+	}
+
+	/** One JSON-RPC message straight at the app, with the session's cookies.
+	 * @param array $message
+	 * @return array
+	 */
+	private function rpc(array $message): array
+	{
+		$url = $this->mcpUrl . (str_contains($this->mcpUrl, '?') ? '&' : '?') . 'mcp=1';
+		return (array) json_decode($this->post($url, (string) json_encode(['jsonrpc' => '2.0'] + $message), json: true), true);
+	}
+
+	private function request(): string
+	{
+		return (string) json_encode(['jsonrpc' => '2.0', 'id' => 7, 'method' => 'tools/list']);
+	}
+
+	private function get(string $url): string
+	{
+		return $this->curl($url, null, false, false);
+	}
+
+	private function post(string $url, string $body, bool $follow = false, bool $json = false): string
+	{
+		return $this->curl($url, $body, $follow, $json);
+	}
+
+	private function curl(string $url, ?string $body, bool $follow, bool $json): string
+	{
+		$ch = curl_init($url);
+		$options = [
+			CURLOPT_RETURNTRANSFER => true,
+			CURLOPT_COOKIEFILE => '',
+			CURLOPT_FOLLOWLOCATION => $follow,
+			CURLOPT_COOKIE => implode('; ', array_map(
+				fn (string $k, string $v): string => "$k=$v",
+				array_keys($this->cookies),
+				$this->cookies,
+			)),
+		];
+		if ($body !== null) {
+			$options[CURLOPT_POST] = true;
+			$options[CURLOPT_POSTFIELDS] = $body;
+		}
+		if ($json) {
+			$options[CURLOPT_HTTPHEADER] = ['Content-Type: application/json'];
+		}
+		curl_setopt_array($ch, $options);
+		$answer = (string) curl_exec($ch);
+		foreach ((array) curl_getinfo($ch, CURLINFO_COOKIELIST) as $line) {
+			$fields = explode("\t", (string) $line);
+			if (count($fields) >= 7) {
+				$this->cookies[$fields[5]] = $fields[6];
+			}
+		}
+		return $answer;
+	}
+
+	/** A directory of this scenario's own, cleaned up when the process ends.
+	 *
+	 * Counted, not merely named after the process: the log scenarios each write a file per day into
+	 * it, and a directory shared with the scenario before reads as yesterday's lines surviving into
+	 * today — which is one of the things they are here to assert.
+	 */
+	private function scratch(): string
+	{
+		static $nth = 0;
+		$dir = sys_get_temp_dir() . '/adminer-desktop-mcp-' . getmypid() . '-' . ++$nth;
+		@mkdir($dir, 0700, true);
+		register_shutdown_function(static function () use ($dir): void {
+			try {
+				array_map('unlink', (array) glob("$dir/*"));
+				@rmdir($dir);
+			} catch (Throwable $e) {
+				// a leftover temp directory is not worth failing a run over
+			}
+		});
+		return $dir;
+	}
+
+	/** The two days the log scenarios talk about, as timestamps. */
+	private function day(string $day): int
+	{
+		$first = mktime(12, 0, 0, 8, 3, 2026);
+		return (int) $first + ((int) $day - 1) * 86400;
+	}
+}
diff --git a/tests/e2e/features/shell/mcp-bridge.feature b/tests/e2e/features/shell/mcp-bridge.feature
new file mode 100644
index 0000000..2286b0d
--- /dev/null
+++ b/tests/e2e/features/shell/mcp-bridge.feature
@@ -0,0 +1,49 @@
+Feature: The stdio bridge when there is no app behind it
+  What the end-to-end scenarios cannot provoke on demand — the app not running, the window closed
+  mid-session, an expired login. Each is a message an agent has to be able to act on, and each is
+  one line away from being silence or a page of HTML instead. No app and no database here: the
+  transport is a closure and the handshake is a temporary directory.
+
+  Scenario: It finds the data dir with no environment of ours
+    Then the bridge finds the data dir with no environment of ours
+
+  Scenario: With no handshake the connection still comes up, carrying the reason
+    Given the app is not running
+    When a client initializes through the bridge
+    Then initialize still succeeds
+    When a client lists the tools through the bridge
+    Then exactly one tool is advertised, named "need_attention_read_me"
+    And it is described with what to do about it
+    When a client calls a tool through the bridge
+    Then the call is a tool error saying "AI access"
+
+  Scenario: A notification with no handshake is answered with silence
+    Given the app is not running
+    When a notification arrives through the bridge
+    Then nothing is sent back
+
+  Scenario: A closed window is reported as one
+    Given the window was closed
+    When a client calls a tool through the bridge
+    Then the answer says "stopped answering"
+
+  Scenario: An empty answer forwards nothing
+    Given the app answers with "nothing"
+    When a notification arrives through the bridge
+    Then nothing is sent back
+
+  Scenario: HTML back means the session expired, and the agent is told so
+    Given the app answers with "a login page"
+    When a client calls a tool through the bridge
+    Then the answer says "expired"
+
+  Scenario: A JSON answer is forwarded exactly as the server said it
+    Given the app answers with "a json answer"
+    Then it is forwarded unchanged
+
+  Scenario: It posts where the handshake says, with the session it was given
+    When a client asks the bridge for anything
+    Then it posts to the connected url with mcp=1, replaying the session cookie
+
+  Scenario: It pumps a stream, blank lines and all
+    Then the bridge answers each request once and every notification never
diff --git a/tests/e2e/features/shell/mcp-log.feature b/tests/e2e/features/shell/mcp-log.feature
new file mode 100644
index 0000000..7b94ae7
--- /dev/null
+++ b/tests/e2e/features/shell/mcp-log.feature
@@ -0,0 +1,36 @@
+Feature: The daily record of what an agent asked for
+  Under -debug the app writes one line per request an agent makes, so "what did it actually do" has
+  an answer. Rotation and retention only misbehave on a date boundary, which is not something a run
+  can wait for — so the day is an argument here and the boundary is just a number.
+
+  Background:
+    Given a log directory of its own
+
+  Scenario: A request lands as one line naming the method, the tool and the query
+    When a request is logged on day "1"
+    Then day "1" has its own file, named for the date
+    And day "1" holds 1 lines
+    And the line names the method, the tool and the query on one line
+
+  Scenario: Writing appends rather than overwrites
+    When a request is logged on day "1"
+    And a second request is logged on day "1"
+    Then day "1" holds 2 lines
+
+  Scenario: Crossing midnight opens a new file and leaves yesterday alone
+    When a request is logged on day "1"
+    And a second request is logged on day "1"
+    And a request is logged on day "2"
+    Then day "1" holds 2 lines
+    And day "2" holds 1 lines
+    And day "2" has its own file, named for the date
+
+  Scenario: Old days are pruned on write, recent ones are not
+    Given a file from 20 days before day "2"
+    And a file from 5 days before day "2"
+    When a request is logged on day "2"
+    Then files older than the window are gone and recent ones are kept
+    And the log is readable only by its owner
+
+  Scenario: Served without the launcher there is nowhere to write, and that is not a crash
+    Then a log with nowhere to write is silent rather than broken
diff --git a/tests/e2e/features/shell/mcp-url.feature b/tests/e2e/features/shell/mcp-url.feature
new file mode 100644
index 0000000..1c038de
--- /dev/null
+++ b/tests/e2e/features/shell/mcp-url.feature
@@ -0,0 +1,28 @@
+Feature: The url the app records for an agent to come back to
+  Recording adminer.php instead of the connected URL cost a round of debugging: Adminer takes the
+  server and the database from the query string rather than from the session, so the agent's
+  request reached an adminer with no driver at all and every tool call died on a null. That is a
+  string-building mistake, and it deserves a scenario that needs no database to catch it.
+
+  Scenario: The connection survives into the recorded url
+    When the app records where it is, served from "127.0.0.1:18080" at "/adminer.php"
+    Then the url recorded while connected is "http://127.0.0.1:18080/"
+
+  Scenario: Nothing is recorded when there is nothing worth borrowing
+    When the app records where it is, served from "127.0.0.1:18080" at "/adminer.php"
+    Then nothing is recorded while disconnected
+
+  Scenario: Nothing is recorded without a host to build an absolute url from
+    When the app records where it is, served from "nowhere" at "/adminer.php"
+    Then nothing is recorded at all
+
+  Scenario: A subdirectory is kept, and not doubled
+    When the app records where it is, served from "h" at "/tools/adminer.php"
+    Then the url recorded while connected is "http://h/tools/"
+
+  Scenario: Windows hands back backslashes, which are not url separators
+    When the app records where it is, served from "h" at "\tools\adminer.php"
+    Then the url recorded while connected is "http://h/tools/"
+
+  Scenario: A request on a stale handshake is answered in JSON, saying what to do
+    Then a request on a stale handshake is told to log in again
diff --git a/tests/e2e/features/shell/mcp.feature b/tests/e2e/features/shell/mcp.feature
new file mode 100644
index 0000000..bc9b1fc
--- /dev/null
+++ b/tests/e2e/features/shell/mcp.feature
@@ -0,0 +1,52 @@
+Feature: An agent reaching the database this window is logged into
+  There is no browser in any of this: what is under test is an HTTP session being borrowed by a
+  second process, so the scenarios log in with curl and drive the endpoint exactly as an agent
+  would.
+
+  The one that earns its keep is the rollback. execute_query runs inside a transaction that is
+  always rolled back, and "always" is a claim about a security boundary: if it ever stops being
+  true, an agent that was told it had read-only access can quietly write.
+
+  Background:
+    Given AI access is on
+    And a window is logged in to the demo database
+
+  Scenario: The handshake points at this window and carries the session to borrow
+    Then a handshake is written, pointing at this window
+    And it carries the session to borrow
+    And it is readable only by its owner
+
+  Scenario: The protocol version is negotiated in both directions
+    When an agent initializes
+    Then it is answered with a protocol version
+    When a client newer than us initializes
+    Then it is told our version rather than its own
+    When a client at "2024-11-05" initializes
+    Then it is met at "2024-11-05"
+
+  Scenario: The tools are offered, and they reach the seeded database
+    When an agent lists the tools
+    Then the tools offered include "current_connection, list_tables, describe_table, preview_table_data, execute_query"
+    When an agent calls "list_tables"
+    Then the answer mentions "users"
+
+  Scenario: A write through the tool leaves nothing behind
+    Given the same statement lands when it is run straight at the database
+    When an agent runs the query "INSERT INTO users (name) VALUES ('mcp-rollback-probe') RETURNING id"
+    Then the answer says it was rolled back
+    And no row named "mcp-rollback-probe" survived
+
+  Scenario: With writes allowed the same statement persists, and says so
+    Given AI access is on and allowed to write
+    When an agent runs the query "INSERT INTO users (name) VALUES ('mcp-committed') RETURNING id"
+    Then the answer no longer claims a rollback
+    And the row named "mcp-committed" is in the database
+
+  Scenario: The bridge carries the same call over stdin and stdout
+    When an agent runs "SELECT count(*) AS n FROM users" through the stdio bridge
+    Then the bridge answered the handshake
+    And it returned the counted row
+
+  Scenario: Turning the feature off retracts the handshake rather than just ignoring it
+    When AI access is turned off
+    Then the handshake is retracted
diff --git a/tests/e2e/harness/fixture.php b/tests/e2e/harness/fixture.php
index 91b118a..109838a 100644
--- a/tests/e2e/harness/fixture.php
+++ b/tests/e2e/harness/fixture.php
@@ -198,6 +198,23 @@ function e2e_boot(): array
 	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
 {

From ea89bd64a2dc440466e9e6991beb18c84c2ecd55 Mon Sep 17 00:00:00 2001
From: Michal Landsman 
Date: Mon, 3 Aug 2026 19:12:09 +0200
Subject: [PATCH 3/3] wip

---
 .github/workflows/e2e.yml               |  16 +-
 .gitignore                              |   4 +-
 Makefile                                |  43 ++--
 mise.toml                               |  13 +-
 tests/e2e/drag-drop-import.test.php     | 132 -----------
 tests/e2e/edit-field-width.test.php     | 129 -----------
 tests/e2e/fixture.php                   | 135 -----------
 tests/e2e/mcp-bridge.test.php           | 147 ------------
 tests/e2e/mcp-endpoint.test.php         |  82 -------
 tests/e2e/mcp-log.test.php              |  83 -------
 tests/e2e/mcp.test.php                  | 292 ------------------------
 tests/e2e/page-size.test.php            |  75 ------
 tests/e2e/plugins/edit-foreign.test.php |  74 ------
 tests/e2e/plugins/json-column.test.php  | 138 -----------
 tests/e2e/run.php                       |  44 ----
 tests/e2e/seed/mysql.sql                | 168 ++++++++++++++
 tests/e2e/settings.test.php             | 199 ----------------
 tests/e2e/sidebar-resize.test.php       | 116 ----------
 tests/e2e/table-columns.test.php        | 202 ----------------
 tests/e2e/table-pager.test.php          | 134 -----------
 tests/e2e/table-sort.test.php           |  89 --------
 tests/e2e/theme.test.php                |  83 -------
 22 files changed, 215 insertions(+), 2183 deletions(-)
 delete mode 100644 tests/e2e/drag-drop-import.test.php
 delete mode 100644 tests/e2e/edit-field-width.test.php
 delete mode 100644 tests/e2e/fixture.php
 delete mode 100644 tests/e2e/mcp-bridge.test.php
 delete mode 100644 tests/e2e/mcp-endpoint.test.php
 delete mode 100644 tests/e2e/mcp-log.test.php
 delete mode 100644 tests/e2e/mcp.test.php
 delete mode 100644 tests/e2e/page-size.test.php
 delete mode 100644 tests/e2e/plugins/edit-foreign.test.php
 delete mode 100644 tests/e2e/plugins/json-column.test.php
 delete mode 100644 tests/e2e/run.php
 create mode 100644 tests/e2e/seed/mysql.sql
 delete mode 100644 tests/e2e/settings.test.php
 delete mode 100644 tests/e2e/sidebar-resize.test.php
 delete mode 100644 tests/e2e/table-columns.test.php
 delete mode 100644 tests/e2e/table-pager.test.php
 delete mode 100644 tests/e2e/table-sort.test.php
 delete mode 100644 tests/e2e/theme.test.php

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/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/drag-drop-import.test.php b/tests/e2e/drag-drop-import.test.php
deleted file mode 100644
index f882346..0000000
--- a/tests/e2e/drag-drop-import.test.php
+++ /dev/null
@@ -1,132 +0,0 @@
- "127.0.0.1:{$fix['pgPort']}",
-	'username' => 'postgres',
-	'db' => 'demo',
-	'ns' => 'public',
-	'import' => '',
-]);
-
-try {
-	$context = Playwright::chromium(['headless' => true]);
-	$page = $context->newPage();
-
-	e2e_login($page, $fix['base'], $fix['pgPort']);
-
-	// Scope: on a non-import page the script must do nothing, so the overlay is never created.
-	$page->goto($fix['select']);
-	$page->waitForLoadState('networkidle');
-	$offImport = $page->evaluate("() => document.getElementById('ad-import-drop') === null");
-	if ($offImport !== true) {
-		$failures[] = 'the dropzone overlay leaked onto a page that is not the import page';
-	}
-
-	$page->goto($import);
-	$page->waitForLoadState('networkidle');
-
-	// The page the whole feature hangs off: Adminer's own upload input, and our overlay wired
-	// up beside it, hidden until a drag begins.
-	$ready = $page->evaluate("() => {
-		const input = document.querySelector('input[type=\"file\"][name=\"sql_file[]\"]');
-		const overlay = document.getElementById('ad-import-drop');
-		const meta = document.querySelector('meta[name=\"ad-import-drop\"]');
-		return {
-			input: !!input,
-			overlay: !!overlay,
-			hidden: overlay ? getComputedStyle(overlay).visibility === 'hidden' : null,
-			label: overlay ? overlay.textContent : null,
-			metaLabel: meta ? meta.content : null,
-		};
-	}");
-	if (!is_array($ready) || !$ready['input']) {
-		$failures[] = 'Adminer\'s sql_file[] upload input was not found on the import page';
-	}
-	if (!is_array($ready) || !$ready['overlay']) {
-		$failures[] = 'the dropzone overlay was not created on the import page';
-	} elseif ($ready['hidden'] !== true) {
-		$failures[] = 'the dropzone overlay was visible before any drag';
-	}
-	// The label is rendered server-side and localized: it must come from the meta AdminerDesktop
-	// emits on the import page, not from a hardcoded string in the script.
-	if (!is_array($ready) || ($ready['metaLabel'] ?? '') === '' || $ready['label'] !== $ready['metaLabel']) {
-		$failures[] = 'the dropzone label did not come from the server meta (label: '
-			. json_encode($ready['label'] ?? null) . ', meta: ' . json_encode($ready['metaLabel'] ?? null) . ')';
-	}
-
-	// Drag a file over the window: dragenter + dragover must raise the affordance. The same
-	// DataTransfer is kept on window so the drop below is the tail of one continuous drag.
-	$hovering = $page->evaluate("() => {
-		const dt = new DataTransfer();
-		dt.items.add(new File(['SELECT 1;\\n'], 'dropped-dump.sql', { type: 'application/sql' }));
-		window.__adDrag = dt;
-		const fire = (t) =>
-			window.dispatchEvent(new DragEvent(t, { bubbles: true, cancelable: true, dataTransfer: dt }));
-		fire('dragenter');
-		fire('dragover');
-		const o = document.getElementById('ad-import-drop');
-		return o.classList.contains('dragging') && getComputedStyle(o).visibility === 'visible';
-	}");
-	if ($hovering !== true) {
-		$failures[] = 'dragging a file over the page did not raise the drop affordance';
-	}
-	// Proof of the affordance for a human to glance at, like the other checks leave behind.
-	// Let the 0.12s fade-in finish first, so the shot shows the overlay at full opacity.
-	usleep(200_000);
-	$page->screenshot($fix['shots'] . '/import-dropzone.png');
-
-	// Drop it: the file must land on Adminer's input, the overlay must fall away, and the
-	// browser's own "navigate to the file" default must be cancelled (dispatchEvent returns
-	// false when a handler preventDefaults a cancelable event).
-	$drop = $page->evaluate("() => {
-		const dt = window.__adDrag;
-		const notPrevented = window.dispatchEvent(
-			new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt }),
-		);
-		const input = document.querySelector('input[name=\"sql_file[]\"]');
-		const o = document.getElementById('ad-import-drop');
-		return {
-			count: input.files.length,
-			name: input.files.length ? input.files[0].name : null,
-			prevented: !notPrevented,
-			hidden: !o.classList.contains('dragging'),
-		};
-	}");
-	if (!is_array($drop) || $drop['count'] !== 1 || $drop['name'] !== 'dropped-dump.sql') {
-		$failures[] = 'the dropped file did not land on Adminer\'s sql_file[] input';
-	}
-	if (!is_array($drop) || $drop['prevented'] !== true) {
-		$failures[] = 'the drop did not cancel the browser\'s navigate-to-file default';
-	}
-	if (!is_array($drop) || $drop['hidden'] !== true) {
-		$failures[] = 'the overlay stayed up after the drop';
-	}
-
-	$context->close();
-} catch (\Throwable $e) {
-	$failures[] = 'drag-drop-import: ' . $e->getMessage();
-}
-
-e2e_done($fix['server'], $failures, 'drag-drop-import');
diff --git a/tests/e2e/edit-field-width.test.php b/tests/e2e/edit-field-width.test.php
deleted file mode 100644
index 0d99b27..0000000
--- a/tests/e2e/edit-field-width.test.php
+++ /dev/null
@@ -1,129 +0,0 @@
- included, which carries an inline width no stylesheet can reach — that it is
- * persisted to the durable config, and that a fresh load opens at it before any script runs,
- * the cold-start path head() drives.
- *
- * Run via `make e2e` (tests/e2e/run.php runs it), or on its own with
- * ./bin/frankenphp php-cli tests/e2e/edit-field-width.test.php.
- */
-
-require __DIR__ . '/fixture.php';
-
-use Playwright\Playwright;
-
-$fix = e2e_boot();
-$failures = [];
-
-// The fixture's data dir, where Desktop\UserSettings writes settings.json. Start clean so a
-// width left by an earlier run cannot push this drag into the clamp and mask it.
-$settings = $fix['data'] . '/settings.json';
-@unlink($settings);
-
-/** Poll for the beacon to land: sendBeacon is fire-and-forget, so the file appears a beat
- * after mouseup with no response to await. Returns the stored width, or null if it never
- * showed. */
-$storedWidth = static function () use ($settings): ?int {
-	for ($i = 0; $i < 30; $i++) {
-		if (is_file($settings)) {
-			$data = json_decode((string) file_get_contents($settings), true);
-			if (is_array($data) && isset($data['user_resized_px']['edit_field'])) {
-				return (int) $data['user_resized_px']['edit_field'];
-			}
-		}
-		usleep(100_000);
-	}
-	return null;
-};
-
-// One seeded row of documents: a text column and a jsonb one, so both kinds of field are on
-// the form — the textarea and the 
 JUSH swaps in for it.
-$edit = $fix['base'] . '?' . http_build_query([
-	'pgsql' => '127.0.0.1:' . $fix['pgPort'],
-	'username' => 'postgres',
-	'db' => 'demo',
-	'ns' => 'public',
-	'edit' => 'documents',
-	'where[id]' => '1',
-]);
-
-/** The width of every field the user can see, and the grip of the first — its bottom-right
- * corner, where the browser puts the resize handle. The hidden textarea behind a JUSH 
- * is skipped; it has no width to speak of. */
-$script = /** @lang JavaScript */ "() => {
-	const all = [...document.querySelectorAll('#form > table.layout textarea, #form > table.layout pre.jush')]
-		.filter((el) => el.offsetWidth > 0);
-	const r = all[0].getBoundingClientRect();
-	return { widths: all.map((el) => el.getBoundingClientRect().width), grip: { x: r.right - 3, y: r.bottom - 3 } };
-}";
-
-try {
-	$context = Playwright::chromium(['headless' => true]);
-	$page = $context->newPage();
-	// Wide enough that the drag below stays clear of the max-width the theme caps fields at.
-	$page->setViewportSize(1600, 900);
-
-	e2e_login($page, $fix['base'], $fix['pgPort']);
-	$page->goto($edit);
-	// JUSH swaps the json column's textarea for its 
 once the page has settled, so the
-	// first measurement has to wait for that, not just for the HTML.
-	$page->waitForLoadState('networkidle');
-
-	$before = $page->evaluate($script);
-	if (count($before['widths']) < 2) {
-		$failures[] = 'the edit form has fewer than two visible fields to resize';
-		e2e_done($fix['server'], $failures, 'edit-field-width');
-	}
-
-	// Drag the grip 150px to the right — the field's own native resize, which is the handle.
-	$mouse = $page->mouse();
-	$mouse->move($before['grip']['x'], $before['grip']['y']);
-	$mouse->down();
-	$mouse->move($before['grip']['x'] + 150, $before['grip']['y'], ['steps' => 10]);
-	$mouse->up();
-
-	$after = $page->evaluate($script);
-	if ($after['widths'][0] - $before['widths'][0] < 120) {
-		$failures[] = sprintf('the drag did not widen the field (%.0f -> %.0f)', $before['widths'][0], $after['widths'][0]);
-	}
-	// The point of the property: the fields nobody touched moved with it.
-	// A few px of slack: JUSH's 
 carries its own border and padding outside the width.
-	foreach ($after['widths'] as $i => $width) {
-		if (abs($width - $after['widths'][0]) > 12) {
-			$failures[] = sprintf('field %d stayed at %.0f, not the dragged %.0f', $i, $width, $after['widths'][0]);
-		}
-	}
-
-	$stored = $storedWidth();
-	if ($stored === null) {
-		$failures[] = 'the width was not persisted to settings.json';
-	} elseif (abs($stored - $after['widths'][0]) > 12) {
-		$failures[] = sprintf('the stored width %d does not match the rendered %.0f', $stored, $after['widths'][0]);
-	}
-
-	// Cold start: a fresh page must open at the stored width — head() emits it into the
-	// initial HTML, so the property is set before anything paints.
-	if ($stored !== null) {
-		$cold = $context->newPage();
-		$cold->setViewportSize(1600, 900);
-		$cold->goto($edit);
-		$cold->waitForLoadState('networkidle');
-		$coldWidth = (float) $cold->evaluate(/** @lang JavaScript */ "() => document.querySelector('#form > table.layout textarea').getBoundingClientRect().width");
-		if (abs($coldWidth - $stored) > 12) {
-			$failures[] = sprintf('cold start opened at %.0f, not the stored %d', $coldWidth, $stored);
-		}
-		$cold->close();
-	}
-
-	$page->screenshot($fix['shots'] . '/edit-field-width.png');
-	$context->close();
-} catch (\Throwable $e) {
-	$failures[] = 'edit-field-width: ' . $e->getMessage();
-}
-
-e2e_done($fix['server'], $failures, 'edit-field-width');
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/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/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');