DEV Community

Jonas Gauffin
Jonas Gauffin

Posted on

How an agent verifies a webpage it can never look at

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.

Why plain jsdom is not enough

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
Enter fullscreen mode Exit fullscreen mode

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 awaits 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('');
});
Enter fullscreen mode Exit fullscreen mode

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.

Fake the server, not the component

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();
});
Enter fullscreen mode Exit fullscreen mode

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([]);
});
Enter fullscreen mode Exit fullscreen mode

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([]);
});
Enter fullscreen mode Exit fullscreen mode

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([]);
});
Enter fullscreen mode Exit fullscreen mode

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

Navigate like the app

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' },
];
Enter fullscreen mode Exit fullscreen mode

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.

Reset between tests

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.

The page test

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.

Top comments (2)

Collapse
 
mythex profile image
Mythex •

Faking the server underneath the http module is the right seam: the agent tests the page the way the app uses it, and it can't make a test pass by mocking the component itself.

One thing we learned running a coding agent on web apps (I'm building Mythex, an AI app builder): the recorded requests are often a better assertion than the DOM. When the agent calls the wrong endpoint, a DOM check just reports an empty list, and the agent goes off fixing the rendering. An assertion on which requests were made points straight at the real bug. Do you put the captured requests into the failure message?

Some comments may only be visible to logged-in visitors. Sign in to view all comments.