Skip to content

test(e2e): run browser-based storefront tests against a Warden environment - #396

Draft
marcelmtz wants to merge 20 commits into
mage-os:mainfrom
marcelmtz:feat/internal-e2e-canary
Draft

marcelmtz wants to merge 20 commits into
mage-os:mainfrom
marcelmtz:feat/internal-e2e-canary

Conversation

@marcelmtz

@marcelmtz marcelmtz commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Revives the stalled browser-based e2e work as something a release can be gated on. Stacked on #395, which adds the serving inputs this needs.

Three pieces:

  1. _internal-e2e.yaml — a canary that stands a store up with this PR's warden/setup-environment and runs a browser against it. Uses ./actions/... deliberately, so it tests the action changes in the PR rather than whatever is on main.
  2. The suite in _test/demo-package/Test/e2e/ — a homepage smoke spec, a guest-checkout spec, and a fixture profile.
  3. e2e.yaml — a reusable workflow_call workflow the release pipeline can call, with e2e-README.md. The canary cannot be reused for that: a relative action path in a reusable workflow resolves against the caller's checkout, so own-repo actions are referenced by full ref here, as integration.yaml and full-integration-tests.yaml already do.

Verified

Canary run, green in 4m10s total:

Confirm the store answers      HTTP/2 200
Seed the catalog               Total execution time: 00:00:09
Run the storefront tests       2 passed (20.2s)

The reusable workflow was verified separately on a throwaway branch with the Warden action pointed at #395's branch — store served, catalog seeded, 2 specs passed, report uploaded. At @main it needs #395's inputs, so e2e.yaml cannot go green until #395 is on main. The canary is green today regardless.

Guest orders land for real: customer_is_guest=1, Flat Rate - Fixed, $6.00 grand total. And the suite bites — with carriers/flatrate/active set to 0 it fails at rate selection with Flat Rate should be offered for the shipping address, not somewhere downstream.

What the specs assert, and why

A 200 is not evidence of a storefront: the admin login page, a CMS no-route page and a Magento error page all answer 200 with a header, a footer and a title. So each assertion is one that fails on something a storefront check should not accept.

Spec Asserts
hello-magento.spec.ts 200; body.cms-index-index and an attached minicart (not some other 200); document.styleSheets has parsed rules (not an empty pub/static — a stylesheet that 404s is still a <link>)
guest-checkout.spec.ts category listing → product page → cart → shipping address → rate → payment → order, asserting a 9-digit increment id

Checkout is the deepest path available: it touches the price index, the quote, the shipping and payment method pools, order placement, and the private-content sections Varnish has to punch holes for.

Fixtures

A bare setup:install has no salable product, so checkout has nothing to buy. fixtures/mageos-e2e.xml seeds the least data it takes: 20 simple products with deterministic SKUs, one configurable, two categories in the main navigation. 12s wall clock, including the reindex the command runs itself, and idempotent.

Core ships no profile for functional seeding — the smallest of the five performance profiles generates 800 products, 100 images and 80 orders, because those exist to benchmark a store under catalog weight. Sample data was the other option and is worse for a gate: it is mirrored into repo.mage-os.org so it needs no Adobe credentials, but its content changes between releases, so an assertion on a product name becomes an assertion about sample data.

Two things a reviewer should know about the profile:

  • It deliberately does not copy small.xml's admin/security/use_form_key=0 and admin_account_sharing=1. A gate running against a store with admin CSRF protection disabled is not testing the store we ship.
  • It carries two <configs> entries because one fatals: ConfigsApplyFixture.php:29 iterates $configs['config'] as a list, but a single <config> element collapses to a flat map, so it iterates that element's own values and dies with Cannot access offset of type string on string. small.xml has four entries, so upstream never sees it. Worth a one-line fix in mage-os/mageos-magento2 separately.

