How to test unicode input
Serve a form from a local server, type a Cyrillic, combining mark and emoji value into it with Chrome, submit, and read the stored value back as code points. The round trip should be identical. A field with maxlength stored 4 of 11 code units and dropped an accent.
Why check this
Input handling breaks in places a screenshot cannot show. The value looks right in the field and wrong in the database, or right in the database and wrong after an export. Reading code points on both ends is the only comparison that survives a font substitution and a terminal that cannot render the character.
Run this when a form gains a length limit, when a field becomes searchable, and before any release that opens sign-up to a new market. The failure it catches is a user whose display name is stored with the accent removed, who then cannot find their own record, because the search term still carries the accent.
Prerequisites
- Node 22 and Chrome 152, plus
npm i puppeteer-core, which installs the driver and no browser. SetCHROMEif yours is elsewhere. The browser figures below are one capture on one machine, on 2026-09-12. - A local target, because this needs a form, a submit and a store that you control. Localhost has no proxy and no CDN in it, so a fault this catches is in your code, not in the network.
server.mjs, a form with three limited fields and an endpoint that echoes what it stored. Start it withnode server.mjs.
import { createServer } from 'node:http';
const FORM = `<!doctype html><html lang="en"><meta charset="utf-8"><title>input</title>
<form method="post" action="/save" accept-charset="utf-8">
<label>full <input name="full" id="full" size="40"></label>
<label>emoji <input name="emoji" id="emoji" maxlength="5"></label>
<label>cut <input name="cut" id="cut" maxlength="4"></label>
<label>accent <input name="accent" id="accent" maxlength="4"></label>
<button id="send">Save</button></form>`;
let stored = {};
createServer((req, res) => {
if (req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
stored = Object.fromEntries(new URLSearchParams(body));
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end('<!doctype html><html lang="en"><meta charset="utf-8"><p id="ok">saved</p>');
});
return;
}
if (req.url === '/read') {
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(stored));
return;
}
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(FORM);
}).listen(8973, '127.0.0.1', () => console.log('listening on 127.0.0.1:8973'));
drive.mjs, which types into the form with a real browser rather than posting the body directly. Typing is the point: a POST built by a script never meets the input element.
// drive.mjs
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const family = String.fromCodePoint(0x1f468, 0x200d, 0x1f469, 0x200d, 0x1f467, 0x200d, 0x1f466);
const accent = String.fromCodePoint(99, 97, 102, 101, 0x301);
const full = 'Київ ' + accent + ' ' + family;
const points = (s) => [...s].map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');
const lone = (s) => /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(s);
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
await page.goto('http://127.0.0.1:8973/', { waitUntil: 'networkidle2' });
await page.type('#full', full);
await page.type('#emoji', family);
await page.type('#cut', family);
await page.type('#accent', accent);
await Promise.all([page.click('#send'), page.waitForNavigation()]);
const typed = await page.evaluate(() => fetch('/read').then((r) => r.json()));
console.log('full typed', full.length, 'units, stored', typed.full.length, 'identical', typed.full === full);
console.log('emoji maxlength=5, typed', family.length, 'units, stored', typed.emoji.length);
console.log(' ', points(typed.emoji), 'lone surrogate', lone(typed.emoji));
console.log('cut maxlength=4, typed', family.length, 'units, stored', typed.cut.length);
console.log(' ', points(typed.cut), 'prefix of input', family.startsWith(typed.cut));
console.log('accent maxlength=4, typed', accent.length, 'units, stored', typed.accent.length);
console.log(' ', points(typed.accent), JSON.stringify(typed.accent));
await page.goto('http://127.0.0.1:8973/', { waitUntil: 'networkidle2' });
await page.evaluate((v) => { document.querySelector('#cut').value = v; }, family);
await Promise.all([page.click('#send'), page.waitForNavigation()]);
const scripted = await page.evaluate(() => fetch('/read').then((r) => r.json()));
console.log('cut maxlength=4, value set by script, stored', scripted.cut.length, 'units');
console.log('chrome', await browser.version());
await browser.close();
normalise.mjs, which builds the two spellings of one word from code points so neither depends on how this page is stored.
const nfc = String.fromCodePoint(99, 97, 102, 0xe9);
const nfd = String.fromCodePoint(99, 97, 102, 101, 0x301);
console.log('on screen ', nfc, nfd);
console.log('raw equal ', nfc === nfd);
console.log('code units ', nfc.length, nfd.length);
console.log('NFC equal ', nfc.normalize('NFC') === nfd.normalize('NFC'));
console.log('NFC code units ', nfc.normalize('NFC').length, nfd.normalize('NFC').length);
Steps
- Step 1.
Confirm the target is up and declares its charset, so a later fault cannot be blamed on the response header.
curl -sI http://127.0.0.1:8973/ | grep -i "^content-type" && curl -s http://127.0.0.1:8973/ | head -2content-type: text/html; charset=utf-8 <!doctype html><html lang="en"><meta charset="utf-8"><title>input</title> <form method="post" action="/save" accept-charset="utf-8">The header and the meta element agree, and the form declares
accept-charset. Anything wrong from here is the field or the store, not the encoding of the page. - Step 2.
Type the values into the form with a browser, submit, and read the stored result back as code points.
node drive.mjsfull typed 22 units, stored 22 identical true emoji maxlength=5, typed 11 units, stored 5 U+1F468 U+200D U+1F469 lone surrogate false cut maxlength=4, typed 11 units, stored 4 U+1F468 U+200D U+200D prefix of input false accent maxlength=4, typed 5 units, stored 4 U+0063 U+0061 U+0066 U+0065 "cafe" cut maxlength=4, value set by script, stored 11 units chrome Chrome/152.0.7977.76Read the
cutline twice. The stored value isU+1F468 U+200D U+200D, two joiners in a row, andprefix of input falsesays it is not the first four units of what was typed. The limit is applied per keystroke, so characters that do not fit are dropped while later, shorter ones still get in. - Step 3.
Insert both spellings of one word into a column with a unique constraint.
sqlite3 :memory: "create table users(name text unique); insert into users values (char(99,97,102,233)); insert into users values (char(99,97,102,101,769)); select rowid, name, length(name) as chars, length(cast(name as blob)) as bytes from users; select 'equal in sql: ' || (char(99,97,102,233) = char(99,97,102,101,769));"1|café|4|5 2|café|5|6 equal in sql: 0Two rows, the same word on screen, and the unique index raised nothing. One is a precomposed
U+00E9, the other iseplus a combining acute. Comparison in SQL is over bytes, so they are different keys. - Step 4.
Normalise both spellings and compare again.
node normalise.mjson screen café café raw equal false code units 4 5 NFC equal true NFC code units 4 4NFCcollapses the combining sequence into the precomposed code point, and the two values become one key. Normalise on the way in, before validation and before the insert, and the unique constraint starts working.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| identical true on the unlimited field | The round trip preserved every code point | The transport is fine. Look at the limited fields. |
| prefix of input false | The limit dropped characters from the middle | State the limit in graphemes and enforce it after typing, not during. |
| A stored value ending in U+200D | A joiner survived the character it joined | Strip trailing joiners and combining marks after any truncation. |
| "cafe" where café was typed | A combining mark fell outside the limit | Normalise to NFC before the length is measured. |
| Two rows that look identical in a unique column | The values differ in normalisation | Normalise on write, then deduplicate the existing rows. |
Common mistakes
What to check next
- How to check string length with emoji: the three counts a limit can mean, and which one to write into the contract.
- How to check for garbled characters from the wrong encoding: separates a truncation artefact from an encoding fault.
- How to check JSON encoding: what happens to these values in the API response.
- How to check if text is truncated: finds cuts that happen after storage, in the layout.
- Localization testing checklist: where this check sits in a release pass.
FAQ
How to test emoji input?
Type a zero width joiner sequence, a flag and a skin tone variant into every limited field, then read the stored value as code points. A count alone hides the fault. The sequence stored in step 2 is 4 code units long and holds two adjacent joiners.
Which characters belong in a test value?
One Cyrillic or Greek word, one combining mark, one astral character and one right-to-left word. That set covers multi-byte encoding, normalisation, surrogate pairs and direction in a single field.
Should the application normalise input?
Normalise to NFC before validation and before storage, and compare normalised values. Keep the original only where the difference carries meaning, which is rare outside linguistic tools.
Why type into the form instead of posting the body?
The input element and its attributes only run during typing. The maxlength behaviour in step 2 does not happen for a POST built by a script, and neither does an input mask or a paste handler.
Can this run against a deployed site?
Yes, once the value is yours to store. Use a local target for the first pass, because it isolates the field and the store from a proxy, and a failure there is unambiguous.
Verified
Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76puppeteer-core 25.10.0sqlite3 3.50.6curl 8.1.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny