Testing streaming AI on localhost is comfortable in the way a rehearsal is comfortable: the network is fast, the browser is yours, and the keyboard is always present. The moment you open the same interface on a phone without an Escape key, or on a screen reader that never hears the stream end, the comfortable assumptions turn into user-facing bugs. A small, keyboard-operable stream tester deployed on a free server closes that gap, and a free model endpoint makes the test repeatable enough to matter.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. I use MonkeyCode's free model access as the streaming source and its free server option to host the tester; these availability statements come from the operator, and model names, quotas, and durations are whatever your console shows, not something I will invent here.
Think of a streaming response as a sentence that arrives word by word. The interface has to show the words, but it also has to mark the end, the interruption, and the retry clearly. If a cancel action only calls abort() but leaves the live region silent, a screen reader user may keep hearing the old tokens as if the phrase were still being spoken. If the only way to stop the stream is a keyboard shortcut, a mobile user has no way out. A useful harness treats those failures as first-class scenarios, not as edge cases to check after launch.
The following single-file page is that harness. It accepts a prompt, starts a streaming request, accumulates the response, announces status changes through a polite live region, and gives you visible Cancel and Retry controls in addition to the Escape key. The endpoint is configurable through a query parameter so you can point it at the actual URL from your MonkeyCode console; when no URL is supplied, the page falls back to a small mock stream so the harness still works offline.
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<title>Stream tester</title>
<style>
:root { font-family: system-ui, sans-serif; line-height: 1.5; }
main { max-width: 42rem; margin: 2rem auto; padding: 0 1rem; }
label, textarea, button, output { display: block; margin: 0.5rem 0; }
textarea { width: 100%; min-height: 4rem; }
button { min-height: 2.5rem; padding: 0.5rem 0.75rem; }
button:focus-visible, textarea:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }
#announce { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
#output { white-space: pre-wrap; border: 1px solid #ccc; padding: 0.75rem; min-height: 6rem; }
</style>
</head>
<body>
<main>
<h1>Stream tester</h1>
<label for='prompt'>Prompt</label>
<textarea id='prompt' rows='3'>Say hello in three short sentences.</textarea>
<div id='announce' role='status' aria-live='polite'></div>
<button id='run'>Run stream</button>
<button id='cancel' disabled>Cancel</button>
<button id='retry' disabled>Retry</button>
<output id='output' aria-label='Stream output'></output>
</main>
<script>
const prompt = document.getElementById('prompt');
const run = document.getElementById('run');
const cancel = document.getElementById('cancel');
const retry = document.getElementById('retry');
const output = document.getElementById('output');
const announce = document.getElementById('announce');
let controller = null;
let lastPrompt = '';
function say(message) {
announce.textContent = '';
requestAnimationFrame(() => {
announce.textContent = message;
});
}
function setBusy(busy) {
run.disabled = busy;
cancel.disabled = !busy;
retry.disabled = busy || !lastPrompt;
}
async function getResponse(promptValue, signal) {
const streamUrl = new URLSearchParams(location.search).get('stream');
if (streamUrl) {
return fetch(streamUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: promptValue }),
signal
});
}
const tokens = ['Hello', ' from', ' a', ' mock', ' stream', '.', 'It', ' ends', ' here', '.'];
return new Response(new ReadableStream({
start(ctrl) {
let index = 0;
const timer = setInterval(() => {
if (index >= tokens.length) {
clearInterval(timer);
ctrl.close();
return;
}
ctrl.enqueue(new TextEncoder().encode(tokens[index]));
index += 1;
}, 180);
}
}), { status: 200 });
}
async function start() {
const promptValue = prompt.value.trim();
if (!promptValue) return;
lastPrompt = promptValue;
output.textContent = '';
controller = new AbortController();
setBusy(true);
say('Stream started');
run.focus();
try {
const response = await getResponse(lastPrompt, controller.signal);
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
buffer = buffer.replace(/^data: */gm, '');
output.textContent = buffer;
}
say('Stream finished');
} catch (error) {
if (error.name === 'AbortError') {
say('Stream cancelled');
} else {
say(`Stream failed: ${error.message}`);
}
} finally {
controller = null;
setBusy(false);
cancel.focus();
}
}
function stop() {
if (controller) {
controller.abort();
}
}
run.addEventListener('click', start);
cancel.addEventListener('click', stop);
retry.addEventListener('click', () => {
prompt.value = lastPrompt;
start();
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && controller) {
stop();
}
});
</script>
</body>
</html>
Replace ?stream=... with the endpoint your console provides. The mock mode emits short tokens every 180 milliseconds, which is just enough to hear the stream start, finish, and cancel under a screen reader. The state announcement uses a requestAnimationFrame-cleared live region because some screen readers ignore rapid consecutive updates to the same live region; clearing the text first, then writing the new message on the next frame, gives assistive technology a better chance to speak a state change instead of swallowing it.
Deploy this file to MonkeyCode's free server option or any static HTTPS host. The point is to open it from a real device browser rather than localhost, because mobile browsers have no Escape key, and the visible Cancel button becomes your primary escape hatch. When you press Escape on desktop, the same code path runs as the button, so you are testing one behavior in two input modalities.
One test pass worth repeating: run the stream once and let it finish, run it again and cancel after the first tokens, and then run it with the device in airplane mode to force the fetch failure. After each transition, ask whether the live region announced the state clearly, whether focus landed somewhere sensible, and whether Retry remembers your last prompt without putting stale text back on screen. If any of those fail on a phone but pass on the desktop, you have found a real device-specific interaction bug, not a theoretical accessibility concern.
The limitations are important. Free model access is often rate-limited and can change latency, so this tester is not a benchmark. The code assumes the endpoint returns streaming text or Server-Sent Events; if your endpoint returns a single JSON blob, the reader loop will not split tokens and you will need a different parser before the live region becomes useful. Do not put a long-lived secret in the page if the free server serves it publicly; use a short-lived token behind a same-origin proxy instead. And this harness checks interaction behavior, not model quality, quotas, or throughput.
Who should skip this approach? If you already have Playwright or axe integrated into a test suite, adding these scenarios there may be less setup than maintaining a separate page. If you need a repeatable load test or a production service level agreement, a free endpoint is the wrong substrate. If you are comparing vendors on exact model versions, retention limits, or data residency, get those details from MonkeyCode directly rather than inferring them from a demo.
If you have a free model endpoint available, put this tester on a free server and run the three passes on a desktop and a phone. You will spot focus and announcement problems before users do, and you will know that the escape hatch works in the only environment that matters: the one your users carry.
Top comments (0)