diff --git a/CLAUDE.md b/CLAUDE.md index a60f3326..64209703 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,13 +37,21 @@ npm run playwright:local # Admin + frontend specs (skips visual) npm run playwright:visual # Visual specs inside the Playwright Linux image npm run playwright:docker # Whole local suite inside that image npm run playwright:update-snapshots # Regenerate visual baselines -npm run playwright:live # Only specs/live, against msls.co +npm run playwright:live # Only specs/live, against msls.co (no wp-env needed) npx playwright test --ui # Run with UI ``` Visual baselines are committed and only pixel-stable when generated inside the container — always use the `:visual` / `:update-snapshots` scripts, never a bare `npx playwright test` for them. +The `live` project is a read-only smoke test against an already-running installation. +`baseURL` comes from `MSLS_LIVE_URL` (default `https://msls.co`), so +`MSLS_LIVE_URL=https://staging.example.com npm run playwright:live` retargets it. The +script also sets `MSLS_LIVE_ONLY=1`, which makes `globalSetup` return before it touches +wp-env — running `npx playwright test --project=live` without it attempts the local +seeding and fails. The target has to serve a public `/testpage` carrying the switcher +markup the specs assert on. See `docs/e2e-testing.md` for the full contract. + ### Local Development Environment ```bash npx wp-env start # Start WordPress multisite via wp-env (PHP 8.3) @@ -70,8 +78,9 @@ it, so activate it once per fresh **development** environment with the command a ### Namespace & Autoloading - PSR-4: `lloc\Msls\` maps to `includes/`, split into per-concern sub-namespaces: `Admin\`, `Blog\`, `Cli\`, `Component\`, `ContentImport\`, `ContentTypes\`, `Data\`, `Db\`, `Frontend\`, `Link\`, `Options\`, `Registry\`, `Request\`, `RestApi\` - PSR-4 (dev): `lloc\MslsTests\` maps to `tests/phpunit/` -- Plugin bootstrap: `MultisiteLanguageSwitcher.php` — defines constants, then on `plugins_loaded` requires `includes/aliases.php`, `includes/deprecated.php`, and `includes/api.php`, builds the PHP-DI container from `config.php`, and calls `lloc\Msls\Plugin::init()` plus `lloc\Msls\Cli\Cli::init()` -- **Backwards-compatibility aliases**: `includes/aliases.php` registers the ~60 pre-3.0 flat class names (`MslsOptions`, `MslsLink`, `MslsPlugin`, …) as `class_alias()` entries for their namespaced replacements. Write new code against the namespaced names; the aliases exist only for third-party consumers +- Plugin bootstrap: `MultisiteLanguageSwitcher.php` — defines constants, requires `vendor/autoload.php` plus `includes/aliases.php`, `includes/deprecated.php` and `includes/api.php` **at file-load time**, then calls `lloc\Msls\Plugin::init()` and `lloc\Msls\Cli\Cli::init()` on `plugins_loaded`. Do not move those requires into the hook: add-ons may load before us, and they need the aliases and the `msls_*()` functions to exist the moment the plugin file is included +- **Backwards-compatibility aliases**: `lloc\Msls\Compat\Aliases::MAP` (`includes/Compat/Aliases.php`) maps the ~60 pre-3.0 flat class names (`MslsOptions`, `MslsLink`, `MslsPlugin`, …) to their namespaced replacements. `::register()` — invoked from the thin `includes/aliases.php` — creates them with `class_alias()` **eagerly**, plus an autoloader for the handful in `::LAZY_ONLY`. Do not make them lazy across the board: PHP resolves the class named in a parameter/return/property type with `ZEND_FETCH_CLASS_NO_AUTOLOAD`, so an alias created on demand never gets its chance and the call fatals with a `TypeError` (MslsMenu declares `get_msls_output(): lloc\Msls\MslsOutput`). `LAZY_ONLY` is limited to names that never shipped before 3.0, so nothing can be holding them. Write new code against the namespaced names; the aliases exist only for third-party consumers +- **PHP-DI**: `lloc\Msls\Container::get()` builds the container from `config.php` on first use and caches it. `config.php` is still empty — nothing is injected through it yet ### Key Patterns - **Registry/Singleton**: `Registry\Instance` is the base class providing the `::instance()` static accessor (backed by `Registry\Registry`); `Registry\GetSet` extends it to add overloaded property access @@ -85,7 +94,7 @@ it, so activate it once per fresh **development** environment with the command a `includes/api.php` exposes the template functions: `msls_the_switcher()`, `msls_get_switcher()`, `msls_get_permalink()`, `msls_get_flag_url()`, `msls_blog_collection()`, etc. Legacy names (`the_msls()`, `get_the_msls()`, …) live in `includes/deprecated.php` and forward to them with a `_deprecated_function()` notice. ### Developer Documentation -`docs/` holds the reference material: `api.md` (public API functions), `hooks.md` (every action and filter), `snippets.md` (integration recipes), `acknowledgements.md` (credits and translators). Keep these in sync when adding or renaming a hook or an API function. +`docs/` holds the reference material: `api.md` (public API functions), `hooks.md` (every action and filter), `snippets.md` (integration recipes), `e2e-testing.md` (the Playwright `local` and `live` projects), `acknowledgements.md` (credits and translators). Keep these in sync when adding or renaming a hook or an API function. ### Test Framework - PHPUnit 10 with Brain\Monkey for WordPress function mocking diff --git a/Changelog.md b/Changelog.md index 5b38852a..d328af75 100644 --- a/Changelog.md +++ b/Changelog.md @@ -3,10 +3,11 @@ * Add Quick Create for translations: create the translated post straight from the editor metabox, or pick a source post on the new "Add from Translation" submenu (single and bulk), backed by a REST endpoint and switchable in the settings. * Add `msls_quick_create_capability` so integrations can override the Quick Create permission checks, plus filters for the post data, the inserted post, the response, the untranslated-posts list, and the mapped taxonomy terms. * Add filter hooks for the AJAX suggest results of the post and term metaboxes. -* Restructure `lloc\Msls\` into per-concern sub-namespaces (`Admin\`, `Blog\`, `ContentImport\`, `ContentTypes\`, `Frontend\`, `Link\`, `Options\`, `Registry\`, `RestApi\`). Every former flat `Msls*` class name keeps working through the aliases in `includes/aliases.php`. +* Restructure `lloc\Msls\` into per-concern sub-namespaces (`Admin\`, `Blog\`, `ContentImport\`, `ContentTypes\`, `Frontend\`, `Link\`, `Options\`, `Registry\`, `RestApi\`). Every former flat `Msls*` class name keeps working through `lloc\Msls\Compat\Aliases`, registered from `includes/aliases.php`. * Move the public helper functions into `includes/api.php` and make the `$attr` argument of `msls_get_switcher()` optional. -* Add a PHP-DI container for service construction. +* Add a PHP-DI container for service construction, built on first use by `lloc\Msls\Container::get()`. * Documentation: add a developer reference under `docs/` (public API, hooks, snippets, acknowledgements) and refresh the class and package diagrams. +* Fix: load the backwards-compatibility aliases and the `msls_*()` functions when the plugin file is included instead of on `plugins_loaded`, and keep the settings page slug handed to `msls_admin_register` at its pre-3.0 value. Add-ons such as MslsMenu load before the plugin and check `class_exists( 'lloc\Msls\MslsOptions' )` before registering anything, which silently disabled them — no add-on settings section, and no switcher in the nav menu. * Fix: check authorization on the destination post during content import, and correct the ContentImporter permission and post type checks. * Fix: do not fall back to `home_url()` for taxonomy and query archives. * Fix: broken links on the page for the latest posts. diff --git a/MultisiteLanguageSwitcher.php b/MultisiteLanguageSwitcher.php index af4af7e8..d5bb7b6e 100644 --- a/MultisiteLanguageSwitcher.php +++ b/MultisiteLanguageSwitcher.php @@ -50,17 +50,18 @@ require __DIR__ . '/vendor/autoload.php'; } + /** + * Loaded here and not on plugins_loaded: add-ons are free to run before us, and the + * backwards-compatibility aliases and the msls_*() functions have to be in place from + * the moment this file is included. + */ + require_once __DIR__ . '/includes/aliases.php'; + require_once __DIR__ . '/includes/deprecated.php'; + require_once __DIR__ . '/includes/api.php'; + add_action( 'plugins_loaded', function () { - require_once __DIR__ . '/includes/aliases.php'; - require_once __DIR__ . '/includes/deprecated.php'; - require_once __DIR__ . '/includes/api.php'; - - $builder = new DI\ContainerBuilder(); - $builder->addDefinitions( require __DIR__ . '/config.php' ); - $builder->build(); - lloc\Msls\Plugin::init(); lloc\Msls\Cli\Cli::init(); } diff --git a/README.md b/README.md index c8a305a5..55607e64 100644 --- a/README.md +++ b/README.md @@ -66,5 +66,6 @@ Reference material for developers extending or integrating with the plugin lives * [Public API Functions](docs/api.md) - the `msls_*` helper functions exposed for use in themes and other plugins. * [Hooks Reference](docs/hooks.md) - every action and filter the plugin emits, grouped by subsystem. * [Snippets & Examples](docs/snippets.md) - short, focused recipes for common integration tasks. +* [End-to-End Testing](docs/e2e-testing.md) - the Playwright suite: the local `wp-env` project and the read-only `live` project that runs against a real installation. Credits for flag icons, banner artwork, and the full list of translators are maintained in [Acknowledgements & Translators](docs/acknowledgements.md). diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md new file mode 100644 index 00000000..4cbaeb0f --- /dev/null +++ b/docs/e2e-testing.md @@ -0,0 +1,149 @@ +# End-to-End Testing + +The plugin ships a [Playwright](https://playwright.dev/) suite in `tests/playwright/`. +`playwright.config.ts` defines two projects, and the project you pick decides both *which* +specs run and *what they run against*: + +| Project | Specs | Target | +| --- | --- | --- | +| `local` | everything except `specs/live/**` | a throwaway `wp-env` multisite (`http://localhost:8889`) | +| `live` | only `specs/live/**/*.spec.ts` | a real, already-running installation (`https://msls.co`) | + +The two are deliberately disjoint: `local` sets `testIgnore: ['**/specs/live/**']` +(`playwright.config.ts:39`) and `live` sets `testMatch: ['**/specs/live/**/*.spec.ts']` +(`playwright.config.ts:47`). + +## Local suite + +The local suite seeds its own multisite topology, so `wp-env` has to be running first. The +commands are listed in [CLAUDE.md](../CLAUDE.md) under *E2E Tests* — in short, +`npm run playwright:local` for the admin and frontend specs, and the `:visual` / +`:update-snapshots` scripts for the visual specs, which are only pixel-stable inside the +Playwright Linux container. + +## Live suite + +The live suite is a read-only smoke test. It opens `/testpage` on a running installation, +clicks through the language switchers, and asserts that the active link picks up the +`current_language` class. There is no login, no seeding and no `wp-env` involved: the spec +imports straight from `@playwright/test` rather than from `tests/playwright/fixtures/msls-fixtures.ts`, +so it gets no `seed` fixture and no storage state. + +### Running it + +```bash +npx playwright install chromium # once; wp-env is not needed for the live suite + +npm run playwright:live # against msls.co +MSLS_LIVE_URL=https://staging.example.com npm run playwright:live # against another host +``` + +There is no `.env` support in this repository — nothing loads env files, so `MSLS_LIVE_URL` +has to be exported in your shell or prefixed to the command. (And note that `.gitignore` +currently has no `.env` entry, so a file you create there would *not* be ignored.) + +### What the target installation has to provide + +The assertions in `tests/playwright/specs/live/testpage.spec.ts` are specific. The target +needs: + +* a publicly reachable page at `/testpage` — no login wall +* a network offering the languages `de_DE` and `en_GB` +* a `.widget_mslswidget` container holding the links *de_DE Deutsch* and *en_GB English* +* an `.msls-menu` holding the links *de_DE* and *en_GB* (exact text) — **msls.co does not + currently provide this**, see *Current status* below +* at least three switcher renderings inside `.entry-content` with *de_DE Deutsch* / + *en_GB English* — the spec iterates `nth(0)` through `nth(2)` +* additional links inside `.entry-content` reading exactly *Deutsch* / *English*, for the + translation-hint test +* the `current_language` class on whichever link is currently active + +`/testpage` is part of the test fixture, not ordinary content. Rebuilding that page on +msls.co will break the suite. + +### Current status + +As of 2026-08-21 the suite was **5 passed, 1 failed** against msls.co. The failing test was +`testing with .msls-menu de_DE en_GB`: `/testpage` no longer rendered an element with the +`msls-menu` class, while the site's Custom CSS rule (`.msls-menu a { display: inline-block; }`) +was still there. + +That was **not** fixture drift — it was the plugin. Since commit `3afd781` the +backwards-compatibility aliases were loaded inside a `plugins_loaded` callback, which made +`class_exists( 'lloc\Msls\MslsOptions' )` return `false` for the MslsMenu add-on, so +MslsMenu registered neither its `wp_nav_menu_items` filter nor its settings section. This +spec is the standing regression test for that bug; keep it. + +### Pitfalls + +**Always use the npm script.** `npx playwright test --project=live` on its own still lets +`globalSetup` run, and `globalSetup` only ever targets the local wp-env installation. The +live tests themselves pass either way, but the setup runs first and has side effects that +have nothing to do with the run: + +* it re-seeds your local test environment — `seedTranslationLinkedPosts()` calls + `wp post delete --force` for every `post_type=post` entry on all three subsites before + recreating the demo posts (`global-setup.ts:193-199`), so local posts are gone +* it re-primes the admin storage states and rewrites + `tests/playwright/artifacts/seed.json` +* it adds roughly half a minute of `npx wp-env run tests-cli` round-trips +* with `wp-env` stopped it fails outright, since every step shells out to that container + +`npm run playwright:live` sets `MSLS_LIVE_ONLY=1`, which makes the setup return before any +of that happens (`tests/playwright/setup/global-setup.ts:259`). + +**Never run the suite bare.** A plain `npm run playwright` or `npx playwright test` +executes *both* projects — so it hits msls.co with the live specs on top of seeding your +local environment. Use `playwright:local` while working locally. + +**Docker is not an option here.** `tests/playwright/scripts/run-in-docker.sh` exists for +the visual baselines. It forwards neither `MSLS_LIVE_URL` nor `MSLS_LIVE_ONLY`, and every +caller passes `--project=local`. Run the live suite directly on the host. + +### Why the other specs cannot be pointed at production + +Only `specs/live/` is portable. The rest is wired to localhost by construction: + +* `specs/frontend/*` and `specs/visual/*` depend on the `seed` fixture + (`tests/playwright/artifacts/seed.json`, which stores localhost links) and pin + `test.use({ baseURL: subsiteUrl(slug) })` to `WP_BASE_URL` +* `specs/admin/*` and the visual admin spec need the storage states primed by + `globalSetup`, which carry localhost cookies +* the committed baselines in + `tests/playwright/specs/__snapshots__/visual/frontend.visual.spec.ts/hreflang-*.txt` + contain literal `http://localhost:8889` URLs + +### Not part of CI + +`.github/workflows/e2e.yml` only ever runs `npm run playwright:local`. The live suite +depends on an external host and on that host's content, so a msls.co outage or an edit to +`/testpage` would turn unrelated pull requests red. Run it manually when you want to +verify a release against the real site. + +### Practical notes + +* **No trace on the first failure.** Outside CI `retries` is `0` + (`playwright.config.ts:13`) while `trace` is `'on-first-retry'` + (`playwright.config.ts:25`), so nothing is captured. Append `--trace=on` or + `--retries=1` when you need to debug. +* **The run is parallel.** `fullyParallel: true` (`playwright.config.ts:11`) applies to + `live` too, and `workers` is unbounded outside CI (`playwright.config.ts:14`), so several + browsers hit the site at once. Add `--workers=1` to be gentle. +* **Reports** land in `tests/playwright/artifacts/` (entirely gitignored) and are shared + with local runs: `html-report/`, `test-results/`, `test-results.json`. +* **The suite only reads.** It clicks frontend links; it never authenticates and never + issues a writing request against production. +* **Known issue:** the `testing translation hint` test clicks a link named *English* right + after asserting `toHaveCount(0)` for that same name. The test currently passes, but the + intent is muddled — don't mistake a later fix for a regression. + +## Environment variables + +| Variable | Default | Effect | Read at | +| --- | --- | --- | --- | +| `MSLS_LIVE_URL` | `https://msls.co` | `baseURL` of the `live` project | `playwright.config.ts:6` | +| `MSLS_LIVE_ONLY` | unset | `1` skips all seeding and auth in `globalSetup` | `global-setup.ts:259` | +| `WP_BASE_URL` | `http://localhost:8889` | `baseURL` of the `local` project, and the host `globalSetup` seeds | `playwright.config.ts:5`, `global-setup.ts:7`, `msls-fixtures.ts:12` | +| `MSLS_SKIP_E2E_SEED` | unset | `1` skips seeding but keeps the local target — set by `run-in-docker.sh` | `global-setup.ts:255` | +| `STORAGE_STATE_DIR` | `tests/playwright/artifacts/storage-states` | where admin storage states are written and read | `global-setup.ts:10`, `msls-fixtures.ts:14` | +| `CI` | unset | enables `forbidOnly`, `retries: 2`, `workers: 1` | `playwright.config.ts:4` | diff --git a/docs/hooks.md b/docs/hooks.md index 2239a6ba..8bccc21e 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -11,6 +11,26 @@ name and a paragraph explaining what it does and a typical use case. To learn the exact arguments a hook receives, grep for the hook name in `includes/` — the `apply_filters()` / `do_action()` call site is the source of truth. +## What add-ons can rely on + +Everything an add-on needs is in place from the moment +`MultisiteLanguageSwitcher.php` is included — before `plugins_loaded` fires, and +therefore regardless of the order in which WordPress happens to load the two +plugins: + +* the `msls_*()` functions of `includes/api.php` and the deprecated wrappers of + `includes/deprecated.php` +* every class under `lloc\Msls\`, through Composer's PSR-4 autoloader +* every pre-3.0 flat class name (`lloc\Msls\MslsOptions`, + `lloc\Msls\MslsAdmin`, `lloc\Msls\MslsLink`, `lloc\Msls\MslsOutput`, …). + The 3.0 restructuring moved these into sub-namespaces; `lloc\Msls\Compat\Aliases` + registers them as `class_alias()` entries, so `class_exists( 'lloc\Msls\MslsOptions' )` + keeps working and the old names are equally valid in type declarations. Write + new code against the namespaced names — the aliases exist for third-party + consumers. + +The hooks below, by contrast, fire during `plugins_loaded` and later. + ## Frontend output and links ### msls_get_output @@ -214,13 +234,29 @@ Action that fires after the plugin registers its built-in settings sections. Use it as the entry point to add your own settings section to the MSLS admin page via `add_settings_section()`. +The callback receives the settings page slug as its only argument. **Always +pass that argument through to `add_settings_section()` / `add_settings_field()` +instead of hard-coding a slug** — it is the only supported way to land on the +page MSLS actually renders: + +```php +add_action( + 'msls_admin_register', + function ( $page ) { + add_settings_section( 'my_section', 'My Settings', 'my_render', $page ); + } +); +``` + ### msls_admin_{section} Dynamic action that fires after the plugin registers the fields belonging to a specific settings section. The `{section}` suffix matches the section ID, for example `msls_admin_main_section`, `msls_admin_language_section`, `msls_admin_advanced_section`, or `msls_admin_rewrites_section`. Use it to -add custom fields to one specific section without touching the others. +add custom fields to one specific section without touching the others. Like +`msls_admin_register`, it hands you the page slug — as the first of its two +arguments, the second being the section ID. ### msls_admin_caps diff --git a/includes/Admin/Admin.php b/includes/Admin/Admin.php index a21bb680..49bdfb92 100644 --- a/includes/Admin/Admin.php +++ b/includes/Admin/Admin.php @@ -39,6 +39,15 @@ final class Admin extends Main { const MSLS_ACTION_PREFIX = 'msls_admin_'; + /** + * Slug of the settings page, as handed to the Settings API and to add-ons. + * + * Deliberately the pre-3.0 class name and not __CLASS__: the restructuring turned the + * latter into lloc\Msls\Admin\Admin, which would have silently dropped the sections + * and fields of every add-on registering against the name it knows. + */ + public const MSLS_SETTINGS_PAGE = 'lloc\\Msls\\MslsAdmin'; + /** * Maximum number of users in the reference user select box * @@ -192,7 +201,7 @@ public function render(): void { ); settings_fields( 'msls' ); - do_settings_sections( __CLASS__ ); + do_settings_sections( self::MSLS_SETTINGS_PAGE ); $value = $this->options->is_empty() ? __( 'Configure', 'multisite-language-switcher' ) : __( 'Update', 'multisite-language-switcher' ); @@ -243,7 +252,7 @@ public function register(): void { } foreach ( $sections as $id => $title ) { - add_settings_section( $id, $title, array( $this, $id ), __CLASS__ ); + add_settings_section( $id, $title, array( $this, $id ), self::MSLS_SETTINGS_PAGE ); } /** @@ -253,7 +262,7 @@ public function register(): void { * * @since 1.0 */ - do_action( self::MSLS_REGISTER_ACTION, __CLASS__ ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- constant value is already prefixed with "msls_". + do_action( self::MSLS_REGISTER_ACTION, self::MSLS_SETTINGS_PAGE ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- constant value is already prefixed with "msls_". } /** @@ -346,7 +355,7 @@ protected function add_settings_fields( array $map, string $section ): int { if ( ! is_callable( $callback ) ) { continue; } - add_settings_field( $id, $title, $callback, __CLASS__, $section, array( 'label_for' => $id ) ); + add_settings_field( $id, $title, $callback, self::MSLS_SETTINGS_PAGE, $section, array( 'label_for' => $id ) ); } /** @@ -357,7 +366,7 @@ protected function add_settings_fields( array $map, string $section ): int { * * @since 2.4.4 */ - do_action( self::MSLS_ACTION_PREFIX . $section, __CLASS__, $section ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- MSLS_ACTION_PREFIX is already prefixed with "msls_". + do_action( self::MSLS_ACTION_PREFIX . $section, self::MSLS_SETTINGS_PAGE, $section ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- MSLS_ACTION_PREFIX is already prefixed with "msls_". return count( $map ); } diff --git a/includes/Compat/Aliases.php b/includes/Compat/Aliases.php new file mode 100644 index 00000000..3723b1e6 --- /dev/null +++ b/includes/Compat/Aliases.php @@ -0,0 +1,182 @@ + + */ + public const MAP = array( + 'lloc\\Msls\\MslsOptions' => Options::class, + 'lloc\\Msls\\MslsOptionsPost' => Post::class, + 'lloc\\Msls\\MslsOptionsQuery' => Query::class, + 'lloc\\Msls\\MslsOptionsQueryAuthor' => Author::class, + 'lloc\\Msls\\MslsOptionsQueryDay' => Day::class, + 'lloc\\Msls\\MslsOptionsQueryMonth' => Month::class, + 'lloc\\Msls\\MslsOptionsQueryPostType' => QueryPostType::class, + 'lloc\\Msls\\MslsOptionsQueryYear' => Year::class, + + 'lloc\\Msls\\MslsOptionsTax' => Tax::class, + 'lloc\\Msls\\MslsOptionsTaxTerm' => Term::class, + 'lloc\\Msls\\MslsOptionsTaxTermCategory' => Category::class, + + 'lloc\\Msls\\MslsLink' => Link::class, + 'lloc\\Msls\\MslsLinkImageOnly' => ImageOnly::class, + 'lloc\\Msls\\MslsLinkTextImage' => TextImage::class, + 'lloc\\Msls\\MslsLinkTextOnly' => TextOnly::class, + + 'lloc\\Msls\\LinkInterface' => LinkInterface::class, + 'lloc\\Msls\\OptionsInterface' => OptionsInterface::class, + 'lloc\\Msls\\OptionsTaxInterface' => OptionsTaxInterface::class, + + 'lloc\\Msls\\MslsOutput' => Output::class, + 'lloc\\Msls\\MslsWidget' => Widget::class, + 'lloc\\Msls\\MslsBlock' => Block::class, + 'lloc\\Msls\\MslsShortCode' => ShortCode::class, + 'lloc\\Msls\\MslsContentFilter' => ContentFilter::class, + + 'lloc\\Msls\\MslsContentTypes' => ContentTypes::class, + 'lloc\\Msls\\MslsPostType' => ContentPostType::class, + 'lloc\\Msls\\MslsTaxonomy' => Taxonomy::class, + + 'lloc\\Msls\\MslsAdmin' => Admin::class, + 'lloc\\Msls\\MslsAdminBar' => Bar::class, + 'lloc\\Msls\\MslsAdminIcon' => Icon::class, + 'lloc\\Msls\\MslsAdminIconTaxonomy' => IconTaxonomy::class, + 'lloc\\Msls\\MslsCustomColumn' => CustomColumn::class, + 'lloc\\Msls\\MslsCustomColumnTaxonomy' => CustomColumnTaxonomy::class, + 'lloc\\Msls\\MslsCustomFilter' => CustomFilter::class, + 'lloc\\Msls\\MslsMetaBox' => MetaBox::class, + 'lloc\\Msls\\MslsPostListActions' => PostListActions::class, + + 'lloc\\Msls\\MslsTranslationPickerPage' => TranslationPickerPage::class, + 'lloc\\Msls\\MslsTranslationPickerTable' => TranslationPickerTable::class, + + 'lloc\\Msls\\MslsBlog' => Blog::class, + 'lloc\\Msls\\MslsBlogCollection' => BlogCollection::class, + + 'lloc\\Msls\\MslsCli' => Cli::class, + + 'lloc\\Msls\\MslsSqlCacher' => SqlCacher::class, + + 'lloc\\Msls\\MslsRegistry' => Registry::class, + 'lloc\\Msls\\MslsRegistryInstance' => RegistryInstance::class, + + 'lloc\\Msls\\MslsPostTag' => PostTag::class, + 'lloc\\Msls\\MslsPostTagClassic' => PostTagClassic::class, + + 'lloc\\Msls\\MslsRestApi' => RestApi::class, + + 'lloc\\Msls\\Query\\AuthorPostsCounterQuery' => AuthorPostsCounterQuery::class, + 'lloc\\Msls\\Query\\BlogsInNetworkQuery' => BlogsInNetworkQuery::class, + 'lloc\\Msls\\Query\\CleanupOptionsQuery' => CleanupOptionsQuery::class, + 'lloc\\Msls\\Query\\DatePostsCounterQuery' => DatePostsCounterQuery::class, + 'lloc\\Msls\\Query\\MonthPostsCounterQuery' => MonthPostsCounterQuery::class, + 'lloc\\Msls\\Query\\TranslatedPostIdQuery' => TranslatedPostIdQuery::class, + 'lloc\\Msls\\Query\\YearPostsCounterQuery' => YearPostsCounterQuery::class, + + 'lloc\\Msls\\MslsFields' => Fields::class, + 'lloc\\Msls\\MslsGetSet' => GetSet::class, + 'lloc\\Msls\\MslsJson' => Json::class, + 'lloc\\Msls\\MslsLanguageArray' => LanguageArray::class, + 'lloc\\Msls\\MslsMain' => Main::class, + 'lloc\\Msls\\MslsPlugin' => Plugin::class, + 'lloc\\Msls\\MslsRequest' => Request::class, + ); + + /** + * Names that never shipped before 3.0, so no third-party code can be holding them. + * + * They stay out of the eager pass: nothing can name them in a type declaration, and + * aliasing MslsTranslationPickerTable would drag wp-admin/includes/class-wp-list-table.php + * into every front-end request. The autoloader still resolves them on demand. + * + * @var array + */ + public const LAZY_ONLY = array( + 'lloc\\Msls\\MslsPostListActions', + 'lloc\\Msls\\MslsRestApi', + 'lloc\\Msls\\MslsTranslationPickerPage', + 'lloc\\Msls\\MslsTranslationPickerTable', + ); + + /** + * Creates the aliases and installs the autoloader resolving the rest on demand. + * + * The autoloader is appended, not prepended: Composer's PSR-4 loader stays + * authoritative for the current class names and this one only ever runs for a name it + * could not resolve. + */ + public static function register(): void { + spl_autoload_register( + static function ( string $name ): void { + if ( isset( self::MAP[ $name ] ) ) { + class_alias( self::MAP[ $name ], $name ); + } + } + ); + + foreach ( self::MAP as $legacy => $current ) { + if ( in_array( $legacy, self::LAZY_ONLY, true ) ) { + continue; + } + + class_alias( $current, $legacy ); + } + } +} diff --git a/includes/Container.php b/includes/Container.php new file mode 100644 index 00000000..0dfda9e6 --- /dev/null +++ b/includes/Container.php @@ -0,0 +1,45 @@ +addDefinitions( Plugin::plugin_dir_path( 'config.php' ) ); + + self::$container = $builder->build(); + } + + return self::$container; + } + + /** + * Drops the built container, so the next call to self::get() builds a new one. + */ + public static function reset(): void { + self::$container = null; + } +} diff --git a/includes/aliases.php b/includes/aliases.php index 96e2d23d..99935a86 100644 --- a/includes/aliases.php +++ b/includes/aliases.php @@ -1,13 +1,11 @@ + */ + public static function alias_provider(): array { + $data = array(); + + foreach ( Aliases::MAP as $legacy => $current ) { + $data[ $legacy ] = array( $legacy, $current ); + } + + return $data; + } + + /** + * @param class-string $current + */ + #[DataProvider( 'alias_provider' )] + public function test_legacy_name_resolves( string $legacy, string $current ): void { + Aliases::register(); + + $this->assertTrue( + class_exists( $legacy ) || interface_exists( $legacy ), + sprintf( 'The legacy name %s does not resolve any more.', $legacy ) + ); + + $this->assertTrue( + is_a( $legacy, $current, true ), + sprintf( '%s is not an alias of %s.', $legacy, $current ) + ); + } + + /** + * PHP resolves the class named in a type declaration without autoloading, so every + * name an add-on may have put in one has to exist the moment register() returns. + * + * @param class-string $current + */ + #[DataProvider( 'alias_provider' )] + public function test_legacy_name_is_created_eagerly( string $legacy, string $current ): void { + if ( in_array( $legacy, Aliases::LAZY_ONLY, true ) ) { + $this->markTestSkipped( sprintf( '%s never shipped before 3.0 and stays lazy.', $legacy ) ); + } + + Aliases::register(); + + $this->assertTrue( + class_exists( $legacy, false ) || interface_exists( $legacy, false ), + sprintf( '%s has to be aliased without autoloading, not on demand.', $legacy ) + ); + } + + /** + * The regression test for the bug this whole compatibility layer exists for: MslsMenu + * declares get_msls_output(): lloc\Msls\MslsOutput and hands it what msls_output() + * returns, an instance of lloc\Msls\Frontend\Output. + */ + public function test_legacy_name_satisfies_a_return_type(): void { + Aliases::register(); + + $output = \Mockery::mock( Output::class ); + + $this->assertInstanceOf( Output::class, $this->legacy_typed_output( $output ) ); + } + + public function test_unknown_name_is_left_alone(): void { + Aliases::register(); + + $this->assertFalse( class_exists( 'lloc\Msls\MslsThisNeverExisted' ) ); + } + + public function test_lazy_only_names_are_not_loaded_upfront(): void { + Aliases::register(); + + foreach ( Aliases::LAZY_ONLY as $legacy ) { + $this->assertFalse( + class_exists( $legacy, false ), + sprintf( '%s must not be aliased before something asks for it.', $legacy ) + ); + } + } + + /** + * @param mixed $output + */ + private function legacy_typed_output( $output ): \lloc\Msls\MslsOutput { + return $output; + } +} diff --git a/tests/phpunit/TestContainer.php b/tests/phpunit/TestContainer.php new file mode 100644 index 00000000..17b1ec7b --- /dev/null +++ b/tests/phpunit/TestContainer.php @@ -0,0 +1,35 @@ +justReturn( dirname( __DIR__, 2 ) . '/' ); + } + + protected function tearDown(): void { + Container::reset(); + + parent::tearDown(); + } + + public function test_get_returns_the_same_container_twice(): void { + $this->assertSame( Container::get(), Container::get() ); + } + + public function test_reset_drops_the_container(): void { + $container = Container::get(); + + Container::reset(); + + $this->assertNotSame( $container, Container::get() ); + } +} diff --git a/tests/phpunit/WP_List_Table.php b/tests/phpunit/WP_List_Table.php new file mode 100644 index 00000000..2f588947 --- /dev/null +++ b/tests/phpunit/WP_List_Table.php @@ -0,0 +1,40 @@ + + */ + public $items = array(); + + /** + * @var array + */ + public $_column_headers = array(); // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore + + /** + * @param array $args + */ + public function __construct( $args = array() ) { + } + + /** + * @return int + */ + public function get_pagenum() { + return 1; + } + + /** + * @param array $args + */ + public function set_pagination_args( $args ) { + } +} diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php index cb386c9e..305c471a 100644 --- a/tests/phpunit/bootstrap.php +++ b/tests/phpunit/bootstrap.php @@ -2,5 +2,6 @@ class_alias( \lloc\MslsTests\WP_Widget::class, '\WP_Widget' ); class_alias( \lloc\MslsTests\WP_CLI::class, '\WP_CLI' ); +class_alias( \lloc\MslsTests\WP_List_Table::class, '\WP_List_Table' ); require_once __DIR__ . '/../../includes/deprecated.php';