Files
feuerwerk/tools/e2e/30-font.mjs
T
adminandClaude Opus 5.5 d4ff652293
feuerwerk / gate (push) Successful in 5m21s
feuerwerk / suite (push) Successful in 18m31s
Keep descenders of a text whole and frame it at its drawn size
A text was drawn into a texture as tall as Pixi measures the string
"|ÉqÅM". Whatever reached further, like the "g" of Manrope (0.015 em
below the "q"), was cut off at the bottom. The blue frame around a
text was only estimated from the font size and the number of letters,
so it was far too wide for most texts.

- textPicture.ts takes the height of a line from the font itself
  (fontBoundingBoxAscent and fontBoundingBoxDescent, as a browser
  does). Pixi draws with a margin, and the picture is cut to that box
  afterwards. Where Pixi's own box is larger, Pixi's box wins.
- Both raster paths (linear and nearest) use the cut.
- The height no longer depends on the letters typed, so a text does
  not jump while it is edited.
- bounds.ts asks a measurer that textPicture.ts registers
  (setTextMeasure). Selection, picking in a running game, framing the
  camera, the drop preview and culling all get the measured size.
  The footprint functions take the font file for this.
- Unit tests in node have no canvas and keep the estimate
  (estimatedTextSize).
- Texts get taller: Source Sans 3 at 48 px is 68 instead of 55 px,
  Manrope is 1.366 em instead of 1.25 em. The glyphs stay in place,
  the centre moves by less than half a pixel. A shader on a text
  gets a taller UV rectangle.

Tests:
- Gate on melektaus-atx green: 307 files, 4762 tests.
- 5 new unit tests for textBox, textCut and the measurer hook.
- e2e 30: 26 checks, 30f-iii new. The texture is 68 high, the font
  box is 68, the frame equals the drawn picture (237x68).
- Counter test: with Pixi's old box the texture is 55 high and
  30f-iii is red.
- Not checked in the browser: the Manrope caption of the user and
  texts with several lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:18:59 +02:00

473 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { edge, node } from '../lib/builders.mjs';
import { LANES, pickAssetType } from './lib/scaffold.mjs';
/**
* Font assets (#74): uploaded like a picture – picked from what the server holds through "+ → Font",
* or dropped onto the editor – and worn by a text object through its Font row. No font means the
* editor's own typeface, which is also what a reference that no longer resolves falls back to.
*
* The seed puts two DejaVu faces on the server without making assets of them: this step makes them.
* Everything lives in its own lane (`__gm.lookAt`), because symbols draw above objects and the
* origin is crowded by what the earlier steps left behind.
*/
const LANE = LANES.font;
const TEXT_ASSET = 'ft-text';
const TEXT_OBJECT = 'ft-obj';
/** The two files the seed uploads, told apart by their exact size rather than by their name. */
const SERIF_BYTES = 380660;
const MONO_BYTES = 343140;
/** How many pixels across the text a check reads. */
const SAMPLE = 120;
const RED_SHADER = {
id: 'ft-red',
type: 'shader',
name: 'Red glyphs',
props: {},
graph: {
nodes: [
node('ft-red-color', 'ShaderColor', 0, 0, { value: 0xe63c3c }),
node('ft-red-out', 'ShaderOutput', 260, 0),
],
edges: [edge('ft-red-e1', 'ft-red-color', 'color', 'ft-red-out', 'color')],
},
};
export async function font(t) {
const { check, sleep, json, evaluate, mutate, click, clickText, locate, press, play, stop, shot, showTab } =
t;
const { select } = t;
const assetsOfType = (type) =>
json(`__gm.state.project.assets.filter((a) => a.type === ${JSON.stringify(type)})
.map((a) => ({ id: a.id, name: a.name, file: a.file }))`);
const info = () => json(`__gm.textInfo(${JSON.stringify(TEXT_OBJECT)})`);
/** The overlay is drawn last, so a menu entry wins over a row of the asset list saying the same. */
const clickLast = async (text) => {
const p = await locate(text, true);
await click(p.x, p.y);
};
/** A face arrives over the network, so what it changes is awaited rather than slept for. */
const waitFor = async (read, done, tries = 20) => {
let value = await read();
for (let i = 0; i < tries && !done(value); i++) {
await sleep(150);
value = await read();
}
return value;
};
await press('Escape');
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await evaluate('__gm.state.select(null)');
await showTab('scene');
// 30a. What the server holds, straight from the API: the two files the seed put there.
// `evaluate`, not `json`: the latter stringifies the promise itself rather than what it yields.
const stored = await evaluate(`fetch('/feuerwerk/api/index.php?r=fonts').then((r) => r.json())`);
const serif = stored.find((item) => item.bytes === SERIF_BYTES);
const mono = stored.find((item) => item.bytes === MONO_BYTES);
check(
'the server holds the two seeded typefaces, named by their content',
Boolean(serif && mono) && /^[a-f0-9]{12}\/[a-f0-9]{64}\.ttf$/.test(serif.file),
JSON.stringify({ serif: serif?.file, mono: mono?.file, count: stored.length }),
);
if (!serif || !mono) return;
// The hash says nothing, so the name the file was uploaded under is kept beside it (#83).
check(
'the listing carries the name each file was uploaded under',
serif.name === 'DejaVuSerif.ttf' && mono.name === 'DejaVuSansMono.ttf',
JSON.stringify({ serif: serif.name, mono: mono.name }),
);
// 30b. "+ → Font" offers them; a font is picked rather than created empty, like a texture.
await evaluate('__gm.console.clear()');
await clickText('+');
await pickAssetType(t, 'Font');
await sleep(500);
const picker = await json('__gm.fontPicker()');
check(
'the + menu opens the font picker on what the server holds, none of it in the project yet',
Boolean(picker) && picker.files.includes(serif.file) && picker.used.length === 0,
JSON.stringify(picker),
);
// A row the project does not wear yet is captioned by the file's own name (#83); without it
// there was nothing to read but its byte count.
check(
'a row nobody wears yet is captioned with the name the file was uploaded under',
picker.captions[picker.files.indexOf(serif.file)] === 'DejaVuSerif.ttf',
JSON.stringify(picker.captions),
);
await shot('30-font-picker');
// A row is a container holding its background, not a Graphics with children: Pixi v8 warns
// about the latter, and the warning is the only sign that the rows are built the wrong way.
const logged = await json('__gm.console.lines()');
check(
'building the rows draws no complaint from Pixi',
!logged.some((line) => /Deprecation/i.test(JSON.stringify(line))),
JSON.stringify(logged.slice(0, 3)),
);
const row = await json(`__gm.fontPickerRow(${JSON.stringify(serif.file)})`);
await click(row.x, row.y);
await clickText('Use font');
const fonts = await assetsOfType('font');
check(
'picking a row makes a font asset of that file, named after the file rather than "Font"',
fonts.length === 1 && fonts[0].file === serif.file && fonts[0].name === 'DejaVuSerif',
JSON.stringify(fonts),
);
if (fonts.length !== 1) return;
const fontId = fonts[0].id;
// Renamed away from "Font": the inspector row is called that too, and a lookup by text would
// find the asset's row in the panel instead of the row being tested.
await mutate(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fontId)}).name = 'Bookish'`);
// 30c. A text in its own lane, drawn in the editor's own typeface while it wears no font.
await mutate(`
const project = __gm.state.project;
project.assets.push({ id: ${JSON.stringify(TEXT_ASSET)}, type: 'text', name: 'Title',
props: { enabled: true, scale: 1, rotation: 0, mirrorX: false, mirrorY: false,
text: 'Handgloves', font: '', fontSize: 48, color: 0xffffff, align: 'center' } });
__gm.state.activeScene.objects.push({ id: ${JSON.stringify(TEXT_OBJECT)}, assetId: ${JSON.stringify(TEXT_ASSET)},
name: '', position: { x: 0, y: ${LANE}, z: 0 }, editorOffset: { x: 0, y: 0 },
overrides: {}, parentId: '', instanceEdits: {} });
__gm.lookAt(0, ${LANE});`);
const plain = await info();
check(
'a text wearing no font is drawn in the editor’s own typeface',
Boolean(plain) && !plain.family.startsWith('gm-font-') && plain.width > 0,
JSON.stringify(plain),
);
if (!plain) return;
// 30d. Wearing the font, chosen the way a user chooses it: the Font row of the text object.
await select({ kind: 'object', holderId: 'scene1', objectIds: [TEXT_OBJECT], objectId: TEXT_OBJECT });
// The label is no anchor on its own: the assets panel has a row per font. Parent and Shader (#169)
// say "None" as well, so the Font row's dropdown is the "None" at its height.
const fontRow = await json("__gm.inspectorField('Font', true)");
const nones = await evaluate(`(() => {
const out = [];
const walk = (node) => {
if (!node.visible) return;
if (node.text === 'None') out.push(Math.round(node.getBounds().y + node.getBounds().height / 2));
for (const child of node.children ?? []) walk(child);
};
walk(__gm.app.stage);
return out;
})()`);
check(
'a text has a Font row, and it reads None',
Boolean(fontRow) && nones.some((y) => Math.abs(y - fontRow.y) < 12),
JSON.stringify({ fontRow, nones }),
);
await click(fontRow.x, fontRow.y);
await clickLast('Bookish (font)');
const chosen = await json(
`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides.font`,
);
const worn = await waitFor(info, (value) => Boolean(value?.family.startsWith('gm-font-')));
check(
'picking it in the Font row redraws the text in that face, and it reflows',
chosen === fontId && Boolean(worn) && worn.width !== plain.width,
JSON.stringify({ chosen, plain, worn }),
);
await shot('30-font-scene');
// The asset's own inspector is where a font is looked at; it has nothing else to show.
await select({ kind: 'asset', assetId: fontId });
await shot('30-font-asset');
// 30e. The game draws through the same path, so the face is in play as well. The camera frames
// the origin, so the text has to leave its lane for as long as the game runs.
const place = (expression) =>
mutate(`
const scene = __gm.state.activeScene;
const cam = scene.objects.find((o) => o.id === 'o3').position;
const text = scene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)});
Object.assign(text.position, ${expression});`);
await place('{ x: cam.x, y: cam.y }');
await play();
await sleep(600);
const played = await json(`__gm.gameTextInfo(${JSON.stringify(TEXT_OBJECT)})`);
check(
'the running game draws the text in the same face',
Boolean(played?.family.startsWith('gm-font-')) && played.family === worn?.family,
JSON.stringify(played),
);
await stop();
await place(`{ x: 0, y: ${LANE} }`);
// 30f. An id that no longer resolves is no font at all – the text falls back rather than vanishing.
await mutate(
`__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides.font = 'no-such-asset'`,
);
const dangling = await waitFor(info, (value) => !value?.family.startsWith('gm-font-'));
check(
'a dangling font id falls back to the editor’s own typeface, at the width from before',
dangling?.family === plain.family && dangling?.width === plain.width,
JSON.stringify(dangling),
);
// 30f-ii. Letter spacing goes between the ten characters of 'Handgloves', so nine times.
const textOverrides = `__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides`;
await mutate(`${textOverrides}.letterSpacing = 5`);
const wide = await waitFor(info, (value) => value?.width !== plain.width);
await mutate(`${textOverrides}.letterSpacing = -3`);
const tight = await waitFor(info, (value) => value?.width !== wide?.width);
await mutate(`delete ${textOverrides}.letterSpacing`);
check(
'letter spacing widens and narrows the text by the spacing between its characters',
Math.abs(wide?.width - plain.width - 45) <= 1 && Math.abs(tight?.width - plain.width + 27) <= 1,
JSON.stringify({ plain: plain.width, wide: wide?.width, tight: tight?.width }),
);
// 30f-iii. A text is as tall as its face's own line box, not as Pixi's "|ÉqÅM" reaches, so a
// tail like Manrope's "g" stays whole – and the frame around it is the drawn picture.
const upright = await waitFor(info, (value) => value?.width === plain.width);
const lineBox = await evaluate(`(() => {
const context = new OffscreenCanvas(1, 1).getContext('2d');
context.font = '48px ' + ${JSON.stringify(upright?.family ?? '')};
const metrics = context.measureText('M');
return metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent;
})()`);
const framed = await json(`__gm.objectRect(${JSON.stringify(TEXT_OBJECT)})`);
const frameScale = framed ? framed.w / upright.width : 0;
check(
'a text is as tall as its face’s line box, with no glyph cut at its top or bottom, and framed as drawn',
Math.abs(upright?.height - lineBox) <= 1 &&
upright?.edgeInk.top === 0 &&
upright?.edgeInk.bottom === 0 &&
Math.abs(framed?.h / frameScale - upright?.height) <= 1,
JSON.stringify({ upright, lineBox, framed }),
);
// 30g. Dropping a font file onto the editor uploads it and makes an asset named after the file.
const dropped = await evaluate(`
(async () => {
const bytes = await fetch('/feuerwerk/data/fonts/${mono.file}').then((r) => r.arrayBuffer());
const file = new File([bytes], 'DejaVuSansMono.ttf', { type: 'font/ttf' });
const data = new DataTransfer();
data.items.add(file);
const canvas = document.querySelector('canvas');
canvas.dispatchEvent(new DragEvent('drop', { dataTransfer: data, bubbles: true, cancelable: true }));
return file.size;
})()`);
await waitFor(
() => assetsOfType('font'),
(list) => list.length === 2,
);
const both = await assetsOfType('font');
check(
'a dropped typeface is uploaded and becomes an asset named after the file',
dropped === MONO_BYTES &&
both.length === 2 &&
both.some((a) => a.file === mono.file && a.name === 'DejaVuSansMono'),
JSON.stringify({ dropped, both, notice: await json('__gm.state.notice') }),
);
// 30h. A text wears a shader like a sprite (#169), picked in its own Shader row. The graph paints
// every glyph pixel one red and keeps the text's alpha, so no white may survive it.
const overrides = `__gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(TEXT_OBJECT)}).overrides`;
await mutate(`
Object.assign(${overrides}, { font: ${JSON.stringify(fontId)} });
__gm.state.project.assets.push(${JSON.stringify(RED_SHADER)});`);
await waitFor(info, (value) => Boolean(value?.family.startsWith('gm-font-')));
/** A run of pixels across the middle of the text, where every glyph is cut. */
const rowAcross = async (view = 'objectRect') => {
const r = await json(`__gm.${view}(${JSON.stringify(TEXT_OBJECT)})`);
return r ? json(`__gm.pixelRow(${Math.round(r.x - SAMPLE / 2)}, ${Math.round(r.y)}, ${SAMPLE})`) : [];
};
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
// Red is counted by hue, not by the exact colour: drawn small, a glyph's pixels blend into the background.
const reddish = (hex) => {
const [r, g, b] = channels(hex);
return r > 0x60 && r > 2 * g && r > 2 * b;
};
const summary = (row) => ({
red: row.filter(reddish).length,
white: row.filter((hex) => channels(hex).every((c) => c > 0xd0)).length,
});
const white = summary(await rowAcross());
await select({ kind: 'object', holderId: 'scene1', objectIds: [TEXT_OBJECT], objectId: TEXT_OBJECT });
const shaderRow = await json("__gm.inspectorField('Shader', true)");
const noShader = await locate('None', true);
check(
'a text has a Shader row below its own, and it reads None',
Boolean(shaderRow) && Math.abs(noShader.y - shaderRow.y) < 12 && white.white > 0 && white.red === 0,
JSON.stringify({ shaderRow, noShader, white }),
);
await click(shaderRow.x, shaderRow.y);
await clickLast('Red glyphs (shader)');
const redInfo = await info();
const red = summary(await rowAcross());
check(
'picking a shader draws the text through it: its glyphs red, none of them white',
redInfo?.shader === RED_SHADER.id && red.red > 0 && red.white === 0,
JSON.stringify({ redInfo, red }),
);
await shot('30-font-text-shader');
// 30i. New content is rasterised anew, and the shader stays on it.
await mutate(`${overrides}.text = 'Handgloves Handgloves'`);
const longer = await info();
const redLonger = summary(await rowAcross());
check(
'changing the text keeps the shader on the new glyphs',
longer?.shader === RED_SHADER.id &&
longer.width > redInfo.width &&
redLonger.red > 0 &&
redLonger.white === 0,
JSON.stringify({ longer, redLonger }),
);
// 30i-ii. A shader's Padding gives a text room around its rectangle (#69), in the text's own
// units. The graph fills the whole quad here, so the margin shows as red left of the text.
const shaderGraph = `__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(RED_SHADER.id)}).graph`;
const filled = (padding) =>
mutate(`
const graph = ${shaderGraph};
graph.nodes = graph.nodes.filter((n) => n.id !== 'ft-red-full');
graph.edges = graph.edges.filter((e) => e.id !== 'ft-red-e2');
graph.nodes.push(${JSON.stringify(node('ft-red-full', 'ShaderNumber', 0, 160, { value: 1 }))});
graph.edges.push(${JSON.stringify(edge('ft-red-e2', 'ft-red-full', 'value', 'ft-red-out', 'alpha'))});
Object.assign(graph.nodes.find((n) => n.id === 'ft-red-out').params, { edge: 'transparent', padding: ${padding} });`);
// The red run on the row through the text: where the filled quad begins and ends on screen.
const redSpan = async () => {
const r = await json(`__gm.objectRect(${JSON.stringify(TEXT_OBJECT)})`);
const from = Math.max(0, Math.round(r.x) - 600);
const row = await json(`__gm.pixelRow(${from}, ${Math.round(r.y)}, 1200)`);
const red = row.map(reddish);
return {
first: from + red.indexOf(true),
last: from + red.lastIndexOf(true),
unit: r.w / (await info()).width,
};
};
await evaluate('__gm.state.select(null)');
await filled(0);
const textTight = await redSpan();
await filled(6);
const textRoomy = await redSpan();
await shot('30-font-text-padding');
const reach = 6 * textTight.unit;
check(
'a padded shader draws six units past the text’s rectangle on either side, and none further',
textTight.last > textTight.first &&
Math.abs(textTight.first - textRoomy.first - reach) <= 2 &&
Math.abs(textRoomy.last - textTight.last - reach) <= 2,
JSON.stringify({ textTight, textRoomy, reach }),
);
// 30i-iii. A shader's Vertex output moves a text's corners as it does a sprite's (#79), in the
// text's own units: six of them to the right, and the filled quad stands six further on.
await filled(0);
await mutate(`
const graph = ${shaderGraph};
graph.nodes.push(${JSON.stringify(node('ft-red-by', 'ShaderNumber', 0, 320, { value: 6 }))});
graph.nodes.push(${JSON.stringify(node('ft-red-vout', 'ShaderVertexOutput', 260, 320, { subdivisions: 4 }))});
graph.edges.push(${JSON.stringify(edge('ft-red-e3', 'ft-red-by', 'value', 'ft-red-vout', 'x'))});`);
const textMoved = await redSpan();
check(
'a Vertex output fed 6 draws the text six units to the right of where it stands',
Math.abs(textMoved.first - textTight.first - reach) <= 2 &&
Math.abs(textMoved.last - textTight.last - reach) <= 2,
JSON.stringify({ textTight, textMoved, reach }),
);
await mutate(`
const graph = ${shaderGraph};
graph.nodes = graph.nodes.filter((n) => !['ft-red-full', 'ft-red-by', 'ft-red-vout'].includes(n.id));
graph.edges = graph.edges.filter((e) => !['ft-red-e2', 'ft-red-e3'].includes(e.id));
Object.assign(graph.nodes.find((n) => n.id === 'ft-red-out').params, { edge: 'clamp', padding: 0 });`);
// 30j. The game draws a text through the same picture, shader included. In front of everything:
// in the chained run, earlier steps leave pictures of their own at the camera.
await place('{ x: cam.x, y: cam.y, z: 1000 }');
await play();
await sleep(600);
const playedShaded = await json(`__gm.gameTextInfo(${JSON.stringify(TEXT_OBJECT)})`);
const playedPixels = await rowAcross('gameRect');
const playedRow = summary(playedPixels);
check(
'the running game draws the text through its shader as well',
playedShaded?.shader === RED_SHADER.id && playedRow.red > 0 && playedRow.white === 0,
JSON.stringify({
playedShaded,
playedRow,
rect: await json(`__gm.gameRect(${JSON.stringify(TEXT_OBJECT)})`),
scale: await json('__gm.gameScale()'),
playedPixels,
}),
);
await stop();
await place(`{ x: 0, y: ${LANE}, z: 0 }`);
// 30k. The font's Filter (#169 comment): Nearest rasterises a pixel per unit and magnifies without
// smoothing. At scale 4 a sharp glyph is blocks of four screen pixels, a smooth one blends
// its edges – so the shortest run of one colour inside the row tells them apart.
// Nothing selected: the outline of a selected text would cross the row with runs of its own.
await mutate(`Object.assign(${overrides}, { shader: '', text: 'Hg', fontSize: 16, scale: 4 })`);
await evaluate('__gm.state.select(null)');
// The scene's grid shows through where the text is transparent, in runs of its own, so everything
// darker than the glyphs counts as one background.
const shortestRun = (pixels) => {
const row = pixels.map((hex) => (channels(hex)[0] > 0x60 ? hex : 'background'));
const runs = [];
let length = 1;
for (let i = 1; i < row.length; i++) {
if (row[i] === row[i - 1]) length++;
else {
runs.push(length);
length = 1;
}
}
// The first and the last run are cut off by the ends of the row.
return Math.min(...runs.slice(1));
};
const smoothInfo = await info();
const smoothRow = await rowAcross();
const smoothRun = shortestRun(smoothRow);
await select({ kind: 'asset', assetId: fontId });
const filterRow = await json("__gm.inspectorField('Filter', true)");
const linear = await locate('Linear', true);
check(
'a font has a Filter row, Linear by default, and its text blends the edges of its glyphs',
Boolean(filterRow) && Math.abs(linear.y - filterRow.y) < 12 && smoothInfo?.smooth && smoothRun <= 2,
JSON.stringify({ filterRow, linear, smoothInfo, smoothRun, smoothRow }),
);
await click(filterRow.x, filterRow.y);
await clickLast('Nearest');
const filter = await json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fontId)}).props.filter`,
);
const sharpInfo = await info();
const sharpRow = await rowAcross();
const sharpRun = shortestRun(sharpRow);
check(
'Nearest draws its text a pixel per unit, magnified in blocks',
filter === 'nearest' && sharpInfo?.smooth === false && sharpInfo.resolution === 1 && sharpRun >= 3,
JSON.stringify({ filter, sharpInfo, sharpRun, sharpRow }),
);
await shot('30-font-nearest');
// 30k-ii. …and its texels are hard: rasterised finer and cut at half coverage, so no glyph edge is
// left half covered – Chrome on Windows smooths canvas text sideways, whatever the filter.
// The fixture's face is no pixel font, so rasterised 1:1 its edges would be half covered here too.
check(
'Nearest leaves no half-covered texel in the text, the smooth face does',
smoothInfo?.partial > 0 && sharpInfo?.partial === 0,
JSON.stringify({ smooth: smoothInfo?.partial, sharp: sharpInfo?.partial }),
);
// 30l. The step takes back everything it made, so the steps after it see the fixture again.
await mutate(`
const gone = new Set([${JSON.stringify(TEXT_ASSET)}, ${JSON.stringify(RED_SHADER.id)},
...${JSON.stringify(both.map((a) => a.id))}]);
const project = __gm.state.project;
project.assets = project.assets.filter((a) => !gone.has(a.id));
__gm.state.activeScene.objects = __gm.state.activeScene.objects.filter(
(o) => o.id !== ${JSON.stringify(TEXT_OBJECT)});
__gm.state.select(null);
__gm.lookAt(0, 0);`);
check(
'the step leaves nothing of its own behind',
(await assetsOfType('font')).length === 0 && (await info()) === null,
JSON.stringify(await assetsOfType('font')),
);
}