Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 9 additions & 8 deletions MultisiteLanguageSwitcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
149 changes: 149 additions & 0 deletions docs/e2e-testing.md
Original file line number Diff line number Diff line change
@@ -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` |
38 changes: 37 additions & 1 deletion docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading