@burojs/testing
v0.5.0
Published
Buro: test-time tools — checkApp and describeScreen
Readme
@burojs/testing
Test-time tools for buro apps: describeScreen and checkApp. Not for
application code — import it from your tests.
Source, in this package, is a screen source: a name a screen reads (record, values, scope, route, user, and the screen's with names), which describeScreen's sources option fills with fixtures. A configuration source — a JSON or YAML document a host loads with @burojs/config — and its SourcePolicy are a different thing; checkApp reports what such a source's layer did through that layer's status.
pnpm add -D @burojs/testingIt imports no test runner: both functions return plain values to assert on with any runner. The examples below use Vitest.
describeScreen(resource, mode, { sources?, permissions?, ref? })
Compiles and plans one record screen (show, edit or create) of a
resource with every source ready, and returns:
visibleKeys(): the keys of the keyed nodes that are not absent, in tree order, andnodeByKey(key): one planned node (state,reason, itschildren…);fields: each field's status (active,readonly,inactive,pending);texts: every text slot, with its bundle key and its default;plan: the wholeScreenPlanthatplanScreenreturned, itsheaderandbodytrees included, for what the helpers above do not answer;issues: why the screen could not be compiled —screen-limitwhen it nests too deep once types'extendentries are appended, with an empty plan. Empty otherwise.
Options:
sourcessupplies fixture values by source name:record,values,scope, and the screen's ownwithsources. Source here is a screen's data source — not a configuration source (@burojs/config), and not a layer runtime's source. A source without a fixture is ready with the valuenull; in show and edit,valuesdefaults to therecordfixture. In create mode there is no record, so arecordfixture is ignored.permissionsis aPermissionMap. The default allows everything, unlikeplanScreenitself, which plans everyrequirespanel absent when no map is passed (fail closed). Pass a map to test a gated panel.refis the resource's canonical ref, for a portable resource value (what a configuration source committed), which does not carry one: its texts then key asresources.<canonical key>.…, as the runtime's do. A resource handle's own ref wins.
import type { ResourcePortableValue } from '@burojs/core';
import { describeScreen } from '@burojs/testing';
import { expect, it } from 'vitest';
import { orders } from '../src/resources/orders.js';
it('shows the shipping panel once the order is paid', () => {
const draft = describeScreen(orders, 'show', { sources: { record: { status: 'draft' } } });
expect(draft.visibleKeys()).not.toContain('shipping');
const paid = describeScreen(orders, 'show', { sources: { record: { status: 'paid' } } });
expect(paid.visibleKeys()).toContain('shipping');
expect(paid.issues).toEqual([]);
});
it('plans the catalog screen a configuration source committed', async () => {
// `app`: your app runtime (`defineApp(…)`), with the configuration source registered and loaded.
await app.ready;
const ref = { namespace: 'json', id: 'catalog' };
const value = app.runtime.getSnapshot().getProjection({ kind: 'resource', ref })?.value;
const screen = describeScreen(value as ResourcePortableValue, 'show', { ref });
expect(screen.texts.map((text) => text.key)).toContain('resources.json:catalog.nodes.main.title');
});describeScreen is how a test sees a tree without rendering it.
With no permissions option describeScreen treats everything as granted, so a
field guarded by requires reads visible. describeAvailability and the
runtime hide guarded fields when there is no permission map; pass a map to test
the guarded reading.
describePageScreen(page, { sources?, permissions?, blocks?, blockData? })
The same description of a custom page's body (defineCustomPage({ …, body }), or a page read
back from app.registry.customPages), planned in show mode with no record. Pass the handles of
the blocks the page places in blocks; a block it cannot resolve compiles denied.
await checkApp(app, { i18n?, components? })
Reports the screen and declaration diagnostics of an app in one call, as
{ code, severity, location, message, locale?, key? }[], in a stable order.
app is any app runtime — what defineApp returns, or
definition.createRuntime().
import { checkApp } from '@burojs/testing';
it('has no diagnostics', async () => {
expect(await checkApp(app)).toEqual([]);
});What it reports:
An app whose
readyrejects: its compile errors.Otherwise, once every registered source has settled, required or not (
app.runtime.whenSettled(); a load still in flight is awaited, and a source never loaded is not loaded).checkAppwaits for every one of them with no timeout, so a source whose load never resolves hangs it, and the test with it until the runner's own timeout; a test that registers such a source should not callcheckAppuntil the load has settled:- the screen checker's issues over the committed snapshot — the
layout.*,fields.*,create.*,ref.*,relation.*,screen.*,access.can-in-whenandtype.*codes. A committed declaration has no errors, so these are its warnings (layout.empty); - the diagnostics of every layer status in
error(app.runtime.getLayerStatuses()). A granted layer that fails is left out while your code commits, so its diagnostics live only on its status. A granted layer's diagnostics are in their public form; - the diagnostics of every rejected
app.use(...)(app.rejectedUses): its errors and its own warnings.useresolves a rejection as{ status: 'rejected', diagnostics }without throwing, andreadydoes not reject on it, so the resources it carried are simply missing. A lateruseunder the same contribution id that commits clears it; for ausewithout an id, a laterusethat commits every declaration it made does.
- the screen checker's issues over the committed snapshot — the
Otherwise, with
i18n: registry, also the translation coverage of every declared text slot: resource, chrome, field, option, enum value, enum fallback and action labels, node titles and descriptions, and check messages. A label declared as''is no slot.registryis the app's own locale registry (createLocaleRegistry({ primary })from@burojs/i18n-core, or anything with itsprimary()andbuildResources()): the bundles checked are whatbuildResources()returns, and the primary locale — the one whose text is the default in code — isregistry.primary(). Pass the same registry the app builds its facade from, so the check cannot disagree with the app about which locale is primary. To probe an empty locale, register an empty bundle for it on a fresh registry.i18n.missing(warning, withlocaleandkey): a non-primary locale covers the slot at no key of its lookup chain.keyis the entry key of that chain (resources.clients.fields.status, not its.label).i18n.object(error, withlocaleandkey), in any locale: an object at a key the slot reads that is not an entry carrying its leaf (a locale map in a bundle, for one). An entry holding only entry keys (label,title,description,empty,doc,options,placeholder), such as a doc-only field entry, is not one: its slot counts as missing until the leaf is there.
The keys and their chains are the table in
docs/i18n-guide.md. Text a host reads witht()itself, outside any declaration, is not a slot.With
components(the host's<BuroProvider components>map), also the errorscreen.override-removedfor each record override key —record.<show|edit|create>.<resource>. Record overrides were removed in P6 and such a component is never rendered; lay the screen out with a frame (views.<mode>.frame) or<RecordScreen frame>. Other keys are ignored.
// The app's i18n module builds its registry: createLocaleRegistry({ primary }) plus its contributions.
import { buildLocaleRegistry } from '../src/i18n.js';
const reported = await checkApp(app, { i18n: buildLocaleRegistry() });
expect(reported.filter((entry) => entry.code === 'i18n.missing')).toEqual([]);A labelled action's slot is covered only by the resource's own entry
(resources.<r>.actions.<name> or its .label): the action button shows the
declared label before resources._ and common, so those never translate it.
What it does not report:
suspendedstatuses (a layer waiting for a transform target) and their warnings;- compile warnings outside the screen checker (
missing-transform-target,source.priority-clamped); - what checks report at run time (
check.parseError,check.exception): a check is code the compiler never sees; - definition-time errors (
fields.bad-nameon an authored object,source.reserved-key,i18n.inline-mapin adefine*call), which throw before an app exists; - a flow's argument (
args) labels: they are not text slots yet, so a missing translation there is not reported. (A flow'sconfirm,notify, check messages and form field labels are slots, underresources.<r>.actions.<name>.)
location reads <resource> <mode>.<path>. For example,
clients show.body[1].tabs[audit] names a keyed node by its key, a field leaf
by its name, and other nodes by index, as it names an element of a fields
list (clients fields[1]).
A declaration that never committed — a rejected compile, a left-out layer —
has no value to read keys from, so its nodes are named by index
(clients show.body[1].children[0]). A target that is not a resource reads
<kind> <ref> <path>.
@burojs/testing/react: renderBlock and fakeData
Renders a block view alone, the way a placement renders it, without an app:
import { renderBlock, fakeData } from '@burojs/testing/react';
import { kpiView } from '../src/blocks/kpi-view';
import { kpi } from '../src/blocks/kpi';
await renderBlock(kpiView, {
props: { userId: 'u1' },
data: fakeData(kpi, { total: { n: 9 } }),
messages: { title: 'Total' },
});renderBlock(implementation, { props?, data?, messages?, wrapper? })takes theimplementBlock(...)result (never an id). It validatespropsthrough the block's schemas — literal defaults applied, the view receives each schema's output — and rejects withcode: 'block.prop-invalid'when a prop fails. Aloadimplementation is awaited. The view'st(key)returnsmessages[key], or the key.fakeData(block, entries)returns a frozen object ofdataentry values; an entry the block does not declare throws aTypeError.
The subpath needs react, @burojs/react and @testing-library/react, declared as optional
peer dependencies: the root @burojs/testing entry (checkApp, describeScreen) needs none of
them.
