End-to-end tests for the Open edX Platform, written in Playwright and TypeScript.
The suite is deployment-agnostic: most providers should be able to point it at their own Open edX installation through configuration alone, without editing test code.
See ADR-0002 for the core principles
guiding development, and docs/findings.md for the defects
this suite has surfaced upstream — the reason behind every test.fixme() and
workaround in the tree.
-
Node.js 24 (see
.nvmrc). With nvm:nvm install nvm use
-
npm 11+ (ships with Node 24).
-
A reachable Open edX installation to test against (see Configuration).
-
The Open edX Demo Course imported on the target for the course-completion specs.** Those specs need real content to work through, and importing content differs per installation. So the course is a prerequisite of the environment rather than something the suite creates. Import one with whatever mechanism your installation uses — on Tutor:
tutor local do importdemocourse
then point
COURSE_KEYat the imported key (the demo course's key differs between installs, e.g.course-v1:OpenedX+DemoX+DemoCourse). LeaveCOURSE_KEYunset and the course-completion specs skip cleanly; set it to a course that doesn't exist on the server and pre-test checks will fail at startup.
# 1. Install dependencies
npm install
# 2. Install Playwright browser binaries (first run only)
npm run install:browsers
# 3. Create your local configuration
cp .env.example .env
# then edit .env to point at your installationAll installation-specific values are read from environment variables and validated
at load time — a missing or malformed value fails fast with a clear message
instead of a confusing test failure. Every variable is documented in
.env.example.
Configuration is only required by what actually drives an installation. Test
collection, --list and the node-only unit project report an invalid
environment as a warning and carry on, so a fresh clone with no .env can still
run the unit tests and the quality gates; browser projects fail on it fatally.
The essentials:
| Variable | Required | Description |
|---|---|---|
LMS_BASE_URL |
✅ | LMS origin, e.g. http://local.openedx.io |
APPS_BASE_URL |
✅ | MFE host origin, e.g. http://apps.local.openedx.io |
CMS_BASE_URL |
— | Studio origin; required when studio is in CAPABILITIES |
ADMIN_USERNAME / ADMIN_PASSWORD |
— | Admin/staff account (set both or neither); also grants author |
ORG |
— | Organization short code, e.g. OpenedX |
COURSE_KEY |
— | Course the course-completion specs work through; unset ⇒ they skip |
CAPABILITIES |
— | Comma-separated capabilities enabled on your install |
ALLOW_CROSS_SITE_ORIGINS |
— | Escape hatch for non-same-site deployments |
ACCOUNT_BACKEND |
— | How new accounts clear email activation (see below) |
CUSTOM_ACCOUNT_BACKEND_PLUGINS |
— | Comma-separated paths of custom account backends |
MAIL_PROVIDER |
— | Mailbox the suite reads e-mail from; required with email-inbox |
CUSTOM_MAIL_PROVIDER_PLUGINS |
— | Comma-separated paths of mailbox provider plugins (src/mail/) |
RUN_ID_SUFFIX |
— | Up to 3 lowercase letters/digits appended to the run id (CI shards) |
CI_PROFILE |
— | The CI profile a job runs (.ci/profiles.json); set by CI |
Where values come from. Configuration is read from process.env, with values
from a local .env file layered in underneath. Real environment variables take
precedence - a variable already set in your shell or CI environment is not
overridden by .env. In practice: use .env for local development, and set
environment variables directly in CI (no .env needed there). A .env value only
applies when that variable is not already present in the environment.
Capabilities. Coverage that depends on what an installation runs is gated on
an explicit declaration: a spec tagged @teams runs only where CAPABILITIES
names teams.
- Default-on: stock surfaces and stock settings are on unless turned off
with a
-prefix (CAPABILITIES=-mfe-authn), so a missing declaration never silently drops coverage an install has. - Required partners:
analytics-in-context,analytics-piiandanalytics-staff-video-countsdescribe parts of an Aspects deployment and needanalyticsdeclared too;analytics-in-context-cardsneedsanalytics-in-context. - Mutually exclusive pairs: two implementations or configurations of one surface. Declaring both halves fails validation.
- Declared but missing: a declared capability the target lacks fails its specs rather than skipping.
docs/capabilities.md describes every capability: what
it means, which specs it gates, which Open edX releases are known to support it,
and where CI turns it on.
Sign-in and sign-out coverage is not gated — it runs through the account
backend's own UI flows, whatever those are.
Origin requirements. All origins (LMS, Studio, MFEs) must share one scheme
(all http:// or all https://) and one registrable parent domain (e.g.
*.local.openedx.io), so a single sign-in covers every sub-domain. Do not use
localhost or IP addresses - they have no sub-domain structure for shared cookies.
Over HTTP, the target must serve SameSite=Lax, non-Secure cookies.
Installations differ in how a newly-registered account becomes able to sign in —
chiefly around email activation. Rather than assume one path, the suite obtains
accounts through a user-choosable backend selected by ACCOUNT_BACKEND, so the
same specs run against very different targets (see
issue #10 and
src/accounts/README.md).
ACCOUNT_BACKEND |
Use when… | Behaviour |
|---|---|---|
automatic |
"Automatic login on" — the default (incl. Tutor/sandbox) | Generates a throwaway @example.com identity; no email needed. The reusable session is taken from the one registration itself creates. |
manual |
Target enforces email activation and can't be reconfigured | Interactive: prompts you for an email to register with, then for the activation link/token. |
openinbox |
You want activation email automated, unattended | Example plugin (plugins/openinbox.plugin.ts): registers with a disposable openinbox.io inbox and visits the activation link it receives. |
| plugin name | Your install has its own auth/mailbox flow | Loaded from CUSTOM_ACCOUNT_BACKEND_PLUGINS; see src/accounts/README.md. |
The automatic backend works against the default without any email setup:
registration auto-authenticates the account, so the captured (reusable) session and
the registration spec pass even on installs that leave accounts inactive until
activation. Specs that drive a separate UI sign-in (login, logout) still
need an install where the account can actually log in — i.e.
SKIP_EMAIL_VALIDATION = True, or the manual backend to activate the account
first.
A 3rd-party mailbox API is covered by the openinbox example plugin rather than
a built-in backend; a plugin for any other mailbox service follows the same
contract. Plugins are currently local-only: the CI workflows expose no input for
CUSTOM_ACCOUNT_BACKEND_PLUGINS.
Open edX caps account registrations per IP with REGISTRATION_RATELIMIT, whose
default is 60/7d (60 per 7 days). Because each run creates several accounts,
frequent runs exhaust it and registration then fails with HTTP 403
forbidden-request — the suite reports this explicitly. For any target you run
against repeatedly, raise the limit to a short, self-resetting window (the
platform's own test settings use per-minute values). With Tutor, override the LMS
setting and restart:
# a Tutor plugin patch on "openedx-common-settings"
REGISTRATION_RATELIMIT = "100/m"tutor local restart lms # or: tutor dev restart lmsIf you have already tripped the default limit, either apply the override above and restart, or wait for the 7-day window to reset.
The password-reset request is throttled twice over, per IP and per email address:
| Setting | Default | Scope |
|---|---|---|
PASSWORD_RESET_IP_RATE |
1/m |
one request per minute per IP |
PASSWORD_RESET_EMAIL_RATE |
2/h |
two requests per hour per email |
The per-IP limit is the one that bites: one request per minute covers every
worker on the machine, so a second reset request within the same minute — a
re-run, a Playwright retry, or another spec — is rejected. POST /account/password
then answers HTTP 403 with:
{
"success": false,
"value": "Your previous request is in progress, please try again in a few moments."
}Note that message is misleading: nothing is in progress, the request was throttled. The authn MFE renders it as a generic error alert, so the symptom is a password-reset spec that passes alone and fails when re-run within the minute.
Only the reset-request test posts (an invalid address is rejected in the browser without a request), so a single run is fine; back-to-back runs and CI retries are not. Raise the limit on any target you run against repeatedly:
# a Tutor plugin patch on "openedx-common-settings"
PASSWORD_RESET_IP_RATE = "100/m"
PASSWORD_RESET_EMAIL_RATE = "100/m"tutor local restart lms # or: tutor dev restart lmsSet ACCOUNT_BACKEND=manual and run with a single worker so the interactive
prompts don't interleave:
ACCOUNT_BACKEND=manual npx playwright test --project=smoke --workers=1 \
-g "signs in with valid credentials"For each account the suite provisions, it prompts on your terminal to:
- enter an email to register with — use an inbox you can read;
plus-addressing (
you+e2e1@example.com) lets one inbox serve several runs; and - paste the activation link or token from the resulting email (either the
full
.../activate/<key>URL or just the key).
The prompt reads your controlling terminal directly (via /dev/tty) and disables
the test timeout while it waits, so an interactive run won't time out. Each
provisioned account needs a unique email, so specs that provision their own
account (login, logout, and the setup sign-in) prompt once each.
Getting the activation key without email (Tutor). If you administer the target and would rather not wait on (or configure) email, read the pending registrations' activation keys straight from the database and paste the one matching the email you registered with:
tutor local run lms ./manage.py lms shell -c "from common.djangoapps.student.models import Registration; rs = [ (r.user.email, r.activation_key) for r in Registration.objects.select_related('user').all()]; print(rs);"Use tutor dev run on a dev stack. The prompt accepts either the bare key or the
full .../activate/<key> link.
npm test # run everything
npm run test:smoke # critical-path (@smoke) tier only
npm run test:regression # broader (@regression) tier
npm run test:ui # interactive Playwright UI mode
npm run test:headed # headed browser
npm run test:debug # step-through debugger
npm run report # open the last HTML reportTests are organized into Playwright projects:
unit— pure logic tests (e.g. config validation); no browser or target needed.setup— signs in once per role and writes reusable auth state to.auth/.smoke/regression— anonymous browser tests tagged@smoke/@regression.lms-learner— authenticated tests (@authenticated); depends onsetup. The project loads the captured learner session; specs that change course state use thecourseLearnerfixture instead, which provisions a fresh learner per test (one registration each — see the rate limit below).studio-author— Studio tests (@author, thetests/studio/tree); depends onsetup. Each worker provisions an author of its own (see "Studio coverage") whose session is valid on Studio as well as the LMS. Runs only when thestudiocapability is declared.
Responsive coverage runs at the sizes in src/config/viewports.ts
(phone 375, tablet 768, small desktop 1024, desktop 1280, each on one side of a
breakpoint the Open edX frontends switch on) inside the same projects; there is
no separate mobile project.
On a Tutor main target, two toggles that plugins set only in FEATURES must
also be set as flat settings for their coverage to run (the TUTOR-001
flattening): ENABLE_COURSE_DISCOVERY for catalog-search, and
ENABLE_EDXNOTES for notes. The CI workflow's settings patch sets both.
Run a single project or filter by tag:
npx playwright test --project=unit # just the unit tests
npx playwright test --project=smoke
npx playwright test --grep @smokeThe unit project needs no configuration or browsers, so you can run it right
after npm install:
npx playwright test --project=unitStudio specs run when CAPABILITIES includes studio and CMS_BASE_URL is set
(declaring one without the other fails configuration). Leave studio undeclared
on an LMS-only target and the whole tests/studio/ tree skips.
The author role. The setup project provisions a fresh account, gives it a
Studio session (Studio keeps its own session behind a silent OAuth handshake with
the LMS — no second sign-in, no credentials; an install that fronts Studio with
its own IdP replaces this through the account backend's signInStudio), and
grants it course-creator status. The studio-author project then provisions
one such author per worker (workerAuthor) and runs that worker's tests as
it: the platform's PREVENT_CONCURRENT_LOGINS (on by default) ends a user's
other sessions on every sign-in, and the browser specs sign the author in through
the UI per test, so a single shared author would have each worker logging the
others out mid-test. The admin is the one account that stays shared, so anything
that signs in as the admin (the course-creator grant, the superuser-in-a-browser
fixtures) runs under a cross-worker lock and reuses the setup session where it
can — which also keeps admin sign-ins clear of the platform's per-account
login rate limit (LOGISTRATION_PER_EMAIL_RATELIMIT_RATE, default 30/5m;
exceeded, sign-in answers 400 "Too many failed login attempts"). Repeated local
runs within five minutes can still reach it; wait it out or raise the setting.
On a default install (ENABLE_CREATOR_GROUP on) the only grant path is the Studio
Django admin, so the default account backend signs in as ADMIN_USERNAME /
ADMIN_PASSWORD (a superuser) to approve the request. Without an admin account
the author role — and every Studio spec — skips, unless the install grants every
user (then no admin is needed). Installs that gate course creation differently
implement grantCourseCreator in an account backend plugin
(src/accounts/README.md).
Courses the suite creates. There is no API to delete a course, so the suite
keeps the count low: the settings specs share one course per worker
(authoredCourse), the authoring-to-learner round-trip specs share two more
per worker (contentCourse and futureCourse, with authoringCourse giving a
per-test course only where a spec needs isolation), the instructor-dashboard
certificate specs share one more (certificateCourse, set up so certificates
can be issued), and each builds a uniquely named section inside its course
rather than a new course. Only the specs whose
subject is course creation make their own. Content libraries accumulate the
same way: DELETE /api/libraries/v2/<lib>/ answers 500 for any library that
ever held a unit, subsection or section (LIB-001, filed as
edx-platform#39117),
so the library specs seed
one small library per test (seededLibrary, plus an empty authoringLibrary
where a spec mutates), attempt the delete, and rely on run-unique slugs
(e2e-<run id>-…) to keep runs apart; every list they select a library from is
filtered by that slug or title first. Every suite course is numbered E2E<run id><slot> under ORG (or
E2E when ORG is unset). On a persistent target, purge them with the CMS
management command — it prompts, so pipe yes into it:
yes | tutor local exec cms ./manage.py cms delete_course <course key>then ./manage.py cms reindex_course --all --setup if deleted courses still show
in catalog search.
There is no equivalent for v2 libraries today. The CMS offers
export_content_library, import_content_library and
migrate_course_legacy_library_blocks_to_item_bank, but nothing that deletes a
v2 library, and LIB-001 blocks the per-library API for any library that held a
container — so the libraries the suite seeds stay on a persistent target. The
legacy half is different: ./manage.py cms delete_v1_libraries (run it with
--help for its arguments) covers the library-v1: libraries the
legacyLibrary fixture creates.
CI's Tutor installs are ephemeral, so nothing accumulates there.
npm run check # typecheck + lint + format:check (run before pushing)
npm run typecheck # tsc --noEmit (strict; catches what Playwright's transpile won't)
npm run lint # ESLint
npm run lint:fix # ESLint with autofix
npm run format # Prettier write
npm run format:check # Prettier checkBrowser specs need a running Open edX installation, so they run in dedicated
GitHub Actions workflows rather than the fast PR gate (ci.yml, which runs
static checks and the browser-free unit project as required, blocking checks).
Both workflows share the same run-suite
composite action, which runs the suite (or one shard of it) and uploads a
Playwright blob report, and the report-suite
action, which merges blob reports (merge.config.ts) into the HTML report and
suite reports, writes the job summary and publishes BTR results. They differ
only in how the target installation is provisioned.
ci.yml additionally runs run_tests_tutor.yml twice every PR and push to
main: once against an ephemeral Tutor main, and once against the last
named release environment (currently verawood). Each runs the full suite
under the default and extended CI profiles (see CI profiles and shards
below), so a PR's result covers everything the scheduled runs do.
Stands up a fresh Tutor "local" Open edX install on the runner (installing the named release's Tutor/plugin versions, launching it, importing the demo course, and creating an admin user), then runs the suite against it. Everything is provisioned from scratch, so this is a heavier, self-contained job — nothing to configure beforehand beyond triggering it.
Triggers:
-
workflow_dispatch— run on demand with:Input Description openedx_releaseNamed release ( main,verawood,ulmo,teak,sumac,redwood); drives the Tutor/plugin version and capabilities. Defaultverawood.test_refGit ref of this repo to test. Defaults to the branch the workflow runs from. domainsSpace-separated domains to run (e.g. lms studio). Empty = all.featuresSpace-separated tag filter (e.g. @smoke @discussions). Empty = all.exclude_featuresSpace-separated tags to exclude (mapped to --grep-invert, e.g.@unit). Empty = exclude nothing.capabilitiesOverride the release's default capabilities (comma-separated); each profile's delta still applies. Empty = use the release default from .ci/openedx-releases.json.profilesSpace-separated CI profiles to run (keys of .ci/profiles.json, see below), e.g.default extended. Defaultdefault.btr_sheet_urlOverride: publish BTR results to this Google Sheet instead of the release's BTR_SHEET_URL_<RELEASE>variable (see BTR results sheets). -
schedule— automatically at 09:00 UTC (5am US Eastern in daylight time), Mondays and Fridays, always against this repo'smainbranch with themainOpen edX release (domains/features/capabilitiesare dispatch-only and don't apply to scheduled runs, so scheduled runs cover the full suite withmain's default capabilities). -
workflow_call— called byci.yml'sdocker-mainanddocker-last-releasejobs (see above); same inputs asworkflow_dispatch.
The Tutor install also runs a Mailpit catcher
(a local-docker-compose-services patch, with SMTP_HOST=mailpit and
RUN_SMTP=false), so the platform's mail lands where the mailpit provider
reads it and email-inbox is declared for main and verawood. Tutor's own
relay delivers straight to recipients' mail servers on port 25, which
GitHub-hosted runners block. To run the e-mail specs against a local Tutor,
do the same there (the tutor-contrib-mailpit plugin targets tutor dev
only, so copy its service into a local-docker-compose-services patch) and
set MAIL_PROVIDER=mailpit, CUSTOM_MAIL_PROVIDER_PLUGINS=./plugins/mailpit.plugin.ts
and MAILPIT_BASE_URL=http://localhost:8025.
CI profiles and shards. .ci/profiles.json defines
the named configurations a release can run under. Each profile lists the Tutor
plugin files that configure the install (.ci/tutor/e2e_base.py holds the
settings every profile starts from), a capability delta applied to the
release's list, and a shard count. A plan job expands the selected profiles
into one run job per shard. Each provisions its own Tutor install, so
shards never share users, courses or site settings, and runs its slice of the
suite with --shard. A merge job then combines every shard's blob report into
the release's single playwright-report-<release> and suite-reports-<release>
artifacts. scripts/ci-profiles.mts reads the file, and the unit project
validates it. Each shard's run id carries a RUN_ID_SUFFIX (profile code +
shard number), so data created by different shards never collides.
Three profiles exist, and ci.yml runs all three for main and verawood:
| Profile | Runs | Installation |
|---|---|---|
default |
the whole suite, in 4 shards | .ci/tutor/e2e_base.py |
extended |
only the cases default skips (select: "delta") |
plus .ci/tutor/e2e_extended.py (AuthZ migration left to an operator, a SUPPORT_URL), .ci/seed/extended.sh (a second-organization course, an intro video on the demo course) and, where the release has the plugin, tutor-contrib-codejail for Python-graded problems |
aspects |
only the analytics cases (select: "delta"), on main and verawood |
plus tutor-contrib-aspects (a line per release in the profile: 6.x on main, frontend-base 2; 5.1+ on verawood, frontend-base 1) and .ci/tutor/e2e_aspects.py (learner PII and in-context metrics on, Superset reachable from the LMS container), on its own prebuilt image variant |
A select: "delta" profile runs only the tests tagged with a capability it
declares and default does not. The merge then reports those tests from it and
everything else from default. rbac-global is not in extended: turning the
authz flag on for the whole site locks every unmigrated course's team out of
Studio, which the other cases need. It would need a profile of its own.
A profile can declare an image variant (images): build_tutor_main_images.yml
builds it nightly for each release the profile runs on, with the profile's Tutor
plugins and extensions enabled, and pushes it under its own tags and BuildKit
caches (<image>:<release>-<variant>). The aspects variant is the Open edX,
MFE, Aspects and Superset images. A variant job pulls it; if the pull fails it
stops (a variant cannot be rebuilt on the runner, where the profile's plugins
are enabled only after launch), so run the build workflow and retry.
The MySQL state after migrations is cached per release/Tutor-version so most
runs skip the ~20-minute migration step; delete the tutor-mysql-* cache from
the Actions UI if it ever needs a clean rebuild.
Per-release configuration. .ci/openedx-releases.json
is the single source of truth for what each named release needs: the Tutor
version constraint (replacing what used to be a hardcoded shell case
statement) and the default CAPABILITIES to declare for that release, since
feature/MFE availability can differ across platform versions. Adding or
removing a supported release is just an edit to that file (plus the matching
openedx_release dropdown option in the workflow — ci.yml's "Verify
openedx_release choices match .ci/openedx-releases.json" step fails the build
if the two ever drift apart).
Runs the suite against any installation you already have running — a Tutor
dev stack, a staging server, or a provider's own environment — instead of
provisioning one. This is how providers point the suite at their own
installation without editing any code.
Credentials are sourced from a GitHub Environment (Settings → Environments) rather than hard-coded, so different targets (and their approval/protection rules) stay isolated from each other:
| Input | Description |
|---|---|
environment (required) |
Name of the GitHub Environment to source ADMIN_USERNAME/ADMIN_PASSWORD secrets from. |
test_ref |
Git ref of this repo to test. Defaults to the branch the workflow runs from. |
lms_base_url (required) |
LMS origin, e.g. https://courses.example.com. |
apps_base_url (required) |
MFE host origin, e.g. https://apps.example.com. |
cms_base_url |
Studio origin. Leave empty to skip Studio specs. |
org |
Organization short code. |
course_key |
Default course for course-completion specs. |
capabilities |
Comma-separated capabilities enabled on the target. |
account_backend |
automatic (default; works on the default install, see above) or manual (interactive — not usable in CI). |
allow_cross_site_origins |
Set when LMS/Studio/MFE origins are not same-site. |
mail_provider |
none (default) or openinbox: where the target's notification e-mail is read. openinbox needs an OPENINBOX_API_KEY Environment secret; add email-inbox to capabilities. |
domains / features |
Same filters as above. |
exclude_features |
Space-separated tags to exclude (mapped to --grep-invert, e.g. @unit). Empty = exclude nothing. |
openedx_release |
Release the target runs (a key of .ci/openedx-releases.json). Enables publishing to that release's BTR results sheet. Empty = no publish. |
btr_sheet_url |
Override sheet URL for this run; requires openedx_release. |
To run it against your own installation: create a GitHub Environment (e.g.
staging) with ADMIN_USERNAME/ADMIN_PASSWORD secrets (and any required
approval rule), then trigger the workflow with that environment name and your
target's base URLs.
Every run writes test-results/btr-run.json: one row per BTR Release Test Plan
case exercised (spec, result, notes, timing) plus the run's metadata. Scheduled
and manually dispatched runs can publish that file to a Google Sheet, one
spreadsheet per Open edX release, kept separate from the manually maintained
BTR sheet. Each publish overwrites the sheet's Latest tab (for an unfiltered
run of the default branch), appends a row to the Runs index, and adds a
timestamped tab that is kept for comparison over time. PR and push runs never
publish.
To enable it for a release, in the repository (or a fork, or a provider's deployment):
- Create a Google Cloud service account, download a JSON key, and store the
key's text as the
BTR_SHEET_CREDENTIALSsecret (repository-wide, or on the GitHub Environment an external target uses). - Create an empty Google Sheet per release and share each one with the
service account's
client_emailas an Editor. - Set a repository variable
BTR_SHEET_URL_<RELEASE>per sheet, with the release upper-cased:BTR_SHEET_URL_MAIN,BTR_SHEET_URL_VERAWOOD, … An Environment-scoped variable of the same name overrides the repository one forrun_tests_external.ymlruns.
The credentials always come from that secret — no workflow or action input passes them in. A repository without it (a fork, say) still runs the suite: the publish step warns and skips, leaving the run's result unchanged.
The first publish bootstraps the sheet (tabs, headers, title). A sheet
remembers its release and refuses runs for another one, so a mis-set variable
cannot mix releases. ci.yml warns (without failing) about releases that have
no variable. Details and the local re-publish command:
src/reporting/README.md.
The two workflows above are how this repository runs the suite. A deployment pipeline that owns an Open edX installation should not reimplement them - it should reuse the same runnable unit and supply only the target.
- GitHub Actions: use the
run-suitecomposite action. - GitLab CI: include
ci/gitlab/run-suite.ymland extend the hidden.openedx-e2ejob it defines. That file's header comment is the reference for the variables it accepts, andci/gitlab/README.mdhas worked examples - gating on a deploy, waiting for the target to serve, serialising runs - plus what to check on the installation under test.
Both own the same thing - the image, the install, the Playwright command line,
and the report artifacts - so the consuming pipeline is left with the target's
origins, its readiness check, and its credentials. Their inputs correspond:
domains / features / exclude_features / select_grep / shard on the
action are E2E_DOMAINS / E2E_FEATURES / E2E_EXCLUDE_FEATURES /
E2E_SELECT_GREP / E2E_SHARD in the template, and adding one to either
generally means adding it to both. Inputs that
only set an environment variable (mail_provider, profile, run_id_suffix, …)
need nothing in the template: a consuming job sets them as variables:.
include:
- remote: 'https://raw.githubusercontent.com/openedx/end-to-end-tests/<ref>/ci/gitlab/run-suite.yml'
e2e:
extends: .openedx-e2e
stage: test
variables:
E2E_SUITE_REF: <commit-sha> # required; keep equal to the include ref
LMS_BASE_URL: https://courses.example.com
APPS_BASE_URL: https://apps.example.comThe suite itself is configured by its ordinary environment variables, set as
variables: on that job - so .env.example stays the single reference for what
those are, and a new one works in GitLab the day it works locally. Put secrets
in masked CI variables rather than variables: values, which are committed in
plain text.
Set PLAYWRIGHT_JUNIT_OUTPUT_FILE in any CI system to have the suite append a
JUnit reporter to the ones playwright.config.ts declares. Do not pass
--reporter=junit: that replaces the whole list and silently disables the
BTR-coverage and accessibility reporters.
tests/ # specs, grouped by platform domain (lms/, studio/)
lms/auth/ # authn + account MFEs: registration, login, logout, session,
# password reset, profile
lms/catalog/ # catalog MFE: discovery, enrollment
lms/course-home/ # learning MFE: outline, progress, unit/course completion
lms/courseware/ # learning MFE: outline sidebar
lms/dashboard/ # learner dashboard
lms/instructor/ # instructor dashboard MFE: course info, enrollments, grading,
# date extensions, data downloads, certificates
lms/landing.spec.ts # proof-of-life smoke test
studio/ # authoring MFE + Studio APIs (bootstrap: author session, course factory)
conventions/ # suite-wide rules enforced as tests (no displayed text)
config/ api/ auth/ accounts/ a11y/ reporting/ # unit tests (@unit) per module
auth.setup.ts # auth setup project
global-setup.ts # clears .auth/, preloads account backends
src/
config/ # typed, validated environment configuration
selectors/ # structural anchors per surface, one module each
api/ # typed API clients + data factories
accounts/ # user-choosable account backends (+ plugin loader)
auth/ # provider-swappable authentication contract
pages/{lms,studio}/ # page objects, one per surface, in the spec's domain folder
steps/ # reusable multi-page business flows
fixtures/ # composition root (config + pages + api + auth + skips)
assets/ # bundled media served in place of course-hosted sources
reporting/ # BTR test_id annotations, coverage + run-detail + a11y reporters
a11y/ # @axe-core/playwright WCAG 2.2 AA gate
plugins/ # example account-backend plugin (openinbox)
.ci/ # CI configuration: releases, profiles, Tutor plugins, seeds
docs/
decisions/ # ADRs
capabilities.md # every capability: meaning, releases, CI declarations
findings.md # defects the suite has surfaced
The layers have a strict dependency direction —
config → api → pages → accounts → {auth, steps} → fixtures → tests — with each
layer's responsibility described in its own README.md under src/. See
ARCHITECTURE.md (layer diagram and responsibilities) and
CONVENTIONS.md (locator priority, tagging, test-data rules)
for the full mechanics.
Start with DEVELOPER.md, the condensed guide to writing a
well-formed spec, with links into the detailed documents.
- Write TypeScript under strict settings;
npm run checkmust pass. - Keep configuration centralized in
src/config/— never readprocess.envdirectly in specs. - Never commit a real
.envor captured auth state (both are gitignored). - When a change works around an upstream defect, or skips a case because of one,
record it in
docs/findings.mdand quote its ID in thetest.fixme()reason or the comment. Add the entry in the pull request that needs it, so a reviewer can see what the branch worked around and why.