cd /news/developer-tools/how-an-agent-verifies-a-webpage-it-c… · home › topics › developer-tools › article
[ARTICLE · art-142302] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

How an agent verifies a webpage it can never look at

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.

by read4 min views3 publishedSep 30, 2026

Fifth in a series on using @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.

Everything here is from @relax.js/core/testing.

An agent that knows Web Components will write this and be puzzled:

const page = document.createElement('profile-page');
expect(page.querySelector('form')).not.toBeNull(); // null

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.

Two helpers cover this. mount() attaches to document.body and hands back unmount(), which is also how you test disconnectedCallback:

it('stops_listening_once_removed_from_the_page', () => {
    const { element, unmount } = mount('profile-header');
    unmount();

    document.dispatchEvent(new ProfileSavedEvent('42', 'Alice B'));
    expect(element.querySelector('.display-name')?.textContent).toBe('');
});

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.

The page loads its data through @relax.js/core/http. fakeServer() replaces the network underneath that module with canned responses and records every request:

beforeEach(() => {
    configure({ baseUrl: '/api' });
    server = fakeServer().on('GET', '/api/users/42', alice);
    routing = mountRouting(routes);
    captured = captureRelaxErrors();
});

afterEach(() => {
    captured.restore();
    routing.unmount();
    server.restore();
});

Paths 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.

The 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:

it('fills_the_form_from_the_server_when_navigated_to', async () => {
    const page = await routing.navigate<ProfilePage>('profile', { params: { userId: '42' } });

    const email = page.querySelector<HTMLInputElement>('input[name="email"]')!;
    expect(email.value).toBe('alice@example.com');
    expect(server.requests.map((r) => `${r.method} ${r.path}`)).toEqual(['GET /api/users/42']);
    expect(captured.messages()).toEqual([]);
});

A function body is called with the request, which is how a PUT answers with what it was sent:

it('submit_sends_the_edited_form_and_announces_the_save', async () => {
    server.on('PUT', '/api/users/42', (request) => request.json());
    const page = await routing.navigate<ProfilePage>('profile', { params: { userId: '42' } });
    const saved: ProfileSavedEvent[] = [];
    page.addEventListener(ProfileSavedEvent.type, (e) => saved.push(e));

    page.querySelector<HTMLInputElement>('input[name="displayName"]')!.value = 'Alice B';
    page.querySelector('form')!.requestSubmit();
    await flush();

    const put = server.requests.find((r) => r.method === 'PUT')!;
    expect(put.json()).toEqual({ ...alice, displayName: 'Alice B' });
    expect(saved.map((e) => e.displayName)).toEqual(['Alice B']);
    expect(page.querySelector('.status')?.textContent).toBe('Saved');
    expect(captured.messages()).toEqual([]);
});

Note 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.

The failure path is one more on() with a status:

it('a_rejected_save_lands_in_the_error_summary_instead_of_throwing', async () => {
    server.on('PUT', '/api/users/42', { message: 'nope' }, 409);
    const page = await routing.navigate<ProfilePage>('profile', { params: { userId: '42' } });

    page.querySelector('form')!.requestSubmit();
    await flush();

    expect(page.querySelector('.status')?.textContent).toBe('');
    expect(page.textContent).toContain('The server rejected the change (409)');
    expect(captured.messages()).toEqual([]);
});

Registration order matters: the first match wins. That is why the PUT is registered per test and not in beforeEach.

The 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.

It 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.

The routes it takes are the app's own:

export const routes: Route[] = [
    { name: 'profile', path: '/profile/:userId', componentTagName: 'profile-page' },
];

One 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.

Routing 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.

Put 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.

It still relies on the template being rendered with the right model before a typo shows up. The next article removes that dependency.

── more in #developer-tools 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/how-an-agent-verifie…] indexed:0 read:4min 2026-09-30 · —