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.