When the UI reads you: testing interfaces that infer your state
Jul 11, 2026
Sentient UX is the idea of reading behavioral signals, language, context (time, location, history) to infer what state the user is in and adapt the interface accordingly. Components that appear and disappear, hierarchies that change, options that simplify when they detect overload. Spotify reorganizes its home page based on what you actually listen to (and what it wants you to listen to). The limit is the uncanny valley: past a certain point, an interface that’s “too human” gets unsettling instead of helpful.
I built this idea into this very site: a local module (no backend, all in localStorage) that distinguishes a new visitor from a returning one, and reading pace (scan vs explore) based on how much they scroll in the first few seconds. Nothing gets sent anywhere, and there’s never a message like “we noticed that…”. That last part isn’t just a design preference: it’s what makes this testable without it being obvious.
The real problem: there’s no single state to test
A traditional UI test loads a page and asserts on what it sees. This UI has the same initial HTML for everyone, but ends up looking different depending on signals that depend on browser history and behavior during the session. If your suite only knows how to load the page and look at the DOM, every run can land in a different state without you having decided that.
The fix isn’t simulating real users scrolling like crazy. It’s seeding the state directly:
await context.addInitScript(() => {
localStorage.setItem('qa-notes:readCategories', JSON.stringify(['Tools']));
});
That forces the reading history without depending on the test navigating through three posts first. Determinism before realism.
The pace heuristic (scan/explore) is worse: it depends on time
My “pace” heuristic re-evaluates every 6 seconds via setInterval. Writing a test that waits 6 real seconds per assertion is slow and brittle (what happens if CI is slow and the scroll takes longer?). Playwright has a clock API (page.clock) built for exactly this: you install the clock before navigating, perform the action (a big scroll, or none), and fast-forward time synchronously:
await page.clock.install();
await page.goto('/about');
await page.mouse.wheel(0, 5000);
await page.clock.fastForward(6000);
await expect(page.locator('html')).toHaveAttribute('data-pace', 'scan');
Without this, any logic based on setTimeout/setInterval forces you to choose between slow tests or tests that guess at timing with waitForTimeout and flake the day CI is busy.
The silent risk: nobody sees the same bug
Because the adaptation is ambient (no explicit message), a personalization bug doesn’t break anything visually obvious. If the “new” badge shows up on the wrong post, or post ordering breaks for one specific category, no user reports anything odd because each user sees a different version and has nothing to compare it against. Test coverage has to be more explicit exactly where the product is more implicit.