This guide explains how Gameplan is tested, what belongs in each layer, and how to run and extend the tests.
Each layer owns a different kind of check. Put a test in the layer that owns it.
| Layer | What it runs on | Owns |
|---|---|---|
| Backend (Frappe integration tests) | Real database | Business rules, permissions, edge cases, API contracts |
| Cypress E2E | Real browser + backend | User journeys, one happy path per feature, UI/routing/mobile behavior |
| Vitest (planned, not set up yet) | Node | Pure frontend logic only (no DOM, no network) |
Frontend coverage is collected from the Cypress layer, not from Vitest — see
§ 2 "Coverage". There is still no unit-test runner; src/utils and src/composables
together are ~630 of 27k frontend lines, which is why one has not earned its keep.
The rule:
- Permission matrices and edge cases never go in E2E. They live in the backend suite, where they are fast, exhaustive, and run against the real database.
- E2E covers the happy path a user actually walks through. If an E2E test would need a permission check or an edge case to be correct, that check belongs in the backend instead. Punting a flaky E2E detail down to a backend test is by design, not a workaround.
Backend tests are Frappe integration tests. They create data, act as a user, and assert against the real database, rolling everything back after each test.
gameplan/tests/
base.py # GameplanTestCase: personas + permission assertions
fixtures.py # builders (create_community, create_space, ...)
features/ # per-feature behavior tests
permissions/ # the permission matrix and permission tests
platform/ # cross-cutting platform tests (search, migrations, ...)
GameplanTestCase creates the standard cast fresh in every test and rolls it back in
tearDown. Each is available on self.
| Persona | Role | Represents | |
|---|---|---|---|
admin |
admin@example.com | Gameplan Admin | Global admin |
member |
member@example.com | Gameplan Member | Owner of the test content |
second_member |
member2@example.com | Gameplan Member | Another member in the community |
guest |
guest@example.com | Gameplan Guest | Guest with access only where granted |
outsider |
outsider@example.com | Gameplan Member | A member outside every community/space |
Subclass it, use the personas on self, run code as a user with as_user, and assert
permissions with assert_allowed / assert_not_allowed.
from gameplan.tests.base import GameplanTestCase
from gameplan.tests.fixtures import create_community, create_space, create_discussion
class TestExample(GameplanTestCase):
def setUp(self):
super().setUp()
self.community = create_community("Acme", members=[self.member])
self.space = create_space("Engineering", self.community)
def test_member_can_edit_own_discussion(self):
discussion = create_discussion("Hello", self.space, owner=self.member)
# Run a block as a specific persona
with self.as_user(self.member):
...
# Assert who may do what
self.assert_allowed(discussion, "write", self.member)
self.assert_not_allowed(discussion, "delete", self.outsider)Builders use product language (Community/Space), which maps to the old schema names
(GP Team = Community, GP Project = Space). Each accepts either a doc or a name
wherever a linked record is expected.
create_user,create_admin,create_member,create_guestcreate_community(GP Team) andcreate_space(GP Project)create_discussion,create_comment,create_task,create_page,create_pollgrant_guest_accessset_owner(re-own a doc after insert, so content can belong to a persona)
permissions/test_permission_matrix.py is the executable spec of Gameplan's
permission model. A single table (EXPECTATIONS) maps space kind → actor →
(read, write, delete), and the test walks every content doctype for every actor and
action. When the rules change, change the table.
To extend it:
- New doctype — build it in the world in
setUp(add it to theself.contentdict), and the matrix will assert it for every actor automatically. - New actor or space — add rows/blocks to
EXPECTATIONSwith the expected(read, write, delete)tuple.
The content doctypes model three tiers, and it matters which one a check is about:
- read = view. Can the user see this content's space?
- write = interact. Anyone who can reach a space — members and granted guests
alike — holds
writeon its content.writemeans "may participate" (react, comment), not "may edit anything". This is why the matrix shows guestwriteasTrueon member-owned content, and whyreact()'s plainsave()succeeds for a guest. The gate lives inpermissions.content_has_permission's write branch: for a non-editor it allows the save only when nothing beyond interaction-safe fields (thereactionschild table) changed. That helper (_protected_fields_changed) must work in two contexts — a clean doc (the permission gate / UI checks report "no changes" so guests pass) and a dirty in-memory doc at save time (it reads the stored row and diffs to catch a real edit) — so it never relies onhas_value_changed()/get_doc_before_save()alone. - edit-others and delete = business rules. Editing someone else's content is
blocked at save time by the write branch above (via
can_edit_content); deleting is gated bycan_delete_content. These are the genuinely restricted actions.
Guests are participants, not read-only viewers. In a space they've been granted
access to, a guest can edit their own content, delete their own content, react
to any post or comment, comment on discussions, create and vote in polls, and record
space visits. A guest cannot edit or delete anyone else's content, and gets
nothing at all outside the spaces they were granted. The full spec lives in
features/test_guest_participation.py, features/test_polls.py, and
features/test_spaces.py.
A red test means one side is wrong — the test or the code — and you decide which against product intent, not against whichever is easier to change. If the code violates the intended behavior, fix the code; never bend a passing assertion onto buggy behavior just to go green. Only update a test when the intended behavior itself changed (cite the decision in the commit message, as the guest policy above did).
Always run against the local demo site, never a dev site with real data.
# Full backend suite
bench --site gameplan-demo.test run-tests --app gameplan
# Just the permission matrix
bench --site gameplan-demo.test run-tests --module gameplan.tests.permissions.test_permission_matrixBoth layers are measured, and both are informational — no minimum is enforced
and no check fails on coverage. Both emit Cobertura XML, so one script renders both;
--profile picks the denominator, area map and caveat.
# Backend — writes sites/coverage.xml
# (needs coverage in the bench env: env/bin/pip install coverage)
bench --site gameplan-demo.test run-tests --app gameplan --coverage
python .github/scripts/coverage_report.py ~/benches/frappe-bench/sites/coverage.xml \
--profile backend
# Frontend — needs an instrumented build, then the usual Cypress run
cd frontend && GAMEPLAN_COVERAGE=1 yarn build # from the bench: GAMEPLAN_COVERAGE=1 bench build --app gameplan
yarn test
yarn coverage:merge
python ../.github/scripts/coverage_report.py coverage/cobertura-coverage.xml \
--profile frontendThe backend number comes from unit and integration tests that assert on what they execute. The frontend number comes from Cypress driving an instrumented build, so a line counts as covered when it ran — a happy-path spec marks everything it renders past, asserted or not. Read the frontend number to find untouched areas, not as a quality score, and do not read the gap between the two as a quality gap. The report footer says so on every frontend comment.
coverage_report.py measures product code only. For the backend this matters a
lot: frappe.coverage points coverage at the whole app directory, so an unfiltered
report counts the test suite itself — ~100% covered by construction — and inflates
the headline (87.6% unfiltered against 83.7% real, when this was written). Each
profile's excluded tuple is the filter, and the report footer always names what it
dropped.
The frontend has the mirror-image trap. 33 routes are lazy () => import(...), so a
page no spec visits never registers in window.__coverage__ and would drop out of
the report entirely — flattering the percentage by shrinking the denominator rather
than counting the file as 0%. frontend/vite.config.ts therefore writes a zeroed
baseline of every instrumented file at build time, and yarn coverage:merge merges
it under the runtime data. Untouched files count as 0%, which is the honest number.
GAMEPLAN_COVERAGE=1 is what enables instrumentation. Only ui-test.yml sets it:
it roughly doubles bundle size and slows every expression, so no production build
should ever carry it.
One more trap, specific to CI. bench get-app clones the repo into the bench
rather than symlinking it, so the instrumented build writes its baseline under
~/frappe-bench/apps/gameplan/frontend while Cypress writes .nyc_output in the
workspace checkout — two trees. The recorded coverage paths belong to the bench
tree, and nyc silently discards every entry outside its own cwd, so merging from
the workspace yields 0/0 and a report with nothing in it rather than an error.
COVERAGE_ROOT (consumed by coverage:merge) points nyc at the tree the paths
name. Locally it is unset and everything is one tree, which is exactly why this
only ever breaks in CI.
Both consumers are self-contained — no Codecov, no shields.io:
-
Pull requests get exactly one Test report comment, plus the same content in each job summary. Server Tests and UI Tests finish at different times and each knows only half the story, so
.github/scripts/pr_comment.pygives each a named section of one comment rather than a comment each. Every workflow rewrites only its own section.The comment is written to be skimmed: one status line per layer, the failing tests named in full underneath it, and everything that passed folded into a
<details>. Nobody should have to expand anything to learn whether the run is red or which test broke.Two workflows editing one comment can race — the API has no compare-and-swap — so each writer verifies its section survived and re-applies it if not. Both writers doing this is what makes the comment converge.
Fork PRs are served by the
*-report.ymlcompanions, which run in the trusted base repo. Those jobs resolve which PR to comment on from theworkflow_runevent'shead_shaandhead_repositoryvia the API — never from an uploaded artifact, which a fork controls and could point at any PR in the repo. A commit can belong to several open PRs, so the match must be exactly one or the job comments nowhere.Those jobs also parse artifacts the fork produced — both coverage XML and JUnit XML — so every read goes through
.github/scripts/safe_xml.py, which caps size per document and across a directory and refuses any document declaring XML entities (a few nested ones expand to gigabytes). It is shared rather than reimplemented per renderer precisely because a guard only one of them remembers to apply is not a guard. Note it asks expat rather than scanning bytes for<!ENTITY: XML declares its own encoding, so the same declaration in UTF-16 shares no bytes with an ASCII needle and would slip straight past. -
The README badges are
coverage.svgon the orphanbadges-backendandbadges-frontendbranches, linked by raw URL.server-tests.ymlandui-test.ymlrepublish them on pushes todevelop, and only when the rendered SVG actually changed, via.github/scripts/publish_badge.sh. They are kept offdevelopon purpose: a commit per coverage change is noise in the history people read. Each branch is force-pushed to a single commit, so neither accumulates, and they are separate branches because both workflows fire on the same push and a shared branch would have them clobbering each other. The badges therefore always showdevelop, not the branch you are reading.Publishing lives in a separate
badgejob in each workflow, gated on a push todevelopand holding the onlycontents: writein the file. The test job stayscontents: readand hands the rendered SVG over as an artifact, because it checks out and executes pull-request code —install.sh, the frontend build, the specs — and a repo-write token there would be a write credential handed to code under review. Note that a job-levelpermissions:block replaces the workflow-level one rather than adding to it, so a job needing both has to say both.The badge jobs are deliberately not
continue-on-error. The first version was, and it reported success while a 403 published nothing, leaving the README badge 404ing with no signal anywhere. By the time they run the suite has passed, so a red run blocks nobody and is the only thing that says the badge is stale.
E2E specs drive a real browser against a real backend. They cover the happy path a user walks through, one path per feature.
Specs live in frontend/cypress/e2e/, grouped by area:
journeys/ # end-to-end smoke journeys (the smoke suite)
discussions/
comments/
polls/
spaces/
communities/
members/
tasks/
pages/
search/
notifications/
profile/
shell/
mobile/
gameplan/ui_test_helpers.py deletes every Gameplan row and every framework User
except the personas. Bench installs an app by putting its repo directory on sys.path,
so that file exists on every server that runs Gameplan and packaging cannot exclude it.
It is therefore gated at runtime, three ways, and all three must hold or the seed
call throws and every spec fails in its beforeEach:
| Gate | Where it comes from | How to satisfy it locally |
|---|---|---|
Test mode (frappe.tests.utils.whitelist_for_tests) |
test run, dev server + allow_tests, or CI |
allow_tests: 1 in site_config.json and a dev server (see below) |
enable_ui_tests |
site_config.json, read with cint |
enable_ui_tests: 1 — a number or JSON boolean, not the quoted string "true", which cint reads as 0 |
| System Manager | frappe.only_for |
nothing to do: Cypress seeds as Administrator, whom only_for waves through |
The dev-server part is the one that bites. frappe._dev_server is read from the
DEV_SERVER environment variable, which bench start sets and a bare
bench --site … serve does not. So start the local Cypress target as either:
bench start # sets DEV_SERVER itself
DEV_SERVER=1 bench --site gameplan-demo.test serve --port 8002 # standalone serverCI needs no special handling: GitHub Actions exports CI, the workflow runs
bench start, and .github/helper/site_config.json carries both allow_tests and
enable_ui_tests.
create_invitation will only mint Gameplan Member and Gameplan Guest invitations.
gameplan.api.accept_invitation is allow_guest and GET-reachable and appends the
invitation's role to the accepting user, so an unrestricted role here would let anyone
grant themselves Gameplan Admin.
Reset and seed data with resetData(scenario) from cypress/support/seed.ts. One call
proves the responding site is the one baseUrl names, logs in as Administrator, wipes
all Gameplan data, resets the persona users, and seeds the scenario. It yields the ids
of the seeded records.
The site check is the assertConfiguredSite Node task in cypress.config.ts: it asks
the server for its own site name rather than trusting the URL, so host aliasing, a
--site-pinned server or a stale default_site cannot silently route the wipe at a
site with real data. It is memoized per origin, so it costs one request per run.
import { resetData } from "../support/seed";
resetData("space_with_discussion").then(({ space, discussion }) => {
// ... use the seeded ids
});
cy.loginAs("member");Every scenario's community includes member and member2, so both personas can reach it
without any join step. Content is created as its owner, which also settles unread state:
a discussion is unread for every space member except the person who wrote it.
| Scenario | Seeds | Returned ids |
|---|---|---|
onboarded |
Community "Acme" with an auto-created General space | community, space |
space_with_discussion |
Public space "Engineering" with a discussion "Welcome thread" by member; General is left empty |
community, space, general_space, discussion, discussion_slug |
private_space_with_guest |
Private space "Secret Plans" (member is a space member, guest has access) plus a public discussion in General; both mention "roadmap" | community, space, private_space, discussion, public_discussion |
search_page |
Six "roadmap" results across two communities, spaces, authors, doctypes, and tags for exercising every search filter | community, space, discussion |
two_communities |
Communities "Alpha"/"Beta", one public space each | communities (2), spaces (2) |
unread_discussion |
Community "Acme" with General + "Product"; a discussion in General written by member2, so it is unread for member |
community, space, second_space, discussion |
For a search test, call gameplan.ui_test_helpers.rebuild_search_index after seeding — the
SQLite index is built on demand, not by the seed.
Log in as a persona with cy.loginAs(persona) where persona is one of admin,
member, secondMember, guest, outsider (all seeded with password admin).
Switching personas mid-test: once a page has been loaded, use
cy.switchUser(persona) instead of cy.loginAs. Frappe re-sets the sid cookie on
every response for the session that request ran under
(CookieManager.init_cookies), so a request the old page still has in flight can land
after the login and hand the previous user's cookie back — the next cy.visit then
boots as the old user and the test fails on whatever it asserts about the new one.
switchUser unloads the page first (aborting those requests) and only continues once
the server names the acting user.
cy.request shares the browser's cookie jar, so it always acts as whoever the open
page is logged in as. When a spec needs someone else to change something while the
page under test stays loaded, use the requestAsUser Node task
(cypress.config.ts), which logs in and calls the API in a session of its own:
cy.task("requestAsUser", {
user: "member@example.com",
path: `/api/v2/document/GP Discussion/${discussion}/method/close_discussion`,
});discussions/realtime-activity.cy.ts uses it for the live-update path.
This is the source of truth for Cypress site/port routing:
- Local:
frontend/cypress.config.tsdefaults tohttp://gameplan-demo.test:8002. Start it withDEV_SERVER=1 bench --site gameplan-demo.test serve --port 8002— withoutDEV_SERVERthe seed API's test-mode gate refuses (see "Enabling the seed API" above). All local Cypress runs and seed/reset calls use this disposable demo site. - Local realtime authentication:
:8000is a secondgameplan-demo.test-pinned Frappe server used by Socket.IO to authenticate the browser session. Start it withDEV_SERVER=1 bench --site gameplan-demo.test serve --port 8000. Socket.IO itself listens on:9000(bench socketio). Do not point Cypress or seed/reset calls at:8000; it is an authentication target, not the configured local test runner. - CI:
.github/workflows/ui-test.ymloverridesCYPRESS_BASE_URLwithhttp://gameplan.test:8000. CI runsbench start, whose Procfile starts Socket.IO, so the realtime spec runs for real.SKIP_REALTIME_E2Eis deliberately not set: the preflight throws when it is set andCIis set, because a skipped spec rolls up as a passing one and the variable would silently delete the suite's only genuine socket-delivery coverage. Locally the variable still just skips the spec — a bench withoutbench socketiorunning is a normal state. - Retired:
:8001was a historical local runner. It is not part of the current setup and must not be used.
Realtime on a local bench: genuine delivery uses the three local processes
listed above. Under developer_mode,
apps/frappe/realtime/utils.js::get_url rewrites the socket origin's port to
common_site_config.json's webserver_port. The earlier SameSite theory was wrong:
the cookie is sent; it was being validated against a different site.
discussions/realtime-activity.cy.ts has a behavioral preflight with distinct
failure messages for the configured Cypress server, realtime authentication
target, and Socket.IO server.
The site and web port come from CYPRESS_BASE_URL; the authentication and socket
ports default to :8000 and :9000 and can be overridden with
GAMEPLAN_REALTIME_AUTH_PORT and GAMEPLAN_SOCKET_PORT.
After seeding, the preflight mints a new persona session on each web port and resolves the acting user immediately in the same Cypress task. It does not inspect process names, and it never carries a sid from one Cypress step to another.
Three traps matter when checking this manually:
frappe.realtime.get_user_inforeturns{}unless the request includesX-Frappe-Socket-Secret;{}means “missing secret,” not “Guest” or “wrong site.” Get the secret withbench --site gameplan-demo.test execute frappe.realtime.get_socketio_secret.- Test-suite pressure can expire sessions within minutes. Mint the sid and resolve
it in the same step; otherwise
session_expired/Guestcan be misdiagnosed as a site-routing failure. bench browse --user X --sidis unavailable in Frappe v16.28. Log in withPOST /api/method/loginand passwordadmin. Curl stores the HttpOnly sid on a#HttpOnly_line; extract it from the cookie jar withawk -F'\t' '$6=="sid"{print $7}' jar.
Known Frappe limitation, deliberately without a PR yet: in developer_mode,
get_url discards the request origin's port unconditionally. The rewrite is needed
when the origin is Vite on :8080, which is not a Frappe server, but a bench serving
two sites on two real web ports can authenticate sockets for only the site on
webserver_port. A future fix should prefer an origin that answers as a Frappe site
and fall back to webserver_port; Faris chose to raise that upstream later.
A genuine two-browser version remains an explicit follow-up. Cypress has no native
multi-session support, so it would need cy.session juggling or an iframe and would
be the most flake-prone spec in the suite. The current reliable version keeps one
browser watching while a separate server-side session makes the change.
- No bare
cy.wait(ms). Wait on an intercept alias instead. The only exception is a test whose subject is a debounce (then the wait is the thing under test). - Selectors: roles and aria first. Query by role/aria/text the way a user would.
data-testidis an escape hatch, used only when there is no accessible selector. - One happy path per feature. Edge cases and permission checks belong in the backend suite.
- Journeys are the smoke suite. The
journeys/specs walk the core flows end-to-end and are the first signal that something is broken.
cd frontend && yarn test-
Run only against
gameplan-demo.test, never another local site. -
The target site needs
enable_ui_tests: 1andallow_tests: 1in itssite_config.json, and its server must have been started withDEV_SERVERset — see "Enabling the seed API" above. -
Warning: the seed/reset endpoints wipe ALL Gameplan data on whichever site the request resolves to.
resetDataproves the responding site's identity first and fails the spec if it is not the sitebaseUrlnames, but do not rely on that alone when running anything by hand. -
The server must be running current code. A long-lived
frappe servekeeps the Python it booted with, so a spec covering a backend change made after the server started fails in a way that looks like a product or spec defect.clear-cachedoes not help — only a restart does. When a spec fails on an assertion the backend suite proves, check the server's age before touching the spec:ps -o lstart= -p "$(pgrep -fo 'serve --port 8002')" git log -1 --format=%cd # newer than the server? restart it, then re-run
- Server tests run the full backend suite twice: on MariaDB (
server-tests.yml) and on SQLite (server-tests-sqlite.yml), both viabench --site gameplan.test run-tests --app gameplan. - Backend coverage is collected only in the canonical MariaDB lane
(
server-tests.yml), which runs with--coverage. SQLite and v16 stay cheaper compatibility lanes rather than measuring the same Python lines three times. - Frontend coverage rides on
ui-test.yml, which builds withGAMEPLAN_COVERAGE=1so Cypress drives an instrumented bundle. - Both post a sticky PR comment and a job summary (
server-tests-report.ymlandui-test-report.ymlhandle fork PRs, where the token is read-only), and on pushes todevelopboth republish their README badge. See § 2 "Coverage" for what the numbers do and do not count, and why they are not comparable to each other. - UI tests (
ui-test.yml) run Cypress and produce JUnit results. A markdown report is built from the JUnit XML and posted as a sticky comment on the PR (ui-test-report.ymlhandles fork PRs, where the token is read-only). - Cypress does not retry failed tests in run mode (
retries.runMode: 0), so a journey that passes only on a later attempt cannot report green.
The migration is done. Everything described above is the current state, not a plan:
- Backend tests all live under
gameplan/tests/{features,permissions,platform}/. Notest_*.pyremains beside a doctype, and the oldtests/utils.pyis gone — builders live infixtures.py. - All 33 specs sit in the grouped folders under
cypress/e2e/, seed throughresetData(scenario), and log in withcy.loginAs(persona). gameplan/test_api.pyis deleted.gameplan/ui_test_helpers.pyis the only seed surface, and every entry point is behind the three gates described in "Enabling the seed API" above.
What is not done yet is coverage: Step 3 of TEST_SUITE_PLAN.md lists the features that
still have no test at either layer.