Fourth in a series on using @relax.js/core with a coding agent. This is about the failures that make no noise.
The blank element
A template engine has to decide what to do with {{user.naem}} when user has no naem. Throwing means one typo blanks the whole page, so like most engines this one renders an empty string and moves on. That is the right call for a user in a browser.
It is the wrong default for an agent. The agent's only view of the page is a test. It writes the component, writes the test, runs it, and sees:
it('passes_on_a_blank_element_because_nobody_read_the_errors', () => {
const { content, render } = compileTemplate('<p>{{user.naem}}</p>');
render({ user: { name: 'Alice' } });
expect(content.querySelector('p')).not.toBeNull();
});
Green. The <p> exists. It is empty, and nothing said why. This test is in the example app on purpose, as the thing not to write.
One channel
Every failure the library detects goes through one function, reportError(), which builds a RelaxError with a message and a context object and hands it to whatever handler onError() registered. In the application that handler logs to your service or shows a toast. If nothing is registered, the error is still kept: window.relaxErrors holds the last fifty, and the first unhandled one prints a single line to the console naming that array. Once per page load. It is a signpost, not noise.
The design rule behind it, which the agent-facing docs state outright: diagnostics go in values, not in log lines. A human watches a console. An agent reads what a function returned or what a test printed. An error object with { expression, location } on it is something a test can assert on; twelve console.log lines describing a render are not.
In a test
captureRelaxErrors() from @relax.js/core/testing swaps in a handler that collects instead of throwing, and gives it back with restore():
let captured: CapturedErrors;
beforeEach(() => {
captured = captureRelaxErrors();
});
afterEach(() => {
captured.restore();
});
Now the same typo is a failing assertion, with the reason in the message:
it('a_mistyped_path_renders_empty_and_reports', () => {
const { content, render } = compileTemplate('<p>{{user.naem}}</p>');
render({ user: { name: 'Alice' } });
expect(content.querySelector('p')?.textContent).toBe('');
expect(captured.messages()[0]).toContain('Cannot resolve "user.naem"');
});
The testing skill says to assert captured.messages() is empty even in tests that are about something else, and every test in the example's page suite ends with that line. It is the cheapest assertion in the file and the one that catches the most.
The failures that produced no DOM and no error
Once the channel existed, I went looking for everything that used to fail without going through it. Version 1.8.0's changelog is the list. Four of them are in the example app as tests.
render() compares the context by identity. Mutate the object and render it again and nothing changes, because from the engine's side nothing did:
it('rendering_the_same_object_twice_changes_nothing_and_reports', () => {
const state = { count: 1 };
const { content, render } = compileTemplate('<p>{{count}}</p>');
render(state);
state.count = 2;
render(state);
expect(content.querySelector('p')?.textContent).toBe('1');
expect(captured.messages()[0]).toContain('render() was given the same context object');
});
An agent coming from Vue writes exactly this and expects reactivity to notice. The message tells it what to do instead: pass a new object, render({ ...state }).
A handler needs parentheses. r-click="save" binds nothing:
it('a_handler_without_parentheses_is_not_bound_and_reports', () => {
const { render } = compileTemplate('<button r-click="save">Save</button>');
render({}, { save: () => undefined });
expect(captured.messages()[0]).toContain('r-click must be a function call, got "save"');
});
The html tagged literal gives one instance per literal. Binding it twice re-drives the first one and returns an empty fragment, so the second card never appears:
it('binding_an_html_template_twice_redrives_the_first_instance_and_reports', () => {
const { element } = mount(document.createElement('div'));
const card = html`<p>{{name}}</p>`;
element.appendChild(card({ name: 'Alice' }).fragment);
element.appendChild(card({ name: 'Bob' }).fragment);
expect(element.querySelectorAll('p')).toHaveLength(1);
expect(element.querySelector('p')?.textContent).toBe('Bob');
expect(captured.messages()[0]).toContain('This html template was already bound');
});
And a route pointing at a tag that was never defined fails at the moment the routes are defined, not later when someone navigates:
it('a_route_whose_tag_was_never_defined_fails_when_routes_are_defined', () => {
expect(() =>
mountRouting([{ name: 'missing', path: '/missing', componentTagName: 'profile-pgae' }]),
).toThrow("Component with tagName 'profile-pgae' is not defined in customElements.");
});
That last one throws rather than reports, because at definition time there is no page to keep alive and failing fast is free.
Strict when you want it
The template engine takes { strict: true }, and then every reported template error throws instead. I do not use it in application code, for the reason at the top: one typo should not blank the page for a user. In a test the capture is better than strict, because it collects everything instead of stopping at the first.
The remaining question is the one that bothered me most. Everything above happens when the template renders. The agent still has to write the test that renders it, with the right model, and remember the capture. The sixth article is about catching the typo before anything renders at all. Before that, the rest of the test seam: how the agent gets a page on screen without a browser.
Top comments (0)