Two runner details that cost a red run each

  • Checkout path. The Warden action checks the Warden CLI out into warden/ at the workspace root, which is exactly where this repository keeps that action. Checking out at the root clobbers it, so the canary uses path: actions and e2e.yaml uses path: suite. Documented in feat(warden): serve the environment for browser-based tests #395's README.
  • Permissions. The runner cannot write into project directories the container has written to (cp: cannot create regular file 'var/...': Permission denied), so the fixture profile is streamed in through exec -T rather than copied.

On test_command

e2e.yaml takes test_command as an input rather than hardcoding npx playwright test. The workflow's job is to stand a store up and hand over a URL; it should not know what runner points at it, and a caller wanting a grep filter, a shard or a different reporter should not need a new input for each. It arrives through the environment rather than interpolated into the script, so its quoting is bash's problem and not the expression engine's.

The default carries no flags on purpose. Evidence policy belongs in the suite's config where it is reviewable — a CLI flag overrides the config, and --trace on records a trace for every passing test.

The test_command input and the setup-phase idea are both @digitalrisedorset's, from #313. The README documents the setup phase as Playwright project dependencies rather than two --grep invocations, because two invocations share one report directory and the second overwrites the first, so a failing setup phase loses its own evidence. The setup project itself is not here — nothing needs an authenticated session yet, and it should land with the first admin spec that does.

Evidence on failure

trace: 'retain-on-failure' rather than on-first-retry, plus video and screenshot, kept 30 days. A release gate that fails is read by someone deciding whether to ship, and "re-run it and watch" is not an answer at that point. That paid off during this PR: a failing CI run's page snapshot showed the URL was configurable-product-1.html, which is how the catalog-order bug was found in about a minute.

Replaces

Both can be closed once this lands.

Not in scope

  • No supported-version matrix. A release gate has to install the version being released, which by definition is not in supported.json yet, so magento_version and magento_repository are passed directly.
  • The release-pipeline job lives in generate-mirror-repo-js and follows separately, once e2e.yaml is on main to reference.
  • @rhoerr raised on ci(e2e): add Playwright-based E2E workflow for Mage-OS #313 that an e2e workflow overlaps integration.yaml and the common parts should be extracted. Still true, still worth doing separately; this does not make it worse.
  • Luma only. Hyvä is a later step, and the selectors are isolated in one map at the top of the spec so that variant is a different map rather than a rewrite.

PR Checklist

  • The commit message follows our guidelines
  • Tests for the changes have been added
  • Docs have been added / updated (e2e-README.md, suite README.md)

PR Type

  • Bugfix
  • Feature
  • Code style update
  • Refactoring
  • Build related changes
  • CI related changes
  • Documentation content changes

Does this PR introduce a breaking change?

  • Yes
  • No

The Warden environment already brings up nginx, Varnish and Traefik, but
nothing installed into it is reachable from the runner itself. Integration
tests never noticed, because they drive Magento's own bootstrap rather than
making HTTP requests.

Three things have to be true before a browser can reach the storefront, and
each is added here behind a default that preserves current behaviour:

- setup:install needs --base-url, or Magento redirects to its default base
  URL regardless of where the environment is served
- the Traefik hostname needs resolving on the runner, which has no dnsmasq
- static content needs deploying, or the storefront returns 200 and renders
  nothing

Also exposes the served URL as an output, so a caller does not have to
reconstruct it, and makes the admin frontName and the Warden environment
naming configurable rather than hardcoded -- a predictable admin URL is a
prerequisite for testing the admin at all.

Existing callers pass none of the new inputs and are unaffected.
Adds the canary for the browser-serving inputs on
warden/setup-environment: it installs Mage-OS, serves it, deploys Luma's
static content and drives a browser at the result, following the
_internal-* convention the other actions in this repository use.

The suite lives with the demo package, as the dummy e2e test already
did. It stays deliberately small: its job is to prove a storefront is
being served, not to cover a store's behaviour.

The assertions are chosen to fail against things that should not pass. A
200 is not evidence of a storefront -- the admin login page, a CMS
no-route page and a Magento error page all answer 200 with a header, a
footer and a title -- so the suite checks the resolved route in the body
class, Luma's minicart block and whether a stylesheet actually parsed
into rules. Without the last one it would pass against a store whose
pub/static was never deployed.

