Meridian handbook
Templates

Testing templates

How to verify templates with engine-dmo check, the language server in your editor, describe template and describe route, render --json, and template tests in tests/templates/*.test.yaml with selector, JSON-LD and snapshot assertions.

Templates are checked at four levels, from cheapest to fullest. Each catches what the one before cannot see:

flowchart LR
  A["engine-dmo lsp<br/>as you type"] --> B["engine-dmo check<br/>every template, every site"]
  B --> C["engine-dmo render<br/>one real page"]
  C --> D["engine-dmo template test<br/>many pages, in CI"]
ToolNeeds a databaseCatches
engine-dmo lsp in your editorNoThe check’s diagnostics as you type, with completion and hover
engine-dmo checkNo, with --no-dbEvery template on every site against the project’s types, fields, routes, components, image transforms and the security rules
engine-dmo renderYesOne real page through the whole pipeline, with its errors, queries, assets, accessibility findings and images
engine-dmo template testYes, seededAssertions on many pages’ output, for CI and upgrades

engine-dmo check

The gate. It validates configuration, schema, templates and the deployment together, and reports every problem at once, each with its file, line, column, the source line and a hint.

engine-dmo check            # everything, including database checks
engine-dmo check --no-db    # skip checks that need a database (CI, image builds)
engine-dmo check --strict   # configuration warnings are errors
engine-dmo check --json     # { diagnostics, errors, warnings, strict, valid }
FlagEffect
--no-dbSkip checks that need a database
--strictPromote configuration warnings to errors (not template warnings; see below)
--fixWrite a stable id for every configuration element without one, keeping comments, then check
--contract-reportList template usage the next template contract version removes, each with its replacement
--jsonMachine-readable diagnostics

It exits 0 when valid and 3 when the configuration (templates included) is invalid. Configuration still needs its environment variables set; --no-db only skips connecting.

Read every line, not the summary

Template errors are counted and fail the check, but template warnings are not. A warning about a template (a head without head(), a missing string, an uncredited image) is printed above the summary, and the summary still says 0 errors, 0 warnings. Configuration is valid. --strict does not turn a template warning into an error, and --json leaves template warnings out of diagnostics altogether. A fresh project from engine-dmo init shows this at once: its base.html has no head(), so check prints that warning and exits 0. Read the whole output, and in CI search it for warning: if template warnings must fail the build.

A template error is printed twice: once in the template checker’s short form (_shared/walk/entry.html:4:4: error: ...) and once in the configuration form with its error code (templates/_shared/walk/entry.html:4:4: ... and = error code MRD-1006). They are the same problem.

For templates it reports, among the rest:

  • a template that does not compile;
  • a variable, field, filter, tag or function that is not in the template contract for that file, with a “did you mean” ("tone" is not in the template context (contract 1));
  • a field the template’s type does not have, read on entry or on the type’s own key (entry.sumary, walk.sumary);
  • component calls against their contracts (Components);
  • image transforms and breakpoints the site does not define, and helper keywords that are refused (Images);
  • the security rules: a variable in href or src without |url, in <script> without |json, in a style or an inline handler, the safe filter (Frontend build);
  • per-request output inside {% cache %} (Caching);
  • a sigmx.fragment() naming a fragment no template declares;
  • a site copy whose shared original has changed (Multisite);
  • advisory warnings: a head without head() or a description, an image whose credit is printed nowhere, two priority=true images on one page.

A shared template is checked once for every site whose chain reaches it, against that site.

What it does not report, so that only render shows it:

Not checkedWhat happens at render
A field read on a loop variable, a query() result, a relation’s item or a component prop ({% for w in items %}{{ w.photo }})Prints nothing, with no error or warning, even with template.strict: true
A property a value does not have, such as entry.publishedAt.isoPrints nothing
A query() misuse: an unknown type, a field that is not index: true, an unknown operator (Errors)The page answers 500
An unknown endpoint key in enquiryForm() or subscribeForm(), or .render() on eitherPrints nothing
A required component slot when the call has no body at allThe render is refused
t() in an email templatePrints nothing (Email templates)

So render each kind of page once it has content, and read the output.

The language server

engine-dmo lsp is a Language Server Protocol server in the binary, so it always matches the engine that renders the templates. It needs no database and no secrets, and refuses a project whose engine.environment is production. Use engine-cms lsp for a project on engine-cms.

FeatureWhat it gives
DiagnosticsOn open and on every change, from the unsaved text: the compile, the security rules and the contract check of engine-dmo check, merged across every site whose chain holds the file. Each carries the check’s code and hint, narrowed to what it names
CompletionGlobals with their keywords, the template’s type’s fields (entry.), a field value’s properties and helpers (entry.heroImage.), singletons., nav., filters after |, tags after {%, template files in include and extends, component names and their remaining props
HoverA global’s or filter’s documentation, a field’s type, label, help and declaring file and line, a component’s contract
Go to definitioninclude, extends and import to the file the site’s chain resolves; a component call to its contract and markup; entry.<field>, singletons.<key> and nav.<key> to the configuration line that declares them

It reloads the project when a configuration file or a component contract changes, so a field added to a schema file completes on the next keystroke. A configuration that fails to load keeps the last good project and publishes the loader’s errors on the configuration files.

The server speaks over standard input and output (--stdio, the default and only transport). It finds the project from --project, else the workspace root the editor sends, else the working directory. Give it your templates as HTML files.

Neovim 0.11 or later, in the project’s .nvim.lua or your configuration:

vim.lsp.config('meridian', {
  cmd = { 'engine-dmo', 'lsp' },
  filetypes = { 'html', 'htmldjango' },
  root_markers = { 'config/engine.yaml' },
})
vim.lsp.enable('meridian')

Look before you render

These need no database. describe template loads the configuration, so every ${VAR} it uses without a default must be set, even to a placeholder (DATABASE_URL=postgres://localhost/none); template api needs none:

engine-dmo describe template listing/entry.html --site town
engine-dmo template api --site town --json

describe template says which file a name resolves to along the site’s chain, what it extends and includes (and which file each resolves to), and which type it renders. template api is the complete template surface of a site: globals, filters, tags, field types with their properties and helpers, the project’s types and fields, routes and components. engine-dmo docs serve shows the same in a browser.

With a database, describe route renders a path and prints what the template received:

engine-dmo describe route /whats-on/film-club --json

It reports the route that answered, the template the site’s chain resolved, and the whole template context with real values: the variables, functions, filters and components available. Write a template against it.

Render a page

engine-dmo render /whats-on/film-club              # the HTML on stdout, an image table on stderr
engine-dmo render /whats-on/film-club --json       # the report below
engine-dmo render /en/whats-on --locale fr --json  # the same page in another locale
engine-dmo render / --fragment results             # only the named fragments, as a script's request gets them

render boots the engine against the configured database, read-only, and renders the path through the real middleware and pipeline in process, with no server. A traced render records no page view and no search log entry.

FlagMeaning
--site keyThe site (default: the default site)
--locale codeThe locale; the path is then the unprefixed address, and the locale’s prefix or domain is added
--host hostThe Host the request carries (default: the site’s first domain, or the locale’s under the domain strategy)
--fragment a,bRender only these fragments; a fragment the page did not render is a failure
--jsonThe report

The --json report:

KeyValue
status, site, locale, request, kind, templateThe response status, what answered and the template file
htmlThe page
errors, warningsRender problems, such as a template error with its file and line, or a second priority=true
queriesThe database statements the page ran
assetsThe stylesheets, scripts and other files the page references
a11yPage-level accessibility findings (one h1, a title, the language)
imagesEach image: transform, sizes, ladder, formats, each derivative’s bytes and whether it is stored

It exits 1 when the status is 400 or above or the render failed. Log lines (the engine starting, the signing key warning, the request) go to standard error before the page or the failure, so read standard output for the page and the exit status for the result; a failure to reach the database prints those lines and then MRD-3001.

render has no visitor and no session: csrfInput(), csrfToken() and csrf print nothing, and a drawn form’s _csrf field is empty. Check token handling on a running server.

Template tests

Template tests render pages through the real pipeline against the seeded database and assert on the output. Put them in tests/templates/*.test.yaml (or one directory below):

# tests/templates/site.test.yaml
- name: the home page lists what is on
  path: /
  template: page/entry.html
  expect:
    contains: ["Coming up", "Thursday Film Club"]
    selector: { "h1": "Riverside Arts", "#form-newsletter": { count: 1 } }
    lang: en-GB
    headers: { Content-Type: text/html }

- name: a news story renders its title and hero
  fixture: seed:article/new-season
  template: article/entry.html
  expect:
    selector: { "article h1": { count: 1 }, "article picture img": { count: 1 } }
    jsonld: { "@type": "Article" }
    not_contains: ["undefined", "%!s("]

- name: a weekly event links its calendar file
  fixture: seed:event/thursday-film-club
  expect:
    selector: { "a[download]": { attr: { href: /whats-on/thursday-film-club.ics } } }

- name: the events index is unchanged
  path: /whats-on
  locale: en-GB
  snapshot: true

- name: an address with no page is not found
  path: /no-such-page
  expect:
    status: 404
engine-dmo seed apply
engine-dmo template test

A test

KeyMeaning
nameRequired; what the test is called in the output
pathThe page’s address, starting with /, without the locale prefix when locale is given
fixtureseed:<type>/<slug>: the seeded document’s address through its type’s route on the site. Use path for a nested or parented address
siteThe site’s key; the default site otherwise
localeThe locale
fragmentFragments to request, as the Meridian-Fragment header names them
templateThe template the page must render with
snapshottrue records the page’s markup and compares later runs with it
expectThe assertions

A test has exactly one of path and fixture.

Assertions

KeyPasses when
statusThe response status is this (200 when not given)
contains, not_containsThe page contains, or does not contain, each string
selectorEach CSS selector’s first match has the given text; or, as a map, count matches, its text equals, its text contains, its attr values equal
jsonldSome object in the page’s JSON-LD (inside @graph and nested objects too) holds these values
langThe html element’s lang
headersEach header’s value contains the given text

Every failing assertion is reported, not only the first.

Selectors support type selectors, #id, .class, [attr], [attr=value], [attr~=value], [attr^=value], [attr*=value], *, the descendant combinator (a space), the child combinator (>) and groups separated by commas.

Snapshots

A test with snapshot: true, or every test under --snapshots, records the page’s markup in tests/templates/__snapshots__/<file>/<name>.html on its first run and compares later runs with it, reporting the first line that differs. The CSRF token’s value and asset version queries are blanked first, since they change between runs. Commit the snapshots; --update records them again after an intended change.

Running

engine-dmo template test                     # every test
engine-dmo template test --filter "news"     # tests whose name or file contains "news"
engine-dmo template test --snapshots         # snapshot every test
engine-dmo template test --update            # record snapshots again
engine-dmo template test --json              # { passed, failed, tests: [{ name, file, line, passed, failures, snapshot }] }

It prints one line per test with its file and line, the failures beneath, and a count. A failing test exits 1. A problem in a test file (a test with no name, with neither or both of path and fixture, a malformed fixture, a selector it cannot read) is reported at its line and column together with the rest, nothing renders, and the run exits 3:

tests/templates/bad.test.yaml:5:12: test "both" names both a path and a fixture; give one

The tests read the database engine-dmo seed apply wrote. A test of content that is not there fails on its status.

In development

Under engine-dmo serve --dev:

  • a template error renders a development error page with the file, the line and the source, instead of the site’s error template;
  • dump(value) and the dump filter render a navigable tree of a value, and dump() the whole context; both are development only;
  • devMode is true, for markup you want only while developing;
  • ?_profile on a page links to its profile: the render’s spans, queries and each cache block’s hit, miss or skip;
  • templates are read afresh on every render.

In CI

engine-dmo check --no-db --strict > check.log 2>&1 || { cat check.log; exit 1; }
cat check.log; ! grep -q 'warning:' check.log      # fail on template warnings too
engine-dmo build --no-db --strict                  # with a frontend build
engine-dmo migrate up && engine-dmo schema apply && engine-dmo seed apply
engine-dmo template test --json

The first lines need no database, but they still load the configuration, so set DATABASE_URL and every other ${VAR} without a default to a placeholder; a signing_key read from a variable must be at least 32 characters even then. The last three need a disposable database with DATABASE_URL pointing at it.