How to test whitespace trimming in a form field
Build one fixture from five distinct whitespace code points, put it into the field, and read the value back as a U+ column at each hop. Here type="email" kept 20 of 24 characters, trim() cut 13 units to 5, and a field that looked empty reached the store as one character.
Why check this
Whitespace is not one character, and trimming is not one operation. Five characters in the fixture below all look like a gap, and four layers each remove a different subset.
Run this before sign-off on any form that keys a record by a typed string, a user name or a coupon code. Run it again after an upgrade replaces the input component.
The failure it prevents is a record nobody can find. Step 9 reproduces it: three rows display the same three letters, and an exact lookup returns one. Step 6 holds the second, a uniqueness constraint that refuses a true repeat and accepts a twin differing by one invisible character.
Prerequisites
- Node 22 for the three files below.
node:sqlitewarns without--no-warnings. - Chrome and
puppeteer-core(npm i puppeteer-core),CHROMEpointed at your binary. This run drove Chrome 152.0.7977.76 with--lang=en-US, because message text follows the UI locale and this machine answers in Ukrainian. One capture on one machine. - curl. Every request goes to
127.0.0.1:8933. Confirm the port is free first. - What a control strips before your code runs is the value sanitization algorithm, which differs by
type. - Every fixture character is a
\uescape and every measurement aU+column. Values are assigned toel.value, which step 3 checks against keyed entry.
fixture.mjs holds the strings.
export const SP = '\u0020'; // SPACE
export const TAB = '\u0009'; // CHARACTER TABULATION
export const NBSP = '\u00A0'; // NO-BREAK SPACE
export const IDSP = '\u3000'; // IDEOGRAPHIC SPACE
export const ZWSP = '\u200B'; // ZERO WIDTH SPACE
const LEAD = SP + TAB + NBSP + IDSP + ZWSP;
const TRAIL = ZWSP + IDSP + NBSP + TAB + SP;
export const NAME = LEAD + 'Ada' + TRAIL;
export const EMAIL = LEAD + 'qa@example.com' + TRAIL;
export const LINK = LEAD + 'https://example.com/' + TRAIL;
export const NOTE = LEAD + 'one' + '\u000D\u000A' + 'two' + TRAIL;
export const BLANK = SP + TAB + NBSP + IDSP + ZWSP + IDSP + NBSP + TAB + SP;
export const cps = (s) =>
[...s].map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');
target.mjs serves the form and stores what arrives.
import { createServer } from 'node:http';
import { DatabaseSync } from 'node:sqlite';
const PORT = 8933;
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT UNIQUE)');
const FORM = `<!doctype html><html><head><meta charset="utf-8"><title>whitespace fixture</title></head>
<body>
<form id="f" method="post" action="/save">
<input id="name" name="name" type="text" required>
<input id="email" name="email" type="email">
<input id="link" name="link" type="url">
<textarea id="note" name="note"></textarea>
<button id="go" type="submit">Save</button>
</form>
</body></html>`;
const send = (res, code, type, body) => {
res.writeHead(code, { 'content-type': type + '; charset=utf-8' });
res.end(body);
};
createServer((req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
if (req.method === 'GET' && url.pathname === '/') return send(res, 200, 'text/html', FORM);
if (req.method === 'GET' && url.pathname === '/rows') {
const rows = db.prepare('SELECT id, length(name) AS len, hex(name) AS hex, name FROM users ORDER BY id').all();
return send(res, 200, 'text/plain', rows.map((r) => `id ${r.id} length ${r.len} bytes ${Buffer.byteLength(r.name, "utf8")} hex ${r.hex}`).join('\n') + '\n');
}
if (req.method === 'GET' && url.pathname === '/find') {
const key = url.searchParams.get('name') ?? '';
const n = (sql) => db.prepare(sql).all(key).length;
const exact = n('SELECT id FROM users WHERE name = ?');
const trimmed = n('SELECT id FROM users WHERE trim(name) = ?');
const like = n("SELECT id FROM users WHERE name LIKE '%' || ? || '%'");
return send(res, 200, 'text/plain', `key units ${key.length} name = ? matched ${exact} trim(name) = ? matched ${trimmed} LIKE %key% matched ${like}\n`);
}
if (!(req.method === 'POST' && url.pathname === '/save')) return send(res, 404, 'text/plain', 'not found');
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
const name = body.get('name') ?? '';
let error = null;
let id = null;
if (!name) error = 'name is required';
else {
try {
id = Number(db.prepare('INSERT INTO users (name) VALUES (?)').run(name).lastInsertRowid);
} catch (e) {
error = e.message;
}
}
send(res, error ? 400 : 201, 'application/json', JSON.stringify({
id,
error,
receivedUnits: name.length,
receivedBytes: Buffer.byteLength(name, 'utf8'),
afterTrimUnits: name.trim().length,
emptyAfterTrim: name.trim() === '',
}) + '\n');
});
}).listen(PORT, '127.0.0.1', () => console.log('whitespace fixture on http://127.0.0.1:' + PORT));
probe.mjs reads the fixture, the controls and the rendering.
import { NAME, EMAIL, LINK, NOTE, BLANK, ZWSP, cps } from './fixture.mjs';
const CHROME = process.env.CHROME || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const ORIGIN = 'http://127.0.0.1:8933';
const mode = process.argv[2];
const pad = (n, w) => String(n).padStart(w);
const row = (label, s) => label.padEnd(14) + 'units ' + pad(s.length, 2) + ' ' + cps(s);
if (mode === 'points') {
for (const [k, v] of [['NAME', NAME], ['EMAIL', EMAIL], ['LINK', LINK], ['NOTE', NOTE], ['BLANK', BLANK]]) {
console.log(row(k, v));
}
} else if (mode === 'trim') {
for (const [k, v] of [['NAME', NAME], ['BLANK', BLANK]]) {
console.log(row(k + ' raw', v));
console.log(row(k + ' trim()', v.trim()));
}
console.log('BLANK trim() === "" ' + (BLANK.trim() === ''));
console.log('BLANK trim() length ' + BLANK.trim().length);
console.log('BLANK trim() is ZWSP ' + (BLANK.trim() === ZWSP));
console.log('Boolean(BLANK.trim()) ' + Boolean(BLANK.trim()));
console.log('NAME.trim() === "Ada" ' + (NAME.trim() === 'Ada'));
} else {
const { launch } = await import('puppeteer-core');
const browser = await launch({ executablePath: CHROME, headless: true, args: ['--lang=en-US'] });
const page = (await browser.pages())[0];
await page.goto(ORIGIN + '/', { waitUntil: 'networkidle2' });
const assign = (id, v) => page.evaluate(([i, t]) => {
const el = document.getElementById(i);
el.value = t;
return { kept: el.value };
}, [id, v]);
if (mode === 'controls') {
for (const [id, type, v] of [['name', 'text', NAME], ['email', 'email', EMAIL], ['link', 'url', LINK], ['note', 'textarea', NOTE]]) {
const r = await assign(id, v);
console.log(type.padEnd(9) + 'set ' + pad(v.length, 2) + ' kept ' + pad(r.kept.length, 2) + ' ' + cps(r.kept));
}
} else if (mode === 'insert') {
const a = await assign('email', EMAIL);
await page.evaluate(() => { const el = document.getElementById('email'); el.value = ''; el.focus(); });
for (const c of EMAIL) await page.keyboard.sendCharacter(c);
const k = await page.evaluate(() => document.getElementById('email').value);
console.log(row('assigned', a.kept));
console.log(row('keyed', k));
console.log('identical ' + (a.kept === k));
} else if (mode === 'required') {
const r = await assign('name', BLANK);
console.log('name kept units ' + r.kept.length + ' ' + cps(r.kept));
const v = await page.evaluate(() => {
const el = document.getElementById('name');
return { valueMissing: el.validity.valueMissing, formValid: document.getElementById('f').checkValidity(), message: el.validationMessage };
});
console.log('valueMissing ' + v.valueMissing + ' form valid ' + v.formValid + ' message "' + v.message + '"');
} else if (mode === 'render') {
const out = await page.evaluate((items) => items.map(([label, text]) => {
const el = document.createElement('span');
el.textContent = text;
document.body.appendChild(el);
return { label, units: text.length, width: el.getBoundingClientRect().width };
}), [['row 1 raw', NAME], ['row 2 trim()', NAME.trim()], ['row 3 Ada', 'Ada']]);
for (const r of out) console.log(r.label.padEnd(14) + 'units ' + pad(r.units, 2) + ' rendered width ' + r.width.toFixed(2) + ' px');
}
await browser.close();
}
Start the target.
node --no-warnings target.mjs
Steps
- Step 1.
Print the fixture before anything touches it.
node probe.mjs pointsNAME units 13 U+0020 U+0009 U+00A0 U+3000 U+200B U+0041 U+0064 U+0061 U+200B U+3000 U+00A0 U+0009 U+0020 EMAIL units 24 U+0020 U+0009 U+00A0 U+3000 U+200B U+0071 U+0061 U+0040 U+0065 U+0078 U+0061 U+006D U+0070 U+006C U+0065 U+002E U+0063 U+006F U+006D U+200B U+3000 U+00A0 U+0009 U+0020 LINK units 30 U+0020 U+0009 U+00A0 U+3000 U+200B U+0068 U+0074 U+0074 U+0070 U+0073 U+003A U+002F U+002F U+0065 U+0078 U+0061 U+006D U+0070 U+006C U+0065 U+002E U+0063 U+006F U+006D U+002F U+200B U+3000 U+00A0 U+0009 U+0020 NOTE units 18 U+0020 U+0009 U+00A0 U+3000 U+200B U+006F U+006E U+0065 U+000D U+000A U+0074 U+0077 U+006F U+200B U+3000 U+00A0 U+0009 U+0020 BLANK units 9 U+0020 U+0009 U+00A0 U+3000 U+200B U+3000 U+00A0 U+0009 U+0020Five whitespace characters, one of each, on both sides of every value.
BLANKcarries no letter. - Step 2.
Put each value into its control and read it back.
node probe.mjs controlstext set 13 kept 13 U+0020 U+0009 U+00A0 U+3000 U+200B U+0041 U+0064 U+0061 U+200B U+3000 U+00A0 U+0009 U+0020 email set 24 kept 20 U+00A0 U+3000 U+200B U+0071 U+0061 U+0040 U+0065 U+0078 U+0061 U+006D U+0070 U+006C U+0065 U+002E U+0063 U+006F U+006D U+200B U+3000 U+00A0 url set 30 kept 26 U+00A0 U+3000 U+200B U+0068 U+0074 U+0074 U+0070 U+0073 U+003A U+002F U+002F U+0065 U+0078 U+0061 U+006D U+0070 U+006C U+0065 U+002E U+0063 U+006F U+006D U+002F U+200B U+3000 U+00A0 textarea set 18 kept 17 U+0020 U+0009 U+00A0 U+3000 U+200B U+006F U+006E U+0065 U+000A U+0074 U+0077 U+006F U+200B U+3000 U+00A0 U+0009 U+0020type="text"returned all 13 units.type="email"returned 20 of 24 andtype="url"26 of 30: each dropped a leading space and tab and a trailing tab and space, then stopped at U+00A0. The textarea replaced U+000D U+000A with one U+000A. - Step 3.
Check that the insertion path does not change the answer.
node probe.mjs insertassigned units 20 U+00A0 U+3000 U+200B U+0071 U+0061 U+0040 U+0065 U+0078 U+0061 U+006D U+0070 U+006C U+0065 U+002E U+0063 U+006F U+006D U+200B U+3000 U+00A0 keyed units 20 U+00A0 U+3000 U+200B U+0071 U+0061 U+0040 U+0065 U+0078 U+0061 U+006D U+0070 U+006C U+0065 U+002E U+0063 U+006F U+006D U+200B U+3000 U+00A0 identical trueAssignment to
el.valueand keyed entry gave the same 20 units, so the rest of this page assigns. - Step 4.
Run
String.prototype.trim()over the raw fixture.node probe.mjs trimNAME raw units 13 U+0020 U+0009 U+00A0 U+3000 U+200B U+0041 U+0064 U+0061 U+200B U+3000 U+00A0 U+0009 U+0020 NAME trim() units 5 U+200B U+0041 U+0064 U+0061 U+200B BLANK raw units 9 U+0020 U+0009 U+00A0 U+3000 U+200B U+3000 U+00A0 U+0009 U+0020 BLANK trim() units 1 U+200B BLANK trim() === "" false BLANK trim() length 1 BLANK trim() is ZWSP true Boolean(BLANK.trim()) true NAME.trim() === "Ada" falsetrim()removed U+0020, U+0009, U+00A0 and U+3000 from both ends and stopped at U+200B, a format character and not whitespace by its definition.NAMEfell from 13 units to 5 and no longer equalsAda.BLANKfell from 9 to 1, and that survivor is truthy, is not the empty string, and prints as nothing. - Step 5.
Put the whitespace-only value into a
requiredfield.node probe.mjs requiredname kept units 9 U+0020 U+0009 U+00A0 U+3000 U+200B U+3000 U+00A0 U+0009 U+0020 valueMissing false form valid true message ""requiredasks whether the value is empty, not what the characters are. Nine whitespace characters satisfy it. - Step 6.
Post five values with no browser in the path.
for b in '%20%09%C2%A0%E3%80%80%E2%80%8BAda%E2%80%8B%E3%80%80%C2%A0%09%20' '%E2%80%8BAda%E2%80%8B' 'Ada' 'Ada' '%20%09%C2%A0%E3%80%80%E2%80%8B%E3%80%80%C2%A0%09%20'; do curl -s -X POST http://127.0.0.1:8933/save --data "name=$b"; done{"id":1,"error":null,"receivedUnits":13,"receivedBytes":23,"afterTrimUnits":5,"emptyAfterTrim":false} {"id":2,"error":null,"receivedUnits":5,"receivedBytes":9,"afterTrimUnits":5,"emptyAfterTrim":false} {"id":3,"error":null,"receivedUnits":3,"receivedBytes":3,"afterTrimUnits":3,"emptyAfterTrim":false} {"id":null,"error":"UNIQUE constraint failed: users.name","receivedUnits":3,"receivedBytes":3,"afterTrimUnits":3,"emptyAfterTrim":false} {"id":4,"error":null,"receivedUnits":9,"receivedBytes":17,"afterTrimUnits":1,"emptyAfterTrim":false}Raw, trimmed and bare each got an id; only the exact repeat of
Adahit the constraint. The last row is whitespace and nothing else, stored withemptyAfterTrimfalse. - Step 7.
Read the store by character count and bytes.
curl -s http://127.0.0.1:8933/rowsid 1 length 13 bytes 23 hex 2009C2A0E38080E2808B416461E2808BE38080C2A00920 id 2 length 5 bytes 9 hex E2808B416461E2808B id 3 length 3 bytes 3 hex 416461 id 4 length 9 bytes 17 hex 2009C2A0E38080E2808BE38080C2A00920This is the command to take away.
length()counts characters andhex()prints the UTF-8 bytes:416461isAda,E2808Bis U+200B,C2A0is U+00A0,E38080is U+3000. - Step 8.
Render the first three stored names and measure them.
node probe.mjs renderrow 1 raw units 13 rendered width 66.66 px row 2 trim() units 5 rendered width 26.66 px row 3 Ada units 3 rendered width 26.66 pxRows 2 and 3 hold different strings at the same width to the hundredth of a pixel. U+200B costs nothing on screen.
- Step 9.
Look the name up as an operator would.
curl -s 'http://127.0.0.1:8933/find?name=Ada'key units 3 name = ? matched 1 trim(name) = ? matched 1 LIKE %key% matched 3One row of the three that display as
Ada. SQLtrim()matched the same single row, so it removed none of the tab, U+00A0, U+3000 or U+200B.LIKEfound all three, which is how this gets filed as a search bug.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| type="text" kept every unit | No sanitizing applies to that type | The value your handler receives is exactly what was entered. Trim it yourself, and decide which characters count. |
| type="email" kept 20 of 24 | The control strips leading and trailing ASCII whitespace only | Do not credit the server for it. Step 6 sends all 24 past the control and the store keeps them. |
| The textarea kept 17 of 18 | The API value normalises U+000D U+000A to U+000A | Count line endings on the server, not in the browser. A CRLF paste and an LF paste arrive the same length. |
| trim() left U+200B at both ends | U+200B is a format character, not whitespace | Name the characters your rule removes. trim() alone is a rule that keeps four of them. |
| emptyAfterTrim is false on a blank field | The value is one invisible character, not zero | Reject on a code point allowlist, or normalise before the emptiness test. required will not fire. |
| Two rows at the same rendered width | The difference is a zero width character | Compare hex(), never the display. Step 7 is the reproduction. |
| An exact lookup matches 1 of 3 rows | The key is stored in three spellings | Fix the write path first, then backfill. A LIKE search hides the defect. |
Common mistakes
Thresholds
What to check next
- How to test that client validation is enforced on the server: the general case behind step 6.
- How to check field length limits in a form: the same field counted in UTF-16 units.
- How to check required field validation: the attribute step 5 passed.
- Input type email validation: what that control checks after stripping.
- Html input pattern validation: an attribute that rejects a padded value.
FAQ
Does html input trim whitespace?
Only for some types. Step 2 shows type="email" and type="url" removing leading and trailing ASCII whitespace, type="text" removing none, and a textarea changing CRLF to LF. None touches U+00A0, U+3000 or U+200B.
How do I make input trim whitespace everywhere?
Normalise on the server, and name the characters. A rule built on trim() keeps U+200B, as step 4 measures.
Why does trailing spaces not allowed fire on a value that looks clean?
Something upstream added a character the rule counts as a space and your eyes do not. Run step 1 on that value and read the U+ column.
Is a whitespace-only value the same as an empty one?
No. Step 4 turned a 9 character blank into a 1 character string, and step 5 shows required passing it.
Verified
Verified by Maks Vernynode 22.23.2node:sqlite 3.51.3Chrome 152.0.7977.76puppeteer-core 25.10.0curl 8.21.0Windows 11 build 22631
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
intermediate12 minpublished updated Maks Verny