The suite now refuses to start without PLAYWRIGHT_BASE_URL rather than
falling back to a public demo store, since a suite that reports green
about a store nobody asked about is worse than one that will not run.
The first run failed in the post phase of the Warden step: the local
action could no longer be found. warden/setup-environment checks the
Warden CLI out into warden/ at the workspace root, which is exactly
where this repository keeps that action, so the checkout landed on top
of it.

Checking the repository out into actions/ leaves the workspace root free
for the Warden CLI, which the steps reach as
$(dirname $(pwd))/warden/bin/warden.
@marcelmtz

Copy link
Copy Markdown
Contributor Author

Green on run 42044685090 — 4m13s for the whole job:

Phase Duration
composer create-project --no-install 3s
warden svc up && warden env up 56s
composer install (643 packages, in the container) 64s
setup:install → [SUCCESS]: Magento installation complete. 16s
admin user + config preset 17s
setup:static-content:deploy (blank + luma, en_US) 10s
indexer:reindex 2s
curl --head → HTTP/2 200 —
Playwright 2s

Cheaper than I expected, which matters for the next step: gating a release on this costs minutes, not a budget conversation.

The first run failed, usefully

Can't find 'action.yml' under '.../github-actions/warden/setup-environment'

warden/setup-environment checks the Warden CLI out into warden/ at the workspace root — which is exactly where this repository keeps that action, so the checkout landed on top of itself. Fixed here by checking the repository out into actions/; the workspace layout is now documented on #395, since it will bite any caller repository with a warden/ directory of its own.

Verified against a real store, not just in CI

The suite passes against a Mage-OS 4.0.0 Luma store, and the negative control confirms the discriminator bites. Pointed at the admin login page, which answers 200 with stylesheets attached:

Error: expect(locator).toHaveClass(expected) failed
Expected pattern: /cms-index-index/
Received string:  "m137-admin-theme adminhtml-auth-login page-layout-admin-login"

That is the vacuous green this suite is built to refuse.

Staying in draft until #395 merges, since its commits are in this diff until then.

The README told the reader to point PLAYWRIGHT_BASE_URL at the admin URL
and watch the test go red. It does not go red: page.goto("/") resolves
against the origin and drops the path, so the run hits the homepage again
and passes -- the exact vacuous green this suite exists to refuse.

Replacing it with a throwaway spec that navigates to the admin path
directly, and the output it produces against a real store.
trace: on-first-retry only produces a trace once a test has failed and
been retried. A gate that fails is read by someone deciding whether to
ship, and "re-run it and watch" is not an answer at that point, so the
first failure now carries its own trace.

Report artifacts are kept for 30 days rather than 7, long enough to
outlive a release weekend.
The homepage check proves a store is served. It does not prove a store
can take money. This walks the deepest path a storefront check can:
category listing, product page, cart, shipping address, rate selection,
payment, order. It touches the price index, the quote, the shipping and
payment method pools, order placement, and the private-content sections
Varnish has to punch holes for.

That path needs a salable product, which a bare setup:install does not
have. fixtures/mageos-e2e.xml seeds the least data it takes: 20 simple
products with deterministic SKUs, one configurable, two categories in
the main navigation. 12s wall clock on Mage-OS 4.0.0 under Warden,
including the reindex the command runs itself, and idempotent.

Core ships no profile for functional seeding -- the smallest of the five
performance profiles generates 800 products, 100 images and 80 orders,
because those profiles exist to benchmark a store under catalog weight.
Sample data was the other option and is worse for a gate: its content
changes between releases, so an assertion on a product name is an
assertion about sample data rather than about Mage-OS.

Verified both directions against a Warden store: green with three
persisted guest orders carrying Flat Rate and a $6.00 grand total, and
red at the rate-selection step with carriers/flatrate/active set to 0.
The checkout spec needs something to buy, and the install the workflow
stands up has nothing. The profile is copied into the project directory
because the command runs inside the container, which sees only that.

It reindexes itself, so no indexer:reindex follows it.
The runner cannot write into the project directories the container has
written to, because those are owned by the container's user:

  cp: cannot create regular file 'var/mageos-e2e.xml': Permission denied

Piping the profile through exec avoids host permissions entirely and
leaves nothing behind in the project. Keeping the profile outside
setup/performance-toolkit means its di.xml is not loaded, which only
mocks the mail transport -- noted in the profile, and nothing here
generates customers or orders.
Which product a category lists first is not ours to decide, and on a
clean install it is the configurable. That will not go into the cart
until every attribute has a value, so the first CI run failed waiting
for a success message that was never coming -- it had a validation
error instead. It passed locally only because a leftover simple product
happened to sort ahead of it.

Selecting the first available value for whatever the page asks for
makes the step indifferent to catalog order, and lets the spec run
against a simple product, a configurable, or a merchant's own catalog.

Verified against a configurable locally: one dropdown, three values,
first selection adds to the cart.
_internal-e2e.yaml proves the actions work, but it cannot be reused: it
references them through "./", and a relative path in a reusable workflow
resolves against the caller's checkout, which is not this repository
when the caller is the release pipeline. So own-repo actions are
referenced by full ref here, as integration.yaml and
full-integration-tests.yaml already do.

test_command is an input rather than a hardcoded "npx playwright test".
The workflow's job is to stand a store up and hand over a URL; it should
not know what runner points at it, and a caller wanting a grep filter, a
shard or a different reporter should not need a new input for each. It
arrives through the environment rather than interpolated into the
script, so quoting is bash's problem and not the expression engine's.

Evidence policy stays in the suite's config, so the default command
carries no flags: a CLI flag overrides the config, and --trace on
records a trace for every passing test.

fixture_profile seeds a catalog before the run, because a bare
setup:install has nothing to buy. Empty skips it, which is right for a
suite that asserts only on what a bare install provides.

Verified green end to end on a throwaway branch, with the Warden action
pointed at the stacked serving branch: store served, catalog seeded in
9s, 2 specs passed. At @main it needs the serving inputs, so this cannot
go green until mage-os#395 is on main.
@marcelmtz marcelmtz changed the title test(e2e): run a storefront smoke test against the Warden environment test(e2e): run browser-based storefront tests against a Warden environment Nov 10, 2026
An expression interpolated into run: is substituted before bash sees
the script, so its content has to be trusted as shell. This one is our
own action's output rather than anything a caller supplies, so nothing
was exploitable, but it is the pattern issue mage-os#314 asks this repository
to stop using, and there is no reason for the gate to be the exception.

No expression is interpolated into a run: block in either workflow now.
Probed from the running store rather than echoed back from the inputs. A
version input can be a constraint, the repository can resolve it
differently than expected, and a service tag does not carry its patch
level: the local store asked for opensearch 3.3 and runs 3.3.2. A
failing release gate has to be able to say which.

MAGEOS_VERSION and MAGENTO_VERSION reach the suite, because a spec may
reasonably branch on either -- Luma markup tracks upstream Magento while
feature availability tracks Mage-OS. The rest goes to the job summary;
no spec should branch on a Redis patch level.

Nothing here can fail the job. A probe that comes back empty reports
unknown, because a disabled service is not a broken store. Two probes
needed care: varnishd -V writes its version to stderr, and OpenSearch
reports its own several lines into a JSON body.

The READMEs state the preference for test.skip with a reason over a
--grep exclusion, so that a version condition which stops applying is
visible in the report instead of silently selecting nothing.
The storefront is a thin skin over the catalog; the admin is where the
framework lives, and where a release is most likely to break. This adds the
enablement the admin needs and one spec that proves it works, so the specs
that exercise UI components and the EAV save path have somewhere to start.

Nothing in Magento switches two-factor authentication off. There is no enable
flag, and leaving twofactorauth/general/force_providers empty does not help:
ControllerActionPredispatch still routes a fresh admin user to tfa/tfa/index,
which redirects to tfa/tfa/requestconfig. Disabling the module is the only
route in, and MageOS_PasskeyAuth requires it and registers an admin 2FA
provider, so both go in one command or dependency validation refuses it. The
cost is stated in the workflow and the README: this gate does not cover admin
2FA, and doing that properly means driving a TOTP provider.

The admin login CAPTCHA is on by default -- admin/captcha/enable 1, after_fail
mode, three failed attempts per login name, counted in captcha_log. A suite
that asserts a bad credential is refused manufactures exactly those failures,
so leaving it on lets the negative control poison the positive one, reported
as an "Incorrect CAPTCHA" on a correct password. Disabled, and the specs limit
their own exposure: the refusal test uses a name that is not an account, and
the three sign-in assertions are separate tests, so each gets its own context.

No blanket modal dismissal. Both candidates were checked instead of assumed:
Magento_AdminAnalytics' prompt is gated by admin/usage/enabled, which defaults
to 0 and is now pinned, and Magento_ReleaseNotification builds its URL from
system/release_notification/content_url, which Mage-OS ships empty. Neither
renders. A helper that clicked away whatever covered the page would be dead
code today and would hide a real blocking dialog later.

Assertions are on page identity, not URLs: Magento serves the dashboard at
whichever admin path was requested, so a URL assertion passes or fails
depending on how the session arrived. #login-form is ambiguous -- the
storefront authentication popup uses the same id -- so the body class is
asserted first, which a wrong-frontname negative control found.

The sign-in is a project dependency rather than a globalSetup hook so it is
its own reported result: a broken session fails once and the admin specs
report "did not run", instead of a wall of identical reds.

Verified against a live store: three sign-in tests and the storefront smoke
green in 14s, and both negative controls red -- a wrong password and a wrong
frontname each fail the session setup on the assertion that should catch them.
Leaving 2FA in place means the admin specs cannot sign in, so the flag only
makes sense together with a test_command that does not select them. Without
that, the run fails on the session setup rather than on anything it meant to
test.
Two admin specs, in this order on purpose.

The grid spec is the whole Magento_Ui stack in one page: requirejs
resolves the component, Knockout renders the table, a separate
mui/index/render request answers with the data and the collection
applies the filter. If that is broken, every admin screen is broken --
and a create-product spec fails too, with no way to tell "the form never
rendered" from "the product could not be saved".

The create spec then crosses the seams no storefront check can reach:
form validation, the EAV write path, attribute set defaults, the stock
item, url rewrite generation, the indexers that make a product salable,
and a storefront quote. Fixtures are written straight to the database,
so a seeded catalog passing says nothing about whether a merchant can
add a product.

The Luma selector map moves to luma.ts, shared with guest-checkout, so
the two specs agree on what a product page looks like.

Four traps, each found by a red run rather than by reading markup:

- Every grid toolbar and table exists twice, duplicated inside
  .sticky-header, so both match two elements. The real table carries
  data-role="grid"; the real toolbar is a direct child of the outer
  wrap.
- The notification bell is a second grid on every admin page, and its
  loading mask is otherwise indistinguishable from the one under test,
  so the mask is selected by data-component.
- Grid filter state is saved per admin user in ui_bookmark, not per
  session, so a filter one run leaves applied is still applied for the
  next. Both specs start unfiltered and the filter is cleared as an
  asserted step.
- The grid caches pages client-side, so clearing a filter can re-render
  with no request at all. Counts that settle asynchronously are polled.

catalog/product/new is a 404 without an attribute set, so the spec
clicks Add Product rather than hardcoding the default set id, and the
url key is set explicitly so the storefront step is about url rewrites
rather than about how a name becomes a url.

The spec writes one product per run, with the sku generated inside the
test so a retry does not collide with what the failed attempt saved.
Both READMEs say so: an admin suite belongs against a throwaway store.
The reachability check reported a 404 and passed. It was

  curl --silent --show-error --fail --insecure --head "$BASE_URL" | head -1

and a GitHub `run:` without `shell: bash` runs under `bash -e` with no
`-o pipefail`, so the pipeline exited with head's status and discarded
curl's. The one step whose entire job is to say "nothing was served" said
the opposite, and nine specs then failed against a store that was never
there -- which reads as a broken suite rather than a broken environment.

The status is now captured and compared, so the step does not depend on
pipefail being set to mean what it says.

A failure diagnostics step comes with it. When this goes wrong the
containers leave with the runner, and nothing in the log says whether
nginx was up, whether Varnish had a backend, or whether Traefik had a
route at all -- a Traefik 404 and a Magento 404 are indistinguishable
from outside, and only one of them is about the store.
Every request to the store answered 404, with every container in the
environment reporting healthy and bin/magento working fine inside them.
The diagnostics step added alongside this says why:

  traefik | level=error msg="Failed to retrieve information of the
  docker client and server host: Error response from daemon: client
  version 1.24 is too old. Minimum supported API version is 1.40"

Warden 0.14.2 pins traefik:2.2, whose Docker provider speaks API 1.24,
and the Docker daemon on GitHub's current runner image refuses anything
below 1.40. Traefik therefore reads no container labels, builds no
routers, and returns its own 404 for every host -- which from outside is
indistinguishable from a Magento 404, and from the install's point of
view nothing is wrong at all.

The image tag is a variable in the compose file Warden ships
(traefik:${TRAEFIK_VERSION:-2.2}), so this sets it to 2.11, the tag
Warden itself moved to, rather than bumping the pinned Warden version:
0.16 also changes where the certificates are mounted, which is a larger
change than a broken proxy needs.
The traefik_version input is only legible with the failure beside it: a
proxy that cannot reach the Docker daemon reads, from outside, exactly
like a Magento that cannot find a route.
Two findings from the first CI run of this spec, both in the save path.

The sku was wrong in the store. Magento_Catalog/js/components/import-handler
mirrors the name into sku, url_key and the meta fields through the masks in
catalog/fields_masks, and stops only once a field has been edited by hand.
The name field is configured valueUpdate: 'keyup', which Playwright's
fill() does not emit, so the import runs on the name's change event --
which fires as focus leaves it, i.e. while the sku is being filled. The
two interleaved and the store ended up with a sku of "<name><sku>":

  Mage-OS E2E Product 1794623536178mageos-e2e-1794623536178

Every assertion passed anyway, because the grid's sku filter is a LIKE and
a contains assertion is satisfied by a superstring. So the spec reported
that it had created a product it had not created.

The name is now blurred before anything else is typed, the sku is filled
last, and three assertions make the trap unable to return quietly: the
field holds what was typed, the saved edit form reads it back, and the
grid cell equals the sku rather than containing it. Verified against the
database -- the row now reads mageos-e2e-<stamp> exactly.

The save was also flaky in CI: it timed out at 20s on the first attempt
and the retry passed in 10s end to end. The first product save on a
freshly installed store in developer mode pays for code generation along
the save path, then for the Update on Save indexers -- price, EAV, product
categories, catalog search -- which is work this gate wants happening. So
that one assertion gets 90s, and the test 180s.

It waits for the success *or* the error message, which is what keeps a
generous timeout honest: a save rejected by validation now fails in a
second showing the message, instead of in ninety with "element(s) not
found".
The ceiling of 90s was still not enough: in CI the first attempt ran 1.8
minutes and the retry passed in 14.9s. Three runs now put the first save
a store ever performs at 12s, 17s and over 90s, with every retry at
about 15s -- so the cost is the first save, not a slow runner.

Four minutes, then, with the test at five. High enough that runner
variance produces a slow pass rather than a red gate, and retries: 1
covers the rest. A failure on the retry as well would mean the save path
itself regressed, which is what the gate is for.

Waiting for the error message as well as the success one is what keeps a
ceiling this generous honest: a validation failure still reports in
about a second.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant