{"slug": "how-an-agent-verifies-a-webpage-it-can-never-look-at", "title": "How an agent verifies a webpage it can never look at", "summary": "A developer detailed a testing approach for coding agents working with Web Components in the @relax.js/core library, introducing mount() and flush() helpers from @relax.js/core/testing to handle lifecycle timing issues that cause assertions to fail on unattached or unhydrated elements. The writeup also describes fakeServer(), which intercepts the library's HTTP module to return canned responses and record every request, letting tests verify both rendered output and network calls without stubbing fetch globally.", "body_md": "Fifth in a series on using [@relax.js/core](https://www.npmjs.com/package/@relax.js/core) with a coding agent. The previous piece was about seeing errors in a test. This one is about getting the component into a state where there is something to see.\n\nEverything here is from `@relax.js/core/testing`. \n\nAn agent that knows Web Components will write this and be puzzled:\n\n``` js\nconst page = document.createElement('profile-page');\nexpect(page.querySelector('form')).not.toBeNull(); // null\n```\n\n`connectedCallback` only runs when the element is in a document. Created and never attached, it is inert, for reasons that have nothing to do with the component. And once attached, anything it `await` s finishes a task later, so asserting right after attaching sees the element before its data arrived.\n\nTwo helpers cover this. `mount()` attaches to `document.body` and hands back `unmount()`, which is also how you test `disconnectedCallback`:\n\n``` js\nit('stops_listening_once_removed_from_the_page', () => {\n    const { element, unmount } = mount('profile-header');\n    unmount();\n\n    document.dispatchEvent(new ProfileSavedEvent('42', 'Alice B'));\n    expect(element.querySelector('.display-name')?.textContent).toBe('');\n});\n```\n\n`flush()` waits for pending microtasks and the next macrotask, so work started by a lifecycle callback has landed. You will see it after every user action in the page tests below.\n\nThe page loads its data through `@relax.js/core/http`. `fakeServer()` replaces the network underneath that module with canned responses and records every request:\n\n``` js\nbeforeEach(() => {\n    configure({ baseUrl: '/api' });\n    server = fakeServer().on('GET', '/api/users/42', alice);\n    routing = mountRouting(routes);\n    captured = captureRelaxErrors();\n});\n\nafterEach(() => {\n    captured.restore();\n    routing.unmount();\n    server.restore();\n});\n```\n\nPaths include the configured base URL and exclude the query string, because that is what a server sees. A request nothing was registered for gets a 404 whose body lists what is registered, and it is recorded like any other, so an unexpected call shows up in `server.requests` instead of hanging or reaching the network.\n\nThe seam is deliberate. Do not stub `fetch` globally, and do not mock the component's own `load()` method. The component under test is the real one, and the assertion covers both what it rendered and what it asked for:\n\n``` js\nit('fills_the_form_from_the_server_when_navigated_to', async () => {\n    const page = await routing.navigate<ProfilePage>('profile', { params: { userId: '42' } });\n\n    const email = page.querySelector<HTMLInputElement>('input[name=\"email\"]')!;\n    expect(email.value).toBe('alice@example.com');\n    expect(server.requests.map((r) => `${r.method} ${r.path}`)).toEqual(['GET /api/users/42']);\n    expect(captured.messages()).toEqual([]);\n});\n```\n\nA function body is called with the request, which is how a `PUT` answers with what it was sent:\n\n``` js\nit('submit_sends_the_edited_form_and_announces_the_save', async () => {\n    server.on('PUT', '/api/users/42', (request) => request.json());\n    const page = await routing.navigate<ProfilePage>('profile', { params: { userId: '42' } });\n    const saved: ProfileSavedEvent[] = [];\n    page.addEventListener(ProfileSavedEvent.type, (e) => saved.push(e));\n\n    page.querySelector<HTMLInputElement>('input[name=\"displayName\"]')!.value = 'Alice B';\n    page.querySelector('form')!.requestSubmit();\n    await flush();\n\n    const put = server.requests.find((r) => r.method === 'PUT')!;\n    expect(put.json()).toEqual({ ...alice, displayName: 'Alice B' });\n    expect(saved.map((e) => e.displayName)).toEqual(['Alice B']);\n    expect(page.querySelector('.status')?.textContent).toBe('Saved');\n    expect(captured.messages()).toEqual([]);\n});\n```\n\nNote `requestSubmit()`, not `submit()`. The latter skips the `submit` event, and the `FormValidator` that owns the form never hears about it. When an agent picks the wrong one, the test says so: the `PUT` is missing from `server.requests`.\n\nThe failure path is one more `on()` with a status:\n\n``` js\nit('a_rejected_save_lands_in_the_error_summary_instead_of_throwing', async () => {\n    server.on('PUT', '/api/users/42', { message: 'nope' }, 409);\n    const page = await routing.navigate<ProfilePage>('profile', { params: { userId: '42' } });\n\n    page.querySelector('form')!.requestSubmit();\n    await flush();\n\n    expect(page.querySelector('.status')?.textContent).toBe('');\n    expect(page.textContent).toContain('The server rejected the change (409)');\n    expect(captured.messages()).toEqual([]);\n});\n```\n\nRegistration order matters: the first match wins. That is why the `PUT` is registered per test and not in `beforeEach`.\n\nThe page above is reached with `routing.navigate()`, not `mount()`. The difference is everything a page depends on: `loadRoute()` runs with the route parameters before the element is added, the element lands inside `<r-route-target>`, and `routeData` is set. `mountRouting()` registers the routes and puts the targets in the document; its `navigate()` resolves with the rendered component once it is inside the target, after all of that. No `flush()` needed for the load itself.\n\nIt rejects the way `navigate()` throws in the application: no route matched, or a guard stopped it. When the component never appears within the timeout, the rejection lists the errors reported meanwhile, so a `loadRoute()` that threw is named instead of leaving you with an empty target.\n\nThe routes it takes are the app's own:\n\n``` js\nexport const routes: Route[] = [\n    { name: 'profile', path: '/profile/:userId', componentTagName: 'profile-page' },\n];\n```\n\nOne thing this exposed while I was writing the example, and I mentioned it in the third article: the test must import the page module for its side effect. A type-only import is elided, `profile-page` is never defined, and `mountRouting` throws at once with the tag name.\n\nRouting targets, the error handler and the fetch replacement are module-level state. Every helper returns the means to undo itself: `unmount()`, `restore()`. Call them in `afterEach` or a `finally`, so one test's leftovers cannot explain the next test's failure. The `afterEach` above is the whole ritual.\n\nPut together: `fakeServer()` for the data, `mountRouting()` to reach the page, `captureRelaxErrors()` to prove the template resolved, `flush()` after each user action. Four tests cover the example page end to end and finish in milliseconds. That is the loop the agent runs after every edit, and it is the only loop it has.\n\nIt still relies on the template being rendered with the right model before a typo shows up. The next article removes that dependency.", "url": "https://wpnews.pro/news/how-an-agent-verifies-a-webpage-it-can-never-look-at", "canonical_source": "https://dev.to/jgauffin/how-an-agent-verifies-a-webpage-it-can-never-look-at-33mh", "published_at": "2026-09-30 05:27:21+00:00", "updated_at": "2026-09-30 05:46:42.093330+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "ai-tools"], "entities": ["@relax.js/core", "relax.js", "ProfileSavedEvent", "FormValidator"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-an-agent-verifies-a-webpage-it-can-never-look-at", "markdown": "https://wpnews.pro/news/how-an-agent-verifies-a-webpage-it-can-never-look-at.md", "text": "https://wpnews.pro/news/how-an-agent-verifies-a-webpage-it-can-never-look-at.txt", "jsonld": "https://wpnews.pro/news/how-an-agent-verifies-a-webpage-it-can-never-look-at.jsonld"}}