cd /news/ai-agents/a-blank-page-and-a-green-test-the-bu… · home › topics › ai-agents › article
[ARTICLE · art-139594] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

A blank page and a green test: the bug an agent can't see

A developer building the @relax.js/core template engine documented how silent rendering failures — such as a mistyped path like {{user.naem}} rendering an empty string — can pass an agent-written test green while hiding the real error. The library routes all detected failures through a single reportError() function that produces structured RelaxError objects with message and context, and version 1.8.0 adds a captureRelaxErrors() testing helper so tests can assert on captured error messages rather than console output. The design rule, per the agent-facing docs, is that diagnostics go in values, not log lines, so an agent can read what a function returned or what a test printed.

by read4 min views2 publishedSep 25, 2026

Fourth in a series on using @relax.js/core with a coding agent. This is about the failures that make no noise.

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.

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.

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.

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.

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.

── more in #ai-agents 4 stories · sorted by recency
── more on @@relax.js/core 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/a-blank-page-and-a-g…] indexed:0 read:4min 2026-09-25 · —