Changelog¶
All notable changes to this project are documented here.
The format follows Keep a Changelog and this project adheres to Semantic Versioning.
0.5.1 — 2026-06-17¶
Python package only — @spltz/viur-testing (npm) is unchanged and stays at
0.5.0; it reads the token from /_test/config/status regardless of how the
token is generated.
- Deterministic per-day session token. The token is now derived from the
session identity (database, namespace, project id) plus the current UTC day,
so it is identical all day and across server restarts,
finishcalls and re-issues, and rotates at UTC midnight. A cookie armed once via/_test/config/enter(or set by the Playwright fixtures) therefore stays valid for the whole day. It is not a secret —/_test/config/statushands it out freely; the production guard + dev-server gate remain the real protection.
0.5.0 — 2026-06-16¶
Combined Python + @spltz/viur-testing (npm) release. Switches the test-token
transport from a header to a cookie (so manual browsing finally works on a
hard navigation), simplifies the env var to a single value, and removes the
tokenless dev mode and the Vite token plugin.
- BREAKING — cookie-first token transport. The session token now travels as
a
viur-test-tokencookie (SameSite=Strict; HttpOnly; Path=/) instead of theX-Viur-Test-Tokenheader. TheTokenValidatoraccepts only the cookie. A newGET /_test/config/enterendpoint sets the cookie for manual browsing — hard navigations, reloads and server-rendered pages included, with the token still fully enforced. npm: the fixtures set the cookie viacontext.addCookies;authenticatedApi/backendApisend it as aCookieheader;finishTestMode/finish()send no token (bootstrap endpoint). - BREAKING — single-value env var.
VIUR_TESTING=1(ortrue/on) = on, default namespace; any other value is the namespace verbatim (VIUR_TESTING=ak). The former<mode>[:<namespace>]grammar and thetest/devkeywords are gone —test/devare now ordinary namespace names.setup()drops themodeparameter. - BREAKING (npm) — Vite token plugin removed.
viurTestingTokenFetch/withTokenInjectionare gone; the dev-server proxy is now plain (the browser carries the cookie).viteis no longer a peer/dev dependency of the package. The scaffoldedvite.e2e.config.tsis a plain proxy. - Removed — tokenless dev mode. The
devmode,arm_tokenless_browsing,setup(tokenless_app_ids=…)and the PIN-at-boot path are gone — manual browsing is the cookie flow above. Dev-Mirror seeding (viur-mirror) is unchanged (still PIN-gated); bootVIUR_TESTING=<namespace>to work in your slice. - BREAKING — cookie-based production guard; legacy header fully gone. The
TOKEN_HEADERconstant and its export are removed. TheProductionGuardValidatornow watches theviur-test-tokencookie: on a non-dev server any request carrying it gets an immediate 403 (in dev theTokenValidatorowns the cookie). Nothing in the package reads theX-Viur-Test-Tokenheader any more. - Unchanged — Datastore isolation, Guarded Mode.
Migration¶
# env var
VIUR_TESTING=test → VIUR_TESTING=1
VIUR_TESTING=test:ak → VIUR_TESTING=ak
VIUR_TESTING=dev:ak → VIUR_TESTING=ak # dev/tokenless gone
# main.py — drop the removed setup() kwargs
viur.testing.setup(tokenless_app_ids=[...]) → viur.testing.setup()
# regenerate the e2e scaffold (plain Vite proxy, no token injection)
npx viur-testing-init
Note: a leftover VIUR_TESTING=test now selects a namespace named
"test", not "on, default namespace" — change it to VIUR_TESTING=1. To
browse the test instance by hand, navigate once to
/_test/config/enter (sets the cookie), then browse normally.
0.4.0 — 2026-06-16¶
Combined Python + @spltz/viur-testing (npm) release. This version unifies
test-mode activation behind a single env var, adds the Development mode
(live-data mirroring + tokenless browsing), streamlines the
viur-testing-init scaffolder, and ships a bilingual (EN/DE) documentation
site.
-
BREAKING (Python) — one boot env var.
VIUR_TESTING_ENABLE,VIUR_TESTING_NAMESPACEandVIUR_TESTING_TOKENLESScollapse into a singleVIUR_TESTING=<mode>[:<namespace>](mode=testordev;1/true/onaliastest; unset/0/off/false= off).devmode now requires a namespace.setup()drops the three*_env_varparams and gainsenv_var(defaultVIUR_TESTING) plus explicitmode/namespaceoverrides.VIUR_TESTING_ENABLE=1 → VIUR_TESTING=test …_ENABLE=1 …_NAMESPACE=ak → VIUR_TESTING=test:ak …_ENABLE=1 …_NAMESPACE=ak …_TOKENLESS=1 → VIUR_TESTING=dev:ak
-
Development mode (Python). New
viur-mirrorconsole script seeds a per-developer namespace ofviur-testsfrom the live(default)database — read-only source client, viur-core secret/system kinds excluded (viur-confincl. hmacKey,viur-session,viur-securitykey,viur-relations,file/file_rootNode/viur-blob-locks), keys and relations remapped onto the target partition, PIN-gated, never writes(default). BootingVIUR_TESTING=dev:<ns>then PIN-arms tokenless browsing of that slice for whitelisted dev servers (setup(tokenless_app_ids=…)/arm_tokenless_browsing(...)), so a full dev environment incl. Admin works without theX-Viur-Test-Tokenheader — only ever on theviur-testsslice. -
BREAKING (npm) — streamlined
viur-testing-init. The scaffolder no longer prompts for a mode; it always scaffolds a Test Mode suite (the--mode/--guardedflags and the Guarded-Mode preset are gone — Guarded Mode remains as a runtime auto-detect). It auto-locates the project root by walking up for adeploy/directory and proposes<root>/testing/e2e(confirmable/overridable), and additionally generates the backend test-API packagetesting/api/__init__.py+ an exampletesting/api/user.py(UserTestApiwithsetup/teardown). -
npm — dependency refresh.
vitepeer range widened to^5 || ^6 || ^7 || ^8; dev toolchain bumped (TypeScript 6,@types/node25,@playwright/test1.61, Vite 8). -
Docs — bilingual site (EN/DE). Added
mkdocs-static-i18n(folder structure, Material language switcher); API reference stays English (generated from docstrings) with the German nav redirecting to it; the Dev-Mirror Mode page is renamed Development Mode; SVG logo + green favicon added.
0.3.0 — 2026-05-28¶
@spltz/viur-testing only — the Python package stays at 0.2.0.
Introduces Guarded Mode: automatic detection of whether the
target backend is in test mode, with an interactive PIN gate for
the live-backend case. Lets Playwright drive read-only smoke tests
against a deployed application without spinning up a dedicated
test backend, while keeping the bilateral guarantee for everything
that does have one.
Added¶
- Auto mode detection in
createGlobalSetup()—POST /json/_test/config/statusdecides the run mode: - 200 + valid payload → Test Mode (unchanged flow).
- 404 → Guarded Mode + interactive PIN challenge.
- 5xx / timeout / malformed / integrity-fail → hard error, never a silent downgrade.
- 6-digit PIN challenge (
runPinChallenge): fresh code per run, displayed yellow + space-separated above the backend URL. Wrong PIN → abort. No TTY → "Run from an interactive terminal." No persisted ACK file, no env-var bypass — every run is its own human-in-the-loop decision. - Mode propagation via
VIUR_TESTING_MODEenv var (exported asMODE_ENV_VAR). Workers inherit and the fixtures branch on it. - New public exports:
detectMode,probeStatusEndpoint,runPinChallenge+ their option types, for hosts that want to build custom setup flows on top. createGlobalSetup({ backendUrl })— explicit option in addition to the existingE2E_BACKEND_URLenv var (option wins when both are set).viur-testing-initscaffolder picks the mode interactively. On a TTY the CLI asks[1] Test Mode / [2] Guarded Mode, defaulting to Test. Non-TTY runs (CI scaffolding) default to Test silently. Skip the prompt with--mode test|guardedor the--guardedshortcut. The Guarded preset drops Vite,.env.e2e, and theserverStatus-using example spec; the generatedplaywright.config.tspointsbaseURLat the deployed backend instead of a local Vite dev server.
Changed¶
contextfixture is mode-aware. In Test Mode it injects theX-Viur-Test-Tokenheader as before; in Guarded Mode it returns a vanilla browser context (no headers, no overrides) so Playwright behaves like a real browser against the live application.serverStatus,backendApifixtures auto-skip in Guarded Mode viatestInfo.skip(true, "uses _test infrastructure, ..."). The consuming test counts as skipped, not failed — the spec stays valid in both modes without conditional code.callTestModule/callTestModuleRawauto-skip in Guarded Mode viatest.skip(...). Called fromtest.beforeAllskips all tests in the describe; called from a test body skips that test only.global-teardownis a no-op in Guarded Mode — no session token was issued, nothing to release.
Documentation¶
- Top-level README has a new "Guarded Mode" section pointing at the Playwright README for details.
- Playwright README has a full auto-detect table and the Guarded-Mode contract (TTY-required, no persisted ACK, what auto-skips).
- mkdocs site has a dedicated
Guarded Modepage (docs/guarded-mode.md) with the auto-detect table, PIN display/input rules, fixture auto-skip semantics and the--guardedscaffold flag. - Getting Started + top-level README runner section rewritten
around the npm package. The pytest
conftest.pyexample is gone — the canonical e2e wiring is nownpx viur-testing-init→npm test. The Python primitives (require_test_mode,finish,ServerStatus) remain documented in the API reference for hosts that drive their own Python-side runner.
Internal¶
test-mode.ts:requireTestModeis now a thin wrapper over a sharedprobeStatusEndpointhelper that returns{ kind: "armed", status } | { kind: "unarmed" }. Both the explicit "require" path and the auto-detect path use it.- New files:
pin-challenge.ts(interactive 6-digit gate with injectable IO surface for tests) andmode-detect.ts(detectModeorchestrator).
Quality¶
- TypeScript smoke harness covers the three probe outcomes
(armed/unarmed/ambiguous), the PIN challenge (success / wrong
PIN / no-TTY), the mode-detect dispatcher, and the
globalSetupenv-var plumbing. All 17 checks green.
0.2.0 — 2026-05-28¶
Post-design audit: tightens the bilateral guarantee, mostly on the
runner-side TypeScript half, and normalises namespace handling
end-to-end. No public API breaks; tokenFile is the only removal.
Added¶
- TS runner:
expectedProjectIdoption +E2E_TEST_PROJECT_IDenv var, mirroring Python'sexpected_project_id. - TS runner: SHA-256
token_hashverification and runtimeis_dev_servercheck inrequireTestMode— closes parity gap with Python'srequire_test_mode. - TS Vite plugin: cached token auto-refreshes on observed HTTP
403 and after a configurable TTL (default 1 h). New option
refreshIntervalMs(0 disables TTL refresh; 403 path always on). - TS fixtures: worker-scoped
_viurTestingStatusreads.auth/token.jsononce per worker;serverStatus/context/backendApiconsume it (was 3× per test).
Changed¶
- Python: namespace
""→Nonenormalisation now applied inactivate(),ConfigModule.set_active(), andrequire_test_mode()— was previously only insetup(). - Python:
closed_system_allowed_pathsuses renderer-agnostic wildcards*/_test/*+_test/*(wasjson/_test/*only). - Python:
TokenValidatorbootstrap-path check is now an exact segment-shape match (/<renderer>?/_test/config/<action>) instead of a permissivepath.endswith(). - Python:
register_test_submoduleenforces^[a-z][a-z0-9_-]*$and rejects names shadowing existingTestModule/Moduleattributes (e.g.json,handler). - Python: banner injection detects viur-core's banner width at runtime (falls back to 80 if detection misses).
- Python:
_load_project_apiwalks past everyviur.testingframe instead of hard-codinginspect.stack()[2]. - TS:
expectedNamespace=""is normalised tonull, matching the server-sideVIUR_TESTING_NAMESPACE=convention. - TS:
assertNoDirectPlaywrightImportsskipsnode_modules,.git,dist,build,coverage,playwright-report,test-results,.next; strips line + block comments before scanning; clear error whentestsDirdoes not exist. - TS:
finishTestModesendsX-Viur-Test-Token(symmetry with Python'sfinish()). - TS:
callTestModuleRawcookies carry the full PlaywrightCookieshape (sameSite,secure,httpOnly,expiresadded), derived fromAPIRequestContext.storageState(). - TS:
viur-testing-initpins generatedpackage.jsonto^<own-version>instead of"*". - TS:
viur-testing-initvite.e2e.config.tstemplate is now stand-alone; the Sprengplatz-specificappConfigoverlay moved into a commentedOVERLAYblock at the bottom. Thedev:frontendscript is dropped from the default scripts.
Removed¶
- TS:
tokenFileoption oncreateGlobalSetup/createGlobalTeardown. The path was a footgun — changing it silently broke the fixtures that hard-coded.auth/token.json. A single internaltokenFilePath()helper is now the source of truth across globalSetup, globalTeardown, fixtures, and test-module helpers.
Fixed¶
- Python:
_patch_key_factory+_patch_legacy_urlsafeare now idempotent — repeatedactivate()no longer stacks wrapper layers (relevant for test re-entry).
Documentation¶
- README clarifies token persistence: server side never writes
to disk; runner side caches under
.auth/token.json+process.env.E2E_TEST_TOKEN. - Playwright README documents Vite token refresh and the
E2E_TEST_PROJECT_IDenv var; init-template description updated for the stand-alone Vite config.
Quality¶
- 212 pytest cases (was 187), 100% branch coverage held.
- TS smoke-test harness covers the preflight branches, the forbidden-imports walker, the Vite refresh paths, and the init scaffolder's version pin.
0.1.0 — initial design¶
The initial design pre-dates the audit round. Entries below document the design as shipped before 0.2.0.
Distribution¶
- The Python package ships on PyPI as
spltz-viur-testing(the experimentalspltz-prefix marks it pre-1.0). The Python import path staysviur.testing— namespace package, no rename in host code. Install withpip install spltz-viur-testing. - A companion npm package
@spltz/viur-testinglives inplaywright/next to the Python sources. It bundles Playwright fixtures, the global-setup / global-teardown factories, the forbidden-import guard, test-module helpers, the Vite plugin and aviur-testing-initCLI for scaffolding new e2e suites.
Test-mode activation¶
viur.testing.setup()andviur.testing.register_modules()— two one-liner host wrappers around the underlying primitives.main.pyis reduced toimport viur.testing; viur.testing.setup()andmodules/__init__.pytoviur.testing.register_modules(globals()). Both are no-ops in production (env var unset / state inactive).viur.testing.activate()— atomic test-mode activation. Order of checks:viur.core.db.transportnot yet imported, thenconf.instance.is_dev_servertrue, then builds adatastore.Client(database=…, namespace=…)against the test database, runs a synchronous probe roundtrip, patchestransport.__client__, monkey-patchesviur.core.db.types.Key.__init__to injectdatabase=/namespace=on every newly constructed Key, monkey-patchesgoogle.cloud.datastore.Key.to_legacy_urlsafeso it tolerates named databases, extendsconf.security.closed_system_allowed_pathswith broad_test/*wildcards, primesConfigModule, installs the request validator and wrapsviur.core.setupso the dev-server boot banner shows the test-mode parameters. No on-disk state.viur.testing.protect()andProductionGuardValidator— host installs the guard viaprotect()in every environment. On a non-dev server, any request carrying anX-Viur-Test-Tokenheader is rejected with 403 regardless of the header's value. In dev the guard is a no-op (the fullTokenValidatoralready handles the header).setup(api_dir="testing")— registers the project-side test API package as importable top-levelapiviaimportlib.util. The directory lives outsidedeploy/(sogcloud app deploynever uploads it) and is wired in by resolving<dirname(main.py)>/../<api_dir>/api/__init__.py. Walksinspect.stack()to anchor the path at the caller'smain.pyso the host needs nothing butviur.testing.setup(). Prints a one-line info message when the directory does not exist rather than crashing.
Datastore namespace isolation¶
activate(database=…, namespace=…)and theVIUR_TESTING_NAMESPACEenv var — partition writes inside oneviur-testsdatabase so several engineers (or CI runs) can share the database without colliding on each other's entities. Empty string or unset = no namespace (default Datastore namespace)._patch_key_factoryinjects bothdatabase=andnamespace=defaults intoviur.core.db.Key.__init__, so every Key viur-core builds during test mode points at the same slice as the active client._patch_legacy_urlsafemonkey-patchesgoogle.cloud.datastore.Key.to_legacy_urlsafeto temporarily clearself._databasearound the legacy serialisation. Without this every successful login crashed the server (viur-core usesstr(key)in session save + JSON renders, which callsto_legacy_urlsafewhich refuses named databases). The patch restores_databaseinfinallyso the key stays consistent even on exception paths.ConfigModule.current_namespace()and_namespaceclass state — reported back to runners via/json/_test/config/statusso the preflight can assert the expected namespace.require_test_mode(expected_namespace=…)— runner-side check with_UNSETsentinel: pass a string for an exact namespace,Noneto assert the default namespace, omit the field to skip the check entirely.
Bilateral session handshake¶
viur.testing._test.TestModule— host-mountable container module under/_test. Carriesjson = Trueso viur-core's__build_appregisters it under the JSON renderer. Refuses to instantiate outside a local dev server or whenactivate()has not run yet — structural last line of defence against accidental production mounts and silent "mounted-but-unactivated" states.viur.testing._test.config.ConfigModule— the bootstrap config submodule mounted as/_test/config. Carries the per-process class-level state (_database,_namespace,_project_id,_token,_status_hooks,_finish_hooks) and the same two mount guards asTestModuleso a host that bypasses the container and mountsConfigModuledirectly is still caught. Endpoint bodies are emitted as JSON strings (Content-Type: application/json+json.dumps) so viur-core's WSGI layer forwards a proper JSON body rather than a Pythonreprof a dict. Exposes two endpoints:POST /json/_test/config/status— re-verifies dev-server + datastore database, then reads/creates the session token entity in the test database (kindviur-tests, entityauth-token) and returns it to the runner. Response carriestoken,token_hash,database,namespace,project_id,version, plus any extra keys returned from status hooks. Idempotent. POST-only so a parallel browser tab cannot drive-by trigger the endpoint via a simple GET (CORS preflight stops the cross-origin POST).POST /json/_test/config/finish— re-verifies, deletes the token entity, clears the in-process token. Response includes extra keys returned from finish hooks. Test-mode itself stays armed.viur.testing.validator.TokenValidator—RequestValidatorrejecting every non-bootstrap request that lacks a matchingX-Viur-Test-Tokenheader (constant-time compare). Auto-installed byactivate(). Paths ending in/_test/config/statusor/_test/config/finishbypass the token check so the runner can bootstrap a session before one exists.viur.testing.require_test_mode()— runner preflight: calls/json/_test/config/status, verifies test-mode + dev-server + database (and optionally namespace + project_id), checks the token's sha256 matches the server-reportedtoken_hash, returns aServerStatuswith all session info.viur.testing.finish()— runner cleanup: deletes the token entity viaPOST /json/_test/config/finish.
Host-registered test fixtures¶
viur.testing.register_test_submodule(name, cls)— registers a project-specific submodule that mounts under/_test/<name>/…alongside the built-inconfig. Names are normalised to lowercase (viur-core lower-cases URL segments at request time),configis reserved, empty names are refused. Late registration via class-level dict onTestModule, consumed at mount time.viur.testing.register_status_hook(hook)andregister_finish_hook(hook)— let project code attach callbacks that run inside the/json/_test/config/statusand…/finishendpoints. Hook signature is() -> dict | None; returned dicts are merged into the JSON response (later hooks win on key conflicts). Use for project-specific test-mode prep (feature flags, seed-data references) and for surfacing extra info to runners. Side effects onviur.core.confare allowed.
Dev-server boot banner¶
viur.testing.banner.install_banner_patch(database, namespace)— wrapsviur.core.setup()so itsLOCAL DEVELOPMENT SERVER IS UP AND RUNNINGASCII banner gains two extra lines:database = …(always) andnamespace = …(rendered as(default)when not set). Pattern-matches the banner title and trailer instead of hard-counting line indices, so a future viur-core banner format change degrades gracefully. Idempotent — re-entry fromactivate()does not stack wrappers.
Top-level package surface¶
viur.testing/__init__.pyre-exports only the viur-core-free surface:activate,protect,setup,register_modules,register_test_submodule,register_status_hook,register_finish_hook,require_test_mode,finish, plus dataclasses (ServerStatus), errors (TestModePreflightError) and constants (TOKEN_HEADER,DEFAULT_DATABASE). The heavy classes (TestModule,ConfigModule,TokenValidator,ProductionGuardValidator) live in their concrete submodules and must be imported from there — soimport viur.testingdoes not triggerviur.corebefore the datastore client swap.
Quality¶
- pytest suite against
viur-light-mock+ local stubs, 100% line + branch coverage required. 182 tests at the time of writing. smoke_test.py— end-to-end script that walks every refuse path in a fresh subprocess and additionally exercisesactivate()all the way through to a realdatastore.Client(database= "viur-tests")+ probe roundtrip when run against a workstation with valid GCP credentials.
Lessons learned from booting against a real viur-core project¶
These surprises only surfaced when the package was wired into the
real deploy/ project and not into mocks. Each one is now built
in:
- Reserved Datastore kind prefix. Original
PROBE_KINDwas__viur_test_probe__. Google Cloud Datastore reserves__*__for system-internal use and 400s the write withThe kind … is reserved. Final name isviur-test-probe. - Multi-database Key construction. viur-core's
Keyclass forwardsproject=togoogle.cloud.datastore.Keybut notdatabase=ornamespace=. With a named-DB client every Key viur-core builds is for the default database, and Datastore rejects the call withmismatched databases within request. Solved by_patch_key_factorywrappingKey.__init__. to_legacy_urlsaferefuses named databases. viur-core'sKey.__str__callsto_legacy_urlsafe(), which raises on any key withdatabaseset. Triggers on session-save and login-success JSON render. Solved by_patch_legacy_urlsafe.- viur-core lower-cases URL segments at routing time.
register_submodule("userLogin", …)would register under the mixed-case key but the router looks upuserlogin, so the route silently 404'd.register_submodulenow lower-cases the key. - Closed-system gate. Many host projects set
conf.security.closed_system = True. Once on, every URL not inclosed_system_allowed_pathsreturns 401 before the route is resolved.activate()extends the allow-list with broad_test/*/*/_test/*wildcards so both the built-in bootstrap and host-registered fixture submodules pass through; theTokenValidatoris the actual access control. - Render-name opt-in.
__build_apponly registers a module class for a given renderer ifgetattr(cls, render_name, False)is truthy. WithoutTestModule.json = Truethe routes are silently not mounted, and the actual HTTP URL becomes/json/_test/config/…(JSON renderer prefix) — not/_test/config/…as one might expect from the module hierarchy. - Class vs. instance in modules namespace.
__build_appiteratesvars(modules)and only picks up subclasses ofModule(or already-instancedInstancedModule). A bare module instance is silently skipped. The host-side wiring registersTestModuleas a class, not as an instance.