{"slug": "typing-the-date-we-recorded-into-a-date-input-produced-the-year-60901", "title": "Typing the date we recorded into a date input produced the year 60901", "summary": "A developer building Notifio's form-replay automation found that typing a recorded date string into a native segmented date input produced the year 60901, a populated but nonsensical value that passed emptiness checks and submitted successfully. The fix uses fill() for segmented inputs (date, month, week, time, datetime-local) while still typing free text character-by-character so keystroke-driven validation behaves as demonstrated, and reads maxLength off the live control rather than the recorded schema to avoid silent browser truncation.", "body_md": "Notifio's auto-reply works by recording you. You fill in a listing site's contact form once, by hand, in a real browser window, and the app writes down what you did. From then on, when a new listing shows up, it replays that. There is no model in the path, which I wrote about in [We automate form filling with zero LLM calls](https://dev.to/daniel_pertu/we-automate-form-filling-with-zero-llm-calls-and-that-is-the-feature-39kn).\n\nThe naive mental model of a replay is a list of `(selector, value)` pairs that you type back in. That model is wrong in four separate ways, and the one that taught me the most produced the year 60901.\n\nThe recording holds a move-in date as the string the field ended up with, say `2026-09-01`. Replaying it by typing those characters does not work, because `<input type=\"date\">` is not a text field. It is a set of segments, each with its own focus and its own spinner, and keystrokes go to whichever segment has focus right now.\n\nWhat makes this genuinely dangerous is the failure mode. The field does not end up empty, which is what you would check for. It ends up holding a date:\n\n```\n// A native date/time control is edited segment by segment, so typing\n// the characters of \"2026-09-01\" lands them in the wrong boxes and\n// produces a nonsense date (year 60901) that is not empty, and so slips\n// past a check for emptiness. fill() sets the value the way the\n// control's own picker would.\n```\n\nA year five digits long is an obvious bug when you see it. The point is that nothing in the pipeline *sees* it: the field is populated, the form validates, the form submits, and a landlord receives an enquiry saying you would like to move in some time in the year 60901.\n\nSo there is a set of types that get set rather than typed:\n\n``` js\nconst SEGMENTED_INPUTS = new Set([\n  'date', 'month', 'week', 'time', 'datetime-local',\n]);\n```\n\nThe obvious conclusion from the above is to use `fill()` everywhere. That is wrong in the other direction.\n\n`fill()` sets the value and dispatches one input event. A lot of listing-site contact forms are listening for keystrokes: a character counter under the textarea, a \"message is too short\" hint, a submit button that stays disabled until a keyup handler has run. A form filled with one synthetic event can end up valid-looking and un-submittable, or submittable with a counter that disagrees with the value.\n\nSo free text goes in the slow way, and the comment says why:\n\n```\n// Typed rather than set directly, so sites listening for key events (and\n// their validation) behave the same as when the user demonstrated it.\nawait locator.fill('', { timeout: STEP_TIMEOUT }).catch(() => {});\nawait locator.type(value, { delay: 12, timeout: STEP_TIMEOUT });\n```\n\nTwo different strategies for two different kinds of control, decided by the field's own `type`. There is no single correct way to put a string in a form field.\n\nThe recorded schema has a `maxLength` on it. We ignore it and read it again off the control that is on screen right now:\n\n```\n// Read the limit off the live control rather than the recorded schema:\n// it is the one property that silently changes what the landlord\n// actually receives, and the browser would cut it mid-word.\nconst limit = await locator\n  .evaluate((el) => {\n    const max = (el as HTMLInputElement | HTMLTextAreaElement).maxLength;\n    return typeof max === 'number' && max > 0 ? max : null;\n  })\n  .catch(() => null);\n```\n\nSuppose the site tightened its message textarea from 2000 characters to 500 at some point after you recorded. Every other kind of drift announces itself: a renamed field throws, a moved button times out, a redesigned page fails a guard. This one does not. The browser silently truncates at 500 and submits, and your enquiry arrives cut off mid-sentence with no error anywhere.\n\nReading the limit live means the truncation is ours to do, which means it can be done better than the browser does it:\n\n```\nexport function clampToLimit(value: string, limit?: number): string {\n  if (!limit || limit <= 0 || value.length <= limit) return value;\n\n  const hard = value.slice(0, limit);\n  const lastBreak = hard.lastIndexOf(' ');\n  // Only fall back to the word boundary when it does not throw away most of the\n  // text; a field with no spaces in range keeps the hard cut.\n  const cut = lastBreak > limit * 0.6 ? hard.slice(0, lastBreak) : hard;\n  return cut.replace(/[\\s,;:.-]+$/, '');\n}\n```\n\nThree decisions in nine lines. Prefer a word boundary. Only prefer it when it keeps at least 60% of the budget, because a short field holding one long unbroken token has no usable space in range and a hard cut beats three characters. Strip trailing punctuation, so the message never ends on `\",` or `\" -`.\n\nAnd it says so, in the activity pane the user is looking at while it happens:\n\n```\nlog(`[reply] \"${label}\" accepts ${limit} characters, so the message was shortened to fit`);\n```\n\n(That log line is user-visible copy, which is the subject of [the post I published before this one](https://dev.to/daniel_pertu/six-places-a-copy-rule-has-to-reach-and-not-one-of-them-is-a-page-4ca2).)\n\nIf you recorded the flow in September with a move-in date of 1 September, and the app replies to a listing in October, replaying the recording sends a landlord a date that has already been and gone. So a past date rolls forward:\n\n```\n/** Roll a recorded date forward to today if it has already been and gone. */\nexport function refreshPastDate(recorded: string, now = new Date()): string {\n  const parsed = parseRecordedDate(recorded);\n  if (parsed === null) return recorded;\n\n  const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();\n  if (parsed >= startOfToday) return recorded;\n\n  // Keep the format the site was given originally, so validation still passes.\n  return formatLike(recorded, now);\n}\n```\n\nThe last line is the part that took a second attempt. Rewriting the date in ISO form is correct and useless: the site's own validation was written for whatever format it handed us, and a field that was given `01/09/2026` will reject `2026-10-07`. The refresh has to preserve the shape, not normalise it. An unparseable value is returned untouched, because a date we do not understand is not a date we should be editing.\n\nEvery transform above is a hypothesis about what the control did with the value. So each fill ends by asking:\n\n``` js\nconst actual = await locator.inputValue({ timeout: 2_000 }).catch(() => null);\nif (actual !== null && actual.trim() === '' && value.trim() !== '') {\n  return { ok: false, reason: `\"${label}\" would not accept the value.` };\n}\n// Structured controls normalise nothing, so anything other than the exact\n// value means it was misread. Free-text fields are left alone: plenty of\n// them reformat phone numbers and the like as you type.\nif (SEGMENTED_INPUTS.has(type) && actual !== null && actual !== value) {\n  return {\n    ok: false,\n    reason: `\"${label}\" ended up as \"${actual}\" instead of \"${value}\".`,\n  };\n}\n```\n\nTwo strictnesses, and both of them are load-bearing. Free text only has to come back non-empty, because plenty of fields legitimately reformat as you type: a phone field inserting spaces, an amount field adding separators, a name field trimming. Demanding an exact match there would refuse perfectly good replies all day.\n\nA segmented control has to come back byte-identical, because it normalises nothing and does not reformat. Anything other than what we asked for means we misread the control. Being lenient there is precisely how you ship the year 60901.\n\nA recording is not a script. It is a description of a form that has since moved on, and replaying it means re-deriving from the live page the things that can have changed underneath you: the character limit, the current date, how this particular control accepts input, and then checking your own work.\n\nThe file header states the rule the whole thing is built on:\n\nEverything here is deterministic. There is no guessing: if the page does not match what was recorded, or any guard trips, we stop and report why rather than improvising. Refusing to send is always the better failure.\n\nFor an app that sends messages to strangers in your name, a reply that did not go out is a bad afternoon. A reply that went out wrong is your name on it.\n\nIf you want to see what this looks like from the outside: [the help page](https://notifio.app/help) walks through recording a reply, the [Pararius page](https://notifio.app/alerts/pararius) covers what auto-reply can and cannot do on one specific site, and the app itself is on the [download page](https://notifio.app/download).", "url": "https://wpnews.pro/news/typing-the-date-we-recorded-into-a-date-input-produced-the-year-60901", "canonical_source": "https://dev.to/daniel_pertu/typing-the-date-we-recorded-into-a-date-input-produced-the-year-60901-52cg", "published_at": "2026-10-01 09:36:16+00:00", "updated_at": "2026-10-01 09:44:14.532743+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["Notifio"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/typing-the-date-we-recorded-into-a-date-input-produced-the-year-60901", "markdown": "https://wpnews.pro/news/typing-the-date-we-recorded-into-a-date-input-produced-the-year-60901.md", "text": "https://wpnews.pro/news/typing-the-date-we-recorded-into-a-date-input-produced-the-year-60901.txt", "jsonld": "https://wpnews.pro/news/typing-the-date-we-recorded-into-a-date-input-produced-the-year-60901.jsonld"}}