diff --git a/apps/extension/src/tools/__tests__/fill.browser.test.ts b/apps/extension/src/tools/__tests__/fill.browser.test.ts index 17aa4c91d..425661fa7 100644 --- a/apps/extension/src/tools/__tests__/fill.browser.test.ts +++ b/apps/extension/src/tools/__tests__/fill.browser.test.ts @@ -57,6 +57,7 @@ async function withFillBrowser( ref?: string, ) => ReturnType; afterCommand: (hook: (method: string, params?: object) => void | Promise) => void; + activate: () => Promise; calls: string[]; observe: () => ReturnType; }) => Promise, @@ -146,6 +147,9 @@ async function withFillBrowser( afterCommand: (hook) => { afterCommand = hook; }, + activate: async () => { + await send("Page.bringToFront", {}, sessionId); + }, ref: async (expression) => { const { result } = await send<{ result: { objectId: string } }>( "Runtime.evaluate", @@ -356,6 +360,67 @@ describe.skipIf(!process.env.BSK_FILL_CHROME)("fill lifecycle and discoverabilit ); }, 30_000); + describe.each([ + "document", + "shadow", + "closed-shadow", + "iframe", + ] as const)("%s date/time fields", (kind) => { + it.each([ + ["date", "2026-10-14"], + ["time", "14:30"], + ["datetime-local", "2026-10-14T14:30"], + ["month", "2026-10"], + ["week", "2026-W42"], + ] as const)( + "observes and fills a %s input by ref", + async (type, value) => { + const input = ``; + const markup = + kind === "document" + ? input + : kind === "shadow" || kind === "closed-shadow" + ? '
' + : ''; + await withFillBrowser({ background: false, markup }, async (h) => { + if (kind !== "document") { + await h.evaluate(`(() => { + const container = document.querySelector('#container'); + const root = ${ + kind === "shadow" + ? "container.attachShadow({ mode: 'open' })" + : kind === "closed-shadow" + ? "container.attachShadow({ mode: 'closed' })" + : "container.contentDocument.body" + }; + root.innerHTML = ${JSON.stringify(input)}; + window.bookingInput = root.querySelector('#target'); + })()`); + } + const observation = await h.observe(); + expect(observation).not.toHaveProperty("code"); + if (!("text" in observation)) throw new Error("observation failed"); + const ref = observation.text.match( + /(@e\d+) (?:Date|DateTime|InputTime) "Booking field"/, + )?.[1]; + expect(ref, observation.text).toBeDefined(); + expect(await h.fill(value, true, undefined, undefined, ref)).toMatchObject({ + value_length: value.length, + }); + const valueExpression = + kind === "document" + ? "document.querySelector('#target').value" + : "window.bookingInput.value"; + expect(await h.evaluate(valueExpression)).toBe(value); + const after = await h.observe(); + if (!("text" in after)) throw new Error("observation failed after fill"); + expect(after.text).toContain(`="${value}"`); + }); + }, + 30_000, + ); + }); + it.each(["iframe", "shadow"])("fills roots and rejects descendants in %s", async (kind) => { await withFillBrowser( { background: false, markup: guard + '
' }, @@ -464,6 +529,43 @@ describe.skipIf(!process.env.BSK_FILL_CHROME)("fill lifecycle and discoverabilit }); describe.skipIf(!process.env.BSK_FILL_CHROME)("fill focus changes", () => { + it.each([ + ["input", '', "value"], + ["textarea", '', "value"], + ["contenteditable", '
old
', "textContent"], + ] as const)( + "keeps a background %s clear after tab activation", + async (_name, markup, field) => { + await withFillBrowser({ background: true, markup: guard + markup }, async (h) => { + await h.ref("document.querySelector('#target')"); + await h.evaluate(`document.querySelector('#target').addEventListener('focus', () => { + document.querySelector('#target').${field} = 'default'; + })`); + expect(await h.fill("")).toMatchObject({ value_length: 0 }); + expect(await h.evaluate(`document.querySelector('#target').${field}`)).toBe(""); + await h.activate(); + expect(await h.evaluate(`document.querySelector('#target').${field}`)).toBe(""); + expect(h.calls).toContain("Input.dispatchKeyEvent"); + expect(h.calls).not.toContain("Input.insertText"); + }); + }, + 30_000, + ); + + it("does not clear a background text input when deferred focus moves away", async () => { + await withFillBrowser( + { background: true, markup: guard + '' }, + async (h) => { + await h.ref("document.querySelector('#target')"); + await h.evaluate(`document.querySelector('#target').addEventListener('focus', () => { + document.querySelector('#guard').focus(); + })`); + expect(await h.fill("")).toMatchObject({ data: { reason: "fill_focus_lost" } }); + expect(await h.evaluate("document.querySelector('#target').value")).toBe("old"); + }, + ); + }, 30_000); + it.each([ false, true, @@ -494,6 +596,207 @@ describe.skipIf(!process.env.BSK_FILL_CHROME)("fill focus changes", () => { }, 30_000); }); +describe.skipIf(!process.env.BSK_FILL_CHROME)("date-like fill regressions", () => { + it("keeps a background date fill after the tab becomes active", async () => { + await withFillBrowser( + { background: true, markup: guard + '' }, + async (h) => { + await h.ref("document.querySelector('#target')"); + await h.evaluate(`document.querySelector('#target').addEventListener('focus', () => { + document.querySelector('#target').value = '2026-01-01'; + })`); + expect(await h.fill("2026-10-14")).toMatchObject({ value_length: 10 }); + expect(await h.evaluate("document.querySelector('#target').value")).toBe("2026-10-14"); + await h.activate(); + expect(await h.evaluate("document.hasFocus()")).toBe(true); + expect(await h.evaluate("document.querySelector('#target').value")).toBe("2026-10-14"); + expect(h.calls).toContain("Input.dispatchKeyEvent"); + expect(h.calls).not.toContain("Input.insertText"); + }, + ); + }, 30_000); + + it.each([ + ["date", "2026-10-14"], + ["time", "14:30"], + ["datetime-local", "2026-10-14T14:30"], + ["month", "2026-10"], + ["week", "2026-W42"], + ] as const)( + "keeps a background %s clear after deferred focus and tab activation", + async (type, defaultValue) => { + await withFillBrowser( + { + background: true, + markup: guard + ``, + }, + async (h) => { + await h.ref("document.querySelector('#target')"); + await h.evaluate(`document.querySelector('#target').addEventListener('focus', () => { + document.querySelector('#target').value = '${defaultValue}'; + })`); + expect(await h.fill("")).toMatchObject({ value_length: 0 }); + expect(await h.evaluate("document.querySelector('#target').value")).toBe(""); + await h.activate(); + expect(await h.evaluate("document.querySelector('#target').value")).toBe(""); + expect(h.calls).toContain("Input.dispatchKeyEvent"); + expect(h.calls).not.toContain("Input.insertText"); + }, + ); + }, + 30_000, + ); + + it.each([ + ["date", "2026-10-14", "2026-10-15"], + ["time", "12:34", ":56"], + ["week", "2026-W42", ""], + ] as const)( + "rejects --no-clear on a %s input without side effects", + async (type, initial, value) => { + await withFillBrowser( + { + background: false, + markup: guard + ``, + }, + async (h) => { + await h.ref("document.querySelector('#target')"); + await h.evaluate("document.querySelector('#guard').focus(); window.fillEvents = []"); + const before = await h.evaluate(unchangedState); + expect(await h.fill(value, false)).toMatchObject({ + code: "invalid_params", + data: { reason: "fill_value_invalid" }, + }); + expect(await h.evaluate(unchangedState)).toBe(before); + expect(await h.evaluate("document.querySelector('#target').value")).toBe(initial); + expect(h.calls).not.toContain("DOM.focus"); + }, + ); + }, + 30_000, + ); + + it.each([ + ["date", 'min="2026-01-01" max="2026-12-31"', "2027-01-01", "min 2026-01-01, max 2026-12-31"], + ["date", 'min="2026-01-01" max="2026-12-31"', "2025-12-31", "min 2026-01-01, max 2026-12-31"], + ["time", 'min="09:00" max="18:00"', "18:30", "min 09:00, max 18:00"], + ["time", 'min="22:00" max="02:00"', "12:00", "min 22:00, max 02:00"], + ["datetime-local", 'max="2026-10-14T12:00"', "2026-10-14T12:01", "max 2026-10-14T12:00"], + ["month", 'min="2026-03"', "2026-02", "min 2026-03"], + ["week", 'max="2026-W10"', "2026-W42", "max 2026-W10"], + ] as const)( + "rejects an out-of-range %s value (%s) before focus", + async (type, attrs, value, bounds) => { + await withFillBrowser( + { + background: false, + markup: guard + ``, + }, + async (h) => { + await h.ref("document.querySelector('#target')"); + await h.evaluate("document.querySelector('#guard').focus(); window.fillEvents = []"); + const before = await h.evaluate(unchangedState); + expect(await h.fill(value)).toMatchObject({ + code: "invalid_params", + data: { reason: "fill_value_invalid" }, + message: expect.stringContaining(`(${bounds})`), + }); + expect(await h.evaluate(unchangedState)).toBe(before); + expect(h.calls).not.toContain("DOM.focus"); + }, + ); + }, + 30_000, + ); + + it.each([ + ["date", 'min="2026-01-01" max="2026-12-31"', "2026-12-31"], + ["date", 'min="2026-01-01" max="2026-12-31"', "2026-01-01"], + ["time", 'min="22:00" max="02:00"', "23:30"], + ["week", 'min="2026-W10" max="2026-W42"', "2026-W42"], + ] as const)( + "fills an in-range %s value (%s) %s", + async (type, attrs, value) => { + await withFillBrowser( + { background: false, markup: guard + `` }, + async (h) => { + await h.ref("document.querySelector('#target')"); + expect(await h.fill(value)).toMatchObject({ value_length: value.length }); + expect(await h.evaluate("document.querySelector('#target').value")).toBe(value); + }, + ); + }, + 30_000, + ); + + it.each([ + ["date", "2026-10-14"], + ["time", "14:30"], + ["datetime-local", "2026-10-14T14:30"], + ["month", "2026-10"], + ["week", "2026-W42"], + ] as const)( + "notifies a shadow host when filling %s", + async (type, value) => { + await withFillBrowser( + { background: false, markup: guard + '
' }, + async (h) => { + await h.evaluate(`(() => { + const host = document.querySelector('#host'); + const root = host.attachShadow({ mode: 'open' }); + root.innerHTML = ''; + window.hostInputEvents = []; + host.addEventListener('input', (event) => { + window.hostInputEvents.push({ + composed: event.composed, + value: root.querySelector('#target').value, + }); + }); + })()`); + await h.ref("document.querySelector('#host').shadowRoot.querySelector('#target')"); + expect(await h.fill(value)).toMatchObject({ value_length: value.length }); + expect( + await h.evaluate( + "document.querySelector('#host').shadowRoot.querySelector('#target').value", + ), + ).toBe(value); + expect(await h.evaluate("window.hostInputEvents")).toEqual([ + { composed: true, value: "" }, + { composed: true, value }, + ]); + expect(h.calls).not.toContain("Input.insertText"); + }, + ); + }, + 30_000, + ); + + it.each([ + ["text", "hello"], + ["date", "2026-10-14"], + ] as const)( + "does not duplicate a shadow component's forwarded change for %s", + async (type, value) => { + await withFillBrowser({ background: false, markup: '
' }, async (h) => { + await h.evaluate(`(() => { + const host = document.querySelector('#host'); + const root = host.attachShadow({ mode: 'open' }); + root.innerHTML = ''; + window.hostChanges = 0; + root.querySelector('#target').addEventListener('change', () => { + host.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + }); + host.addEventListener('change', () => window.hostChanges++); + })()`); + await h.ref("document.querySelector('#host').shadowRoot.querySelector('#target')"); + expect(await h.fill(value)).toMatchObject({ value_length: value.length }); + expect(await h.evaluate("window.hostChanges")).toBe(1); + }); + }, + 30_000, + ); +}); + describe.skipIf(!process.env.BSK_FILL_CHROME)("fill caret validation", () => { it("stops if page code moves the caret within the same editor", async () => { await withFillBrowser( diff --git a/apps/extension/src/tools/__tests__/fill.test.ts b/apps/extension/src/tools/__tests__/fill.test.ts index 112cfe5fa..1a8b7fc62 100644 --- a/apps/extension/src/tools/__tests__/fill.test.ts +++ b/apps/extension/src/tools/__tests__/fill.test.ts @@ -136,7 +136,6 @@ describe("fill result verification", () => { "checkbox", "radio", "range", - "date", "file", ])("rejects an unsupported %s input before clearing", async (type) => { const h = await setup(``); @@ -149,6 +148,258 @@ describe("fill result verification", () => { expect(h.insert).not.toHaveBeenCalled(); }); + it.each([ + ["date", "2026-10-14"], + ["time", "14:30"], + ["datetime-local", "2026-10-14T14:30"], + ["month", "2026-10"], + ["week", "2026-W42"], + ] as const)("fills a %s input via the value setter", async (type, value) => { + const h = await setup(``); + expect(await h.fill(value)).toMatchObject({ value_length: value.length }); + expect(h.element.value).toBe(value); + expect(h.insert).not.toHaveBeenCalled(); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it.each([ + "date", + "time", + "datetime-local", + "month", + "week", + ])("rejects an invalid %s value before clearing", async (type) => { + const h = await setup(``); + expect(await h.fill("not-a-date")).toMatchObject({ + code: "invalid_params", + data: { reason: "fill_value_invalid" }, + }); + expect(h.element.value).toBe(""); + expect(h.insert).not.toHaveBeenCalled(); + }); + + it("clears a date input without insertText", async () => { + const h = await setup(''); + expect(await h.fill("")).toMatchObject({ value_length: 0 }); + expect(h.element.value).toBe(""); + expect(h.insert).not.toHaveBeenCalled(); + }); + + it("flushes deferred background focus before a date value write", async () => { + const h = await setup(''); + vi.spyOn(document, "hasFocus").mockReturnValue(false); + // Happy DOM fires focus synchronously on DOM.focus; inject the deferred + // background focus handler on the flush key event instead. + const send = h.send.getMockImplementation()!; + h.send.mockImplementation(async (tabId, method, params) => { + const reply = await send(tabId, method, params); + if ( + method === "Input.dispatchKeyEvent" && + (params as { type: string; commands?: string[] }).type === "rawKeyDown" && + (params as { commands?: string[] }).commands?.includes("moveToEndOfDocument") + ) { + h.element.value = "2026-01-01"; + } + return reply; + }); + expect(await h.fill("2026-10-14")).toMatchObject({ value_length: 10 }); + expect(h.element.value).toBe("2026-10-14"); + expect(h.insert).not.toHaveBeenCalled(); + expect(h.send).toHaveBeenCalledWith( + 4, + "Input.dispatchKeyEvent", + expect.objectContaining({ type: "rawKeyDown", commands: ["moveToEndOfDocument"] }), + ); + }); + + it("clears a background date after deferred focus restores a value", async () => { + const h = await setup(''); + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const send = h.send.getMockImplementation()!; + h.send.mockImplementation(async (tabId, method, params) => { + const reply = await send(tabId, method, params); + if ( + method === "Input.dispatchKeyEvent" && + (params as { type: string }).type === "rawKeyDown" + ) { + h.element.value = "2026-01-01"; + } + return reply; + }); + + expect(await h.fill("")).toMatchObject({ value_length: 0 }); + expect(h.element.value).toBe(""); + expect(h.insert).not.toHaveBeenCalled(); + }); + + it.each([ + ["date", "2026-10-14", ""], + ["time", "12:34", ":56"], + ["week", "2026-W42", ""], + ] as const)("rejects --no-clear on a %s input before focus", async (type, initial, value) => { + const h = await setup(``); + expect(await h.fill(value, false)).toMatchObject({ + code: "invalid_params", + data: { reason: "fill_value_invalid" }, + message: expect.stringContaining("--no-clear"), + }); + expect(h.element.value).toBe(initial); + expect(h.send).not.toHaveBeenCalledWith(4, "DOM.focus", expect.anything()); + expect(h.insert).not.toHaveBeenCalled(); + }); + + describe("date-like min/max", () => { + // Happy DOM only implements range validity for number inputs. Model the + // browser's check for these fixed-width values to cover the plumbing; the + // Chrome suite covers the browser's own comparison. + const outside = (input: HTMLInputElement, key: "min" | "max") => { + const bound = input.getAttribute(key); + if (!bound || input.value === "") return false; + return key === "min" ? input.value < bound : input.value > bound; + }; + + it.each([ + ["date", 'min="2026-01-01" max="2026-12-31"', "2027-01-01", "max 2026-12-31"], + ["date", 'min="2026-01-01" max="2026-12-31"', "2025-12-31", "min 2026-01-01"], + ["month", 'min="2026-03"', "2026-02", "min 2026-03"], + ["time", 'max="18:00"', "18:30", "max 18:00"], + ] as const)("rejects an out-of-range %s value before focus", async (type, attrs, value, bound) => { + const h = await setup(``); + vi.spyOn(HTMLInputElement.prototype, "validity", "get").mockImplementation(function ( + this: HTMLInputElement, + ) { + return { + rangeUnderflow: outside(this, "min"), + rangeOverflow: outside(this, "max"), + } as ValidityState; + }); + expect(await h.fill(value)).toMatchObject({ + code: "invalid_params", + data: { reason: "fill_value_invalid" }, + message: expect.stringContaining(bound), + }); + expect(h.element.value).toBe(""); + expect(h.send).not.toHaveBeenCalledWith(4, "DOM.focus", expect.anything()); + }); + + it("fills a value on the inclusive bounds", async () => { + const h = await setup(''); + expect(await h.fill("2026-12-31")).toMatchObject({ value_length: 10 }); + expect(h.element.value).toBe("2026-12-31"); + }); + }); + + it("stops a background date fill when flush loses focus", async () => { + const h = await setup(''); + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const other = document.createElement("input"); + document.body.append(other); + const send = h.send.getMockImplementation()!; + h.send.mockImplementation(async (tabId, method, params) => { + const reply = await send(tabId, method, params); + if ( + method === "Input.dispatchKeyEvent" && + (params as { type: string }).type === "rawKeyDown" + ) { + other.focus(); + } + return reply; + }); + expect(await h.fill("2026-10-14")).toMatchObject({ + code: "cdp_failed", + data: { reason: "fill_focus_lost" }, + }); + expect(h.element.value).toBe(""); + expect(h.insert).not.toHaveBeenCalled(); + expect(h.send).toHaveBeenCalledWith( + 4, + "Input.dispatchKeyEvent", + expect.objectContaining({ type: "keyUp" }), + ); + expect(h.release).toHaveBeenCalledOnce(); + }); + + it("notifies shadow hosts with a composed input event for date-like fills", async () => { + document.body.innerHTML = '
'; + const host = document.getElementById("host")!; + const shadow = host.attachShadow({ mode: "open" }); + shadow.innerHTML = ''; + const element = shadow.querySelector("input") as HTMLInputElement; + const manager = new SessionManager({ + agentWindow: { + create: async () => ({ windowId: 100, initialTabIds: [] }), + remove: async () => {}, + ensureActiveTab: async () => 4, + }, + }); + const ctx = await manager.start("aa11"); + ctx.refStore.set("e1", 12, { tabId: 4 }); + const hostInputs: Event[] = []; + const hostInputValues: string[] = []; + host.addEventListener("input", (event) => { + hostInputs.push(event); + hostInputValues.push(element.value); + }); + const script = vi.fn(async (params: ScriptParams): Promise => { + try { + const fn = new Function(`return (${params.functionDeclaration})`)(); + return { + result: { + value: await fn.apply( + element, + (params.arguments ?? []).map((arg) => arg.value), + ), + }, + }; + } catch { + return { exceptionDetails: { text: "test page script failed" } }; + } + }); + const send = vi.fn(async (_tabId: number, method: string, params?: object) => { + switch (method) { + case "DOM.describeNode": + return { + node: { + nodeName: element.tagName, + attributes: Array.from(element.attributes).flatMap((attr) => [attr.name, attr.value]), + }, + }; + case "DOM.scrollIntoViewIfNeeded": + return {}; + case "DOM.focus": + element.focus(); + return {}; + case "DOM.resolveNode": + return { object: { objectId: "fill-target" } }; + case "Runtime.callFunctionOn": + return script(params as ScriptParams); + case "Input.insertText": + throw new Error("date-like fill must not insertText"); + case "Input.dispatchKeyEvent": + return {}; + case "Runtime.releaseObject": + return {}; + default: + throw new Error(`unexpected ${method}`); + } + }); + const result = await handleFill( + manager, + { session_id: "aa11", ref: "e1", value: "2026-10-14", clear_before: true }, + { + cdp: { send: send as CdpRunner["send"] }, + tabsApi: { + get: async (id) => ({ id, windowId: 100, active: true }) as chrome.tabs.Tab, + query: async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab], + }, + }, + ); + expect(result).toMatchObject({ value_length: 10 }); + expect(element.value).toBe("2026-10-14"); + expect(hostInputValues).toEqual(["", "2026-10-14"]); + expect(hostInputs.every((event) => event.composed && event.bubbles)).toBe(true); + }); + it("rejects an overlong value before clearing", async () => { const h = await setup(''); expect(await h.fill()).toMatchObject({ @@ -534,6 +785,52 @@ describe("fill result verification", () => { expect(h.insert).not.toHaveBeenCalled(); }); + it.each([ + true, + false, + ])("flushes background focus before an empty text request (clear_before=%s)", async (clearBefore) => { + const h = await setup(); + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const send = h.send.getMockImplementation()!; + h.send.mockImplementation(async (tabId, method, params) => { + const reply = await send(tabId, method, params); + if ( + method === "Input.dispatchKeyEvent" && + (params as { type: string }).type === "rawKeyDown" + ) { + h.element.value = "default"; + } + return reply; + }); + expect(await h.fill("", clearBefore)).toMatchObject({ value_length: clearBefore ? 0 : 7 }); + expect(h.element.value).toBe(clearBefore ? "" : "default"); + expect(h.insert).not.toHaveBeenCalled(); + }); + + it("does not clear a background text target if deferred focus moves elsewhere", async () => { + const h = await setup(); + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const other = document.createElement("input"); + document.body.append(other); + const send = h.send.getMockImplementation()!; + h.send.mockImplementation(async (tabId, method, params) => { + const reply = await send(tabId, method, params); + if ( + method === "Input.dispatchKeyEvent" && + (params as { type: string }).type === "rawKeyDown" + ) { + other.focus(); + } + return reply; + }); + expect(await h.fill("")).toMatchObject({ + code: "cdp_failed", + data: { reason: "fill_focus_lost" }, + }); + expect(h.element.value).toBe("old"); + expect(h.send).toHaveBeenCalledWith(4, "Input.dispatchKeyEvent", { type: "keyUp" }); + }); + it("cancels after insertion without sending change events", async () => { const h = await setup(); const controller = new AbortController(); diff --git a/apps/extension/src/tools/__tests__/interaction.test.ts b/apps/extension/src/tools/__tests__/interaction.test.ts index d3638c05e..8428d33ba 100644 --- a/apps/extension/src/tools/__tests__/interaction.test.ts +++ b/apps/extension/src/tools/__tests__/interaction.test.ts @@ -1438,7 +1438,7 @@ function successfulFillScript(params: unknown) { return { result: { value: script.functionDeclaration.startsWith("function(value, clearBefore") - ? { before: "", expected: args[0].value } + ? { before: "", expected: args[0].value, write: "insert" } : script.functionDeclaration.startsWith("function(expected)") ? { connected: true, matches: true, valueLength: String(args[0].value).length } : "ready", diff --git a/apps/extension/src/tools/interaction.ts b/apps/extension/src/tools/interaction.ts index 29f18c7ea..fe5a1404c 100644 --- a/apps/extension/src/tools/interaction.ts +++ b/apps/extension/src/tools/interaction.ts @@ -961,7 +961,7 @@ type FillFailureReason = | "fill_failed"; type FillPreparation = - | { before: string; expected: string } + | { before: string; expected: string; write: "insert" | "value" } | { reason: FillFailureReason; error: string }; type FillReadiness = "ready" | "background" | "fill_target_changed" | "fill_focus_lost"; @@ -981,10 +981,14 @@ interface FillScriptReply { exceptionDetails?: unknown; } +// Date-like controls accept a programmatic value, but Chrome's Input.insertText +// leaves them empty. Fill them via the input value setter instead of typing. +const FILL_DATE_LIKE_TYPES = "['date', 'time', 'datetime-local', 'month', 'week']"; + const FILL_EDITABLE_FUNCTION = `function() { const tag = this.tagName.toLowerCase(); const supported = tag === 'input' - ? ['text', 'search', 'tel', 'url', 'email', 'password', 'number'].includes(this.type) + ? ['text', 'search', 'tel', 'url', 'email', 'password', 'number', ...${FILL_DATE_LIKE_TYPES}].includes(this.type) : tag === 'textarea' || (this.isContentEditable && !this.parentElement?.isContentEditable); return this.isConnected && supported && !this.readOnly && !this.matches(':disabled'); }`; @@ -1037,7 +1041,10 @@ const FILL_VALUE_FUNCTION = `function() { const FILL_PREPARATION_FUNCTION = `function(value, clearBefore) { const tag = this.tagName.toLowerCase(); const native = tag === 'input' || tag === 'textarea'; - if (!(${FILL_EDITABLE_FUNCTION}).call(this)) return { reason: 'target_not_fillable', error: 'fill requires an enabled text input, textarea, or contenteditable editing host; target the editor root rather than an internal paragraph or span' }; + const dateLike = tag === 'input' && ${FILL_DATE_LIKE_TYPES}.includes(this.type); + if (!(${FILL_EDITABLE_FUNCTION}).call(this)) return { reason: 'target_not_fillable', error: 'fill requires an enabled text input, textarea, date/time control, or contenteditable editing host; target the editor root rather than an internal paragraph or span' }; + // Appending to a date/time value only concatenates strings; always replace. + if (dateLike && !clearBefore) return { reason: 'fill_value_invalid', error: 'fill --no-clear is not supported for date/time inputs; omit it to replace the value' }; const before = clearBefore ? '' : (${FILL_VALUE_FUNCTION}).call(this); let expected = before + value; if (native) { @@ -1049,20 +1056,31 @@ const FILL_PREPARATION_FUNCTION = `function(value, clearBefore) { normalizer.multiple = this.multiple; } // insertText treats line breaks in a single-line input as spaces. - if (tag === 'input') expected = expected.replace(/\\r\\n|\\r|\\n/g, ' '); + if (tag === 'input' && !dateLike) expected = expected.replace(/\\r\\n|\\r|\\n/g, ' '); normalizer.value = expected; - if (expected !== '' && normalizer.value === '' && this.type === 'number') { - return { reason: 'fill_value_invalid', error: 'fill value is not valid for a number input' }; + if (expected !== '' && normalizer.value === '' && (this.type === 'number' || dateLike)) { + return { reason: 'fill_value_invalid', error: dateLike + ? 'fill value is not valid for this date/time input' + : 'fill value is not valid for a number input' }; } expected = normalizer.value; - if (this.type !== 'number' && this.maxLength >= 0 && expected.length > this.maxLength) { + if (dateLike && expected !== '') { + // Let the browser compare against min/max, including reversed time ranges. + if (this.min) normalizer.min = this.min; + if (this.max) normalizer.max = this.max; + if (normalizer.validity.rangeUnderflow || normalizer.validity.rangeOverflow) { + const bounds = [this.min && 'min ' + this.min, this.max && 'max ' + this.max].filter(Boolean).join(', '); + return { reason: 'fill_value_invalid', error: 'fill value ' + expected + ' is outside the allowed range of this date/time input (' + bounds + ')' }; + } + } + if (!dateLike && this.type !== 'number' && this.maxLength >= 0 && expected.length > this.maxLength) { return { reason: 'fill_value_invalid', error: 'fill value exceeds the target maxlength' }; } } else { expected = expected.replace(/\\r\\n?/g, '\\n'); if (${FILL_COLLAPSES_WHITESPACE}) expected = expected.replace(/\\u00a0/g, ' '); } - return { before, expected }; + return { before, expected, write: dateLike ? 'value' : 'insert' }; }`; export async function handleFill( @@ -1104,7 +1122,7 @@ export async function handleFill( return rpcError( "invalid_params", "target_not_fillable", - `element ${described.node?.nodeName ?? "?"} not fillable; choose a text input, textarea, or the contenteditable editor root`, + `element ${described.node?.nodeName ?? "?"} not fillable; choose a text input, textarea, date/time control, or the contenteditable editor root`, ); } } catch (err) { @@ -1140,7 +1158,11 @@ export async function handleFill( } const plan = preflight.result.value; if ("error" in plan) return fillError(plan.reason, plan.error); - if (typeof plan.before !== "string" || typeof plan.expected !== "string") { + if ( + typeof plan.before !== "string" || + typeof plan.expected !== "string" || + (plan.write !== "insert" && plan.write !== "value") + ) { return fillError("fill_failed", "fill preflight returned an unexpected result"); } const scrollErr = await scrollElementAndFramesIntoView( @@ -1182,6 +1204,49 @@ export async function handleFill( // Check editability before clearing. Keep the expected result tied to // this object, including the existing value on the append path. await deps.cdp.beginDomMutation?.(); + if (plan.write === "insert" && params.value === "") { + // Empty fills do not send insertText, so flush any background focus + // before clearing (or reading an unchanged value for --no-clear). + const focus = await nodeCdp.send< + FillScriptReply<"ready" | "background" | "target_not_fillable" | "fill_focus_lost"> + >(target.tabId, "Runtime.callFunctionOn", { + objectId, + functionDeclaration: `function() { + if (!(${FILL_EDITABLE_FUNCTION}).call(this)) return 'target_not_fillable'; + if (this.getRootNode().activeElement !== this) return 'fill_focus_lost'; + return this.ownerDocument.hasFocus() ? 'ready' : 'background'; + }`, + returnByValue: true, + }); + if (throwIfAborted(deps.signal)) return { code: "cancelled", message: "fill aborted" }; + const state = focus.result?.value; + if ( + focus.exceptionDetails || + (state !== "ready" && + state !== "background" && + state !== "target_not_fillable" && + state !== "fill_focus_lost") + ) { + return fillError( + "fill_failed", + "fill readiness script failed or returned an unexpected result", + ); + } + if (state === "target_not_fillable" || state === "fill_focus_lost") { + return fillError(state, "fill target changed or lost focus before clearing"); + } + if (state === "background") { + try { + await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { + type: "rawKeyDown", + commands: [], + }); + } finally { + await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "keyUp" }); + } + if (throwIfAborted(deps.signal)) return { code: "cancelled", message: "fill aborted" }; + } + } const prepared = await nodeCdp.send>( target.tabId, "Runtime.callFunctionOn", @@ -1217,7 +1282,11 @@ export async function handleFill( if (preparation && "error" in preparation) { return fillError(preparation.reason, preparation.error); } - if (typeof preparation?.before !== "string" || typeof preparation?.expected !== "string") { + if ( + typeof preparation?.before !== "string" || + typeof preparation?.expected !== "string" || + (preparation.write !== "insert" && preparation.write !== "value") + ) { return fillError("fill_failed", "fill preparation returned an unexpected result"); } // A separate call runs after the clearing event's microtasks drain. @@ -1290,46 +1359,119 @@ export async function handleFill( if (ready !== "ready" && ready !== "background") { return fillError(ready, "fill target changed or lost focus before typing"); } - if (params.value !== "" && (!clearBefore || ready === "background")) { - // Native commands position number/email carets without selection APIs. - // For editors, only flush deferred background focus here: the document - // end command can escape a nested editing host. Place its range below. - // Neither event carries an End key that could invoke a page shortcut. - try { - await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { - type: "rawKeyDown", - commands: native ? ["moveToEndOfDocument"] : [], - }); - } finally { - await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "keyUp" }); + if (preparation.write === "value") { + // Date-like inputs have no text caret and always replace (preparation + // rejects --no-clear); assign the sanitized value directly. Still flush + // deferred background focus first so a later tab switch cannot replay a + // pending focus handler over the value we are about to write. + if (ready === "background") { + try { + await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { + type: "rawKeyDown", + commands: ["moveToEndOfDocument"], + }); + } finally { + await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "keyUp" }); + } + if (throwIfAborted(deps.signal)) { + return { code: "cancelled", message: "fill aborted" }; + } + // The replacement may overwrite a value set by a focus handler. + const focusReply = await nodeCdp.send>( + target.tabId, + "Runtime.callFunctionOn", + { + objectId, + functionDeclaration: `function() { + if (!(${FILL_EDITABLE_FUNCTION}).call(this)) return 'fill_target_changed'; + if (this.getRootNode().activeElement !== this) return 'fill_focus_lost'; + return this.ownerDocument.hasFocus() ? 'ready' : 'background'; + }`, + returnByValue: true, + }, + ); + ready = focusReply.result?.value as FillReadiness; + if ( + focusReply.exceptionDetails || + (ready !== "ready" && + ready !== "background" && + ready !== "fill_target_changed" && + ready !== "fill_focus_lost") + ) { + return fillError( + "fill_failed", + "fill readiness script failed or returned an unexpected result", + ); + } + if (throwIfAborted(deps.signal)) { + return { code: "cancelled", message: "fill aborted" }; + } + if (ready !== "ready" && ready !== "background") { + return fillError(ready, "fill target changed or lost focus before assigning the value"); + } } + // Assign even an empty value: a deferred focus handler may have filled + // the field again after the initial clearing step. + const assigned = await nodeCdp.send>( + target.tabId, + "Runtime.callFunctionOn", + { + objectId, + functionDeclaration: `function(expected) { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(this, expected); + }`, + arguments: [{ value: preparation.expected }], + returnByValue: true, + }, + ); if (throwIfAborted(deps.signal)) { return { code: "cancelled", message: "fill aborted" }; } - } - if (params.value !== "" && (!native || !clearBefore || ready === "background")) { - // Recheck after deferred focus handlers, then place the caret only in - // this editing host. Empty hosts need no temporary DOM characters. - ready = await checkReady(!native); - if (throwIfAborted(deps.signal)) return { code: "cancelled", message: "fill aborted" }; - if (!native && (ready === "ready" || ready === "background")) { - // Selection changes can run page handlers after the placement script. - ready = await checkReady(false, true); + if (assigned.exceptionDetails) { + return fillError("fill_failed", "fill value assignment script failed"); } - if (throwIfAborted(deps.signal)) return { code: "cancelled", message: "fill aborted" }; - if (typeof ready !== "string") return ready; - if (ready !== "ready" && ready !== "background") { - return fillError(ready, "fill target changed or lost focus while positioning the caret"); + } else { + if (params.value !== "" && (!clearBefore || ready === "background")) { + // Native commands position number/email carets without selection APIs. + // For editors, only flush deferred background focus here: the document + // end command can escape a nested editing host. Place its range below. + // Neither event carries an End key that could invoke a page shortcut. + try { + await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { + type: "rawKeyDown", + commands: native ? ["moveToEndOfDocument"] : [], + }); + } finally { + await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "keyUp" }); + } + if (throwIfAborted(deps.signal)) { + return { code: "cancelled", message: "fill aborted" }; + } + } + if (params.value !== "" && (!native || !clearBefore || ready === "background")) { + // Recheck after deferred focus handlers, then place the caret only in + // this editing host. Empty hosts need no temporary DOM characters. + ready = await checkReady(!native); + if (throwIfAborted(deps.signal)) return { code: "cancelled", message: "fill aborted" }; + if (!native && (ready === "ready" || ready === "background")) { + // Selection changes can run page handlers after the placement script. + ready = await checkReady(false, true); + } + if (throwIfAborted(deps.signal)) return { code: "cancelled", message: "fill aborted" }; + if (typeof ready !== "string") return ready; + if (ready !== "ready" && ready !== "background") { + return fillError(ready, "fill target changed or lost focus while positioning the caret"); + } + } + // CDP `Input.insertText` handles IME / multi-byte input out of the + // box, much more reliably than per-key `dispatchKeyEvent`. + if (params.value !== "") { + // Normalize the actual input as well as the expected value: older Chrome + // versions retain CR in editors or turn CRLF into two textarea breaks. + await deps.cdp.send(target.tabId, "Input.insertText", { + text: params.value.replace(/\r\n?/g, "\n"), + }); } - } - // CDP `Input.insertText` handles IME / multi-byte input out of the - // box, much more reliably than per-key `dispatchKeyEvent`. - if (params.value !== "") { - // Normalize the actual input as well as the expected value: older Chrome - // versions retain CR in editors or turn CRLF into two textarea breaks. - await deps.cdp.send(target.tabId, "Input.insertText", { - text: params.value.replace(/\r\n?/g, "\n"), - }); } if (throwIfAborted(deps.signal)) { return { code: "cancelled", message: "fill aborted" }; diff --git a/crates/bsk-cli/skill/SKILL.md b/crates/bsk-cli/skill/SKILL.md index 86afe6a35..9fc551611 100644 --- a/crates/bsk-cli/skill/SKILL.md +++ b/crates/bsk-cli/skill/SKILL.md @@ -87,7 +87,7 @@ Prefer refs for iframe/shadow-root targets; CSS selectors search the main docume | Need | Command | | --- | --- | | Click | `bsk click @e3 --session --context ID` | -| Fill a field | `bsk fill @e3 --value "text" --session --context ID` | +| Fill text/date/time | `bsk fill @e3 --value "text" --session --context ID` | | Select an option | `bsk select @e3 --value "option-value" --session --context ID` | | Press a key | `bsk press Enter --ref @e3 --session --context ID` | | Click–move–click range | `bsk drag @e3 --to @e8 --mode click-move-click --session --context ID` | @@ -118,7 +118,7 @@ acting; load others only as needed. | Website debugging, reproduction evidence, or request rules/replay | [Debugging](references/debugging.md) | | Required profile, existing user tab, multiple/background tabs, or remote tab ownership | [Tabs and profiles](references/tabs-and-profiles.md) | | Missing CLI, daemon startup failure, sandboxed startup, connection failure, or remote pairing | [Environment](references/environment.md) | -| Dragging, click–move–click ranges/selection state, waits, hover menus/probing, scrolling, `next_cursor`/`@more`, console/network, emulation, evaluation, or recording | [Interaction details](references/interaction-details.md) | +| Date/time values, drag, click–move–click selection, waits, hover menus, scrolling, `next_cursor`/`@more`, console/network, emulation, evaluation, or recording | [Interaction details](references/interaction-details.md) | | Screenshot, full-page capture, or `[visual:screenshot]`/Canvas interaction | [Screenshots and Canvas](references/screenshots-and-canvas.md) | | Video recording or MP4 export | [Video](references/video.md) | | Upload or download | [Files](references/files.md) | diff --git a/crates/bsk-cli/skill/references/interaction-details.md b/crates/bsk-cli/skill/references/interaction-details.md index d52fd1d0e..fd53ab412 100644 --- a/crates/bsk-cli/skill/references/interaction-details.md +++ b/crates/bsk-cli/skill/references/interaction-details.md @@ -83,17 +83,24 @@ Do not infer the first-selected cell or range direction from a static highlight. Verify the final displayed range/form and, when requested, the eventual business success message separately. -### Filling text +### Filling fields `fill` supports enabled, editable text inputs, textareas, and contenteditable -editor roots. Target the whole editing host, not an internal paragraph or span, +editor roots, as well as `date`, `time`, `datetime-local`, `month`, and `week` +inputs. Use browser value formats such as `2026-10-14`, `14:30`, +`2026-10-14T14:30`, `2026-10`, and `2026-W42`, respectively. Use `bsk upload` +for file inputs. + +Target the whole editing host, not an internal paragraph or span, even if that child has `tabindex` or repeats `contenteditable`. Unsupported targets are rejected without focusing or changing them; the tool never promotes a child target to its parent editor. By default, fill replaces all text in the target. `--no-clear` appends at its end. -An empty value clears the target unless `--no-clear` is set. Use the editor root's -ref from a fresh observation. After a mid-operation failure or cancellation, +An empty value clears the target unless `--no-clear` is set. Date/time inputs +always replace their value: `--no-clear` is rejected, and the value must be valid +and within the input's `min`/`max`. Use the editor root's ref from a fresh +observation. After a mid-operation failure or cancellation, observe before retrying: earlier input events or edits may already have occurred. Rich-text framework state and undo behavior require application-specific checks. diff --git a/evals/browser/cases/regression/fill-editor-roots/fill-editor-roots.fixture.mjs b/evals/browser/cases/regression/fill-editor-roots/fill-editor-roots.fixture.mjs index 73eff1932..1be2fae54 100644 --- a/evals/browser/cases/regression/fill-editor-roots/fill-editor-roots.fixture.mjs +++ b/evals/browser/cases/regression/fill-editor-roots/fill-editor-roots.fixture.mjs @@ -124,6 +124,21 @@ export const acceptedTargets = [ ["!", false, "hello!"], ], }, + ...[ + ["date", "2026-10-14"], + ["time", "14:30"], + ["datetime-local", "2026-10-14T14:30"], + ["month", "2026-10"], + ["week", "2026-W42"], + ].map(([type, value]) => ({ + name: `${type} input`, + markup: ``, + steps: [ + [value, true, value], + ["", true, ""], + [value, true, value], + ], + })), ]; export const rejectedTargets = [ @@ -147,7 +162,7 @@ export const rejectedTargets = [ name: "disabled fieldset", markup: '
', }, - ...["checkbox", "radio", "range", "date", "file"].map((type) => ({ + ...["checkbox", "radio", "range", "file"].map((type) => ({ name: `${type} input`, markup: ``, })), diff --git a/packages/vom/src/__tests__/render.test.ts b/packages/vom/src/__tests__/render.test.ts index 9f5a6c9cb..37755787c 100644 --- a/packages/vom/src/__tests__/render.test.ts +++ b/packages/vom/src/__tests__/render.test.ts @@ -67,6 +67,41 @@ it("measures allocated reference names within the token budget", () => { }); describe("renderVom single-layer page", () => { + it.each([ + ["date", "Date"], + ["DATE", "Date"], + ["time", "InputTime"], + ["datetime-local", "DateTime"], + ["month", "DateTime"], + ["week", "DateTime"], + ] as const)("exposes an unnamed %s input as a reference", (type, role) => { + const out = renderVom( + scene([ + node({ + id: 1, + backendNodeId: 42, + tag: "input", + role, + attrs: { type }, + rect: { x: 0, y: 0, w: 200, h: 40 }, + }), + ]), + ); + expect(out.text).toContain(`@e1 ${role}`); + expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 42 }]); + }); + + it("does not promote passive or unaddressable date-like nodes to references", () => { + const out = renderVom( + scene([ + node({ id: 1, tag: "div", role: "Date", name: "Today", attrs: { type: "date" } }), + node({ id: 2, tag: "input", role: "presentation", attrs: { type: "date" } }), + node({ id: 3, tag: "input", role: "Date", attrs: { type: "date" }, referenceable: false }), + ]), + ); + expect(out.refs).toEqual([]); + }); + it.each([ "", "true", diff --git a/packages/vom/src/render.ts b/packages/vom/src/render.ts index eaedd1e8b..7a3ef7cc6 100644 --- a/packages/vom/src/render.ts +++ b/packages/vom/src/render.ts @@ -92,6 +92,7 @@ const MAX_SURFACE_ITEMS = 12; const MAX_SCOPE_LINES = 40; const RECOVERY_HANDLER_ATTRS = ["onclick", "onmousedown", "onkeydown", "onkeyup", "onkeypress"]; const NATIVE_TAGS = new Set(["button", "input", "select", "textarea"]); +const DATE_LIKE_INPUT_TYPES = new Set(["date", "time", "datetime-local", "month", "week"]); const GRAPHIC_ONLY_TAGS = new Set(["svg", "use", "path", "g", "symbol"]); const EXPLICIT_RECOVERY_ROLES = new Set([ "button", @@ -173,6 +174,16 @@ function normalizedTag(node: VomNode): string { return node.tag.toLowerCase(); } +function isDateLikeInput(node: VomNode): boolean { + const role = normalizedRole(node); + return ( + normalizedTag(node) === "input" && + role !== "" && + !SKIP_ROLES.has(role) && + DATE_LIKE_INPUT_TYPES.has(node.attrs?.type?.toLowerCase() ?? "") + ); +} + function normalizeContextKey(s: string): string { return s.split(/\s+/).join(" ").toLowerCase(); } @@ -231,7 +242,7 @@ function indexedItemName(node: VomNode): string | undefined { } function shouldRender(node: VomNode): boolean { - if (dragHint(node)) return true; + if (dragHint(node) || isDateLikeInput(node)) return true; if (!node.role) return false; const role = normalizedRole(node); @@ -245,7 +256,9 @@ function shouldRender(node: VomNode): boolean { function isSemanticReferenceNode(node: VomNode): boolean { return ( node.referenceable !== false && - (INTERACTIVE_ROLES.has(normalizedRole(node)) || isUrlBearingStructuralAction(node)) + (INTERACTIVE_ROLES.has(normalizedRole(node)) || + isDateLikeInput(node) || + isUrlBearingStructuralAction(node)) ); }