{"slug": "a-blank-page-and-a-green-test-the-bug-an-agent-can-t-see", "title": "A blank page and a green test: the bug an agent can't see", "summary": "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.", "body_md": "Fourth in a series on using [@relax.js/core](https://www.npmjs.com/package/@relax.js/core) with a coding agent. This is about the failures that make no noise.\n\nA 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.\n\nIt 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:\n\n``` js\nit('passes_on_a_blank_element_because_nobody_read_the_errors', () => {\n    const { content, render } = compileTemplate('<p>{{user.naem}}</p>');\n    render({ user: { name: 'Alice' } });\n\n    expect(content.querySelector('p')).not.toBeNull();\n});\n```\n\nGreen. 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.\n\nEvery 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.\n\nThe 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.\n\n`captureRelaxErrors()` from `@relax.js/core/testing` swaps in a handler that collects instead of throwing, and gives it back with `restore()`:\n\n``` js\nlet captured: CapturedErrors;\n\nbeforeEach(() => {\n    captured = captureRelaxErrors();\n});\n\nafterEach(() => {\n    captured.restore();\n});\n```\n\nNow the same typo is a failing assertion, with the reason in the message:\n\n``` js\nit('a_mistyped_path_renders_empty_and_reports', () => {\n    const { content, render } = compileTemplate('<p>{{user.naem}}</p>');\n    render({ user: { name: 'Alice' } });\n\n    expect(content.querySelector('p')?.textContent).toBe('');\n    expect(captured.messages()[0]).toContain('Cannot resolve \"user.naem\"');\n});\n```\n\nThe 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.\n\nOnce 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.\n\n`render()` compares the context by identity. Mutate the object and render it again and nothing changes, because from the engine's side nothing did:\n\n``` js\nit('rendering_the_same_object_twice_changes_nothing_and_reports', () => {\n    const state = { count: 1 };\n    const { content, render } = compileTemplate('<p>{{count}}</p>');\n    render(state);\n    state.count = 2;\n    render(state);\n\n    expect(content.querySelector('p')?.textContent).toBe('1');\n    expect(captured.messages()[0]).toContain('render() was given the same context object');\n});\n```\n\nAn 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 })`.\n\nA handler needs parentheses. `r-click=\"save\"` binds nothing:\n\n``` js\nit('a_handler_without_parentheses_is_not_bound_and_reports', () => {\n    const { render } = compileTemplate('<button r-click=\"save\">Save</button>');\n    render({}, { save: () => undefined });\n\n    expect(captured.messages()[0]).toContain('r-click must be a function call, got \"save\"');\n});\n```\n\nThe `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:\n\n```\nit('binding_an_html_template_twice_redrives_the_first_instance_and_reports', () => {\n    const { element } = mount(document.createElement('div'));\n    const card = html`<p>{{name}}</p>`;\n    element.appendChild(card({ name: 'Alice' }).fragment);\n    element.appendChild(card({ name: 'Bob' }).fragment);\n\n    expect(element.querySelectorAll('p')).toHaveLength(1);\n    expect(element.querySelector('p')?.textContent).toBe('Bob');\n    expect(captured.messages()[0]).toContain('This html template was already bound');\n});\n```\n\nAnd a route pointing at a tag that was never defined fails at the moment the routes are defined, not later when someone navigates:\n\n``` js\nit('a_route_whose_tag_was_never_defined_fails_when_routes_are_defined', () => {\n    expect(() =>\n        mountRouting([{ name: 'missing', path: '/missing', componentTagName: 'profile-pgae' }]),\n    ).toThrow(\"Component with tagName 'profile-pgae' is not defined in customElements.\");\n});\n```\n\nThat last one throws rather than reports, because at definition time there is no page to keep alive and failing fast is free.\n\nThe 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.\n\nThe 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.", "url": "https://wpnews.pro/news/a-blank-page-and-a-green-test-the-bug-an-agent-can-t-see", "canonical_source": "https://dev.to/jgauffin/a-blank-page-and-a-green-test-the-bug-an-agent-cant-see-3noo", "published_at": "2026-09-25 10:55:27+00:00", "updated_at": "2026-09-25 11:00:58.016247+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools"], "entities": ["@relax.js/core", "RelaxError", "captureRelaxErrors", "window.relaxErrors", "reportError", "onError", "Vue"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/a-blank-page-and-a-green-test-the-bug-an-agent-can-t-see", "markdown": "https://wpnews.pro/news/a-blank-page-and-a-green-test-the-bug-an-agent-can-t-see.md", "text": "https://wpnews.pro/news/a-blank-page-and-a-green-test-the-bug-an-agent-can-t-see.txt", "jsonld": "https://wpnews.pro/news/a-blank-page-and-a-green-test-the-bug-an-agent-can-t-see.jsonld"}}