From 64d0633bd6ddc7e42c15495d28556d6ac43f105d Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 25 May 2026 18:42:30 +0530 Subject: [PATCH] feat(spec): typing builtin for the web (V8) action path --- pkg/spec/src/web-runtime.ts | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index ed9a24a..34ae07a 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -347,6 +347,7 @@ const runtime = { return { kind: "Wait", durationMillis: p.durationMillis }; }, taps: { __sanderlingActionGenerator: true, __sanderlingKind: "taps" } as ActionGeneratorHandle, + typing: { __sanderlingActionGenerator: true, __sanderlingKind: "typing" } as ActionGeneratorHandle, swipes: { __sanderlingActionGenerator: true, __sanderlingKind: "swipes" } as ActionGeneratorHandle, waitOnce: { __sanderlingActionGenerator: true, __sanderlingKind: "waitOnce" } as ActionGeneratorHandle, pressKeys: { __sanderlingActionGenerator: true, __sanderlingKind: "pressKey" } as ActionGeneratorHandle, @@ -441,6 +442,8 @@ function resolveGenerator(handle: ActionGeneratorHandle): unknown { } case "taps": return randomTap(); + case "typing": + return randomInput(); case "swipes": return randomSwipe(); case "waitOnce": @@ -482,6 +485,59 @@ function randomTap(): unknown { }; } +// Per-tick cache mirroring randomTapCandidates, reset each NextAction tick. +let randomInputCandidates: HTMLElement[] | null = null; + +const NON_TEXT_INPUT_TYPES = [ + "button", "submit", "checkbox", "radio", "range", "color", "file", "image", "reset", +]; + +// Edge-case input pool mirroring the goja-side inputCorpus: empty, whitespace, +// overflow length, unicode, numeric boundaries, and common injection payloads. +const WEB_INPUT_CORPUS = [ + "", "a", "a".repeat(4096), "🙂🔥💸", " ", "\t\n", "-1", + "999999999999999999999", "0.0000001", "1e10", "'; DROP TABLE--", + "", "../../etc/passwd", "%s%n", "NaN", +]; + +function isEditableElement(element: HTMLElement): boolean { + if (element.isContentEditable) return true; + const tag = element.tagName.toLowerCase(); + if (tag === "textarea") return true; + if (tag === "input") { + const type = ((element as HTMLInputElement).type || "").toLowerCase(); + return !NON_TEXT_INPUT_TYPES.includes(type); + } + return false; +} + +function randomInput(): unknown { + if (!randomInputCandidates) { + randomInputCandidates = Array.from( + document.querySelectorAll("input, textarea, [contenteditable]"), + ).filter((element) => { + if (!isEditableElement(element)) return false; + if ((element as HTMLInputElement).disabled) return false; + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + } + const candidates = randomInputCandidates; + if (candidates.length === 0) return null; + const picked = candidates[Math.floor(Math.random() * candidates.length)]; + if (!picked) return null; + const rect = picked.getBoundingClientRect(); + const text = WEB_INPUT_CORPUS[Math.floor(Math.random() * WEB_INPUT_CORPUS.length)]; + return { + kind: "InputText", + into: { + x: Math.round(rect.left + rect.width / 2), + y: Math.round(rect.top + rect.height / 2), + }, + text, + }; +} + function randomSwipe(): unknown { // Why: web has no swipe gesture; pointer drags into empty divs are noise. return null; @@ -569,6 +625,7 @@ defineLockedGlobal("__sanderlingNextAction__", function (): unknown { if (!actionsRoot) return null; // Reset per-tick caches so each invocation gets a fresh DOM scan. randomTapCandidates = null; + randomInputCandidates = null; // Match the goja runtime: retry up to 16 times when a weighted entry's // generator returns []. Otherwise on routes where most generators are // gated to other pages, ~80% of ticks would emit no action.