From a8ff141c9dac357e189ee385c98409368023b74f Mon Sep 17 00:00:00 2001
From: Li-john1021 <215793081+Li-john1021@users.noreply.github.com>
Date: Wed, 14 Oct 2026 11:27:03 +0800
Subject: [PATCH 1/7] fix(fill): support date, time, datetime-local, month, and
week inputs
Chrome's Input.insertText leaves date-like controls empty. Allow those
types in the live fill preflight, assign via the input value setter, and
reject invalid values the same way as number inputs. Checkbox, radio,
range, and file stay out of fill.
---
.../src/tools/__tests__/fill.test.ts | 38 ++++-
.../src/tools/__tests__/interaction.test.ts | 2 +-
apps/extension/src/tools/interaction.ts | 133 +++++++++++-------
crates/bsk-cli/skill/SKILL.md | 2 +-
.../fill-editor-roots.fixture.mjs | 17 ++-
5 files changed, 141 insertions(+), 51 deletions(-)
diff --git a/apps/extension/src/tools/__tests__/fill.test.ts b/apps/extension/src/tools/__tests__/fill.test.ts
index 112cfe5fa..cb5860d7e 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,43 @@ 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("rejects an overlong value before clearing", async () => {
const h = await setup('');
expect(await h.fill()).toMatchObject({
diff --git a/apps/extension/src/tools/__tests__/interaction.test.ts b/apps/extension/src/tools/__tests__/interaction.test.ts
index 6420edb7a..f3d0388c5 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 143546a70..5a60ce472 100644
--- a/apps/extension/src/tools/interaction.ts
+++ b/apps/extension/src/tools/interaction.ts
@@ -957,7 +957,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";
@@ -977,10 +977,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');
}`;
@@ -1033,7 +1037,8 @@ 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' };
const before = clearBefore ? '' : (${FILL_VALUE_FUNCTION}).call(this);
let expected = before + value;
if (native) {
@@ -1045,20 +1050,22 @@ 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 && 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(
@@ -1100,7 +1107,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) {
@@ -1136,7 +1143,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(
@@ -1213,7 +1224,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.
@@ -1286,47 +1301,71 @@ 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; assign the sanitized value directly.
+ if (preparation.expected !== "") {
+ 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 (assigned.exceptionDetails) {
+ return fillError("fill_failed", "fill value assignment script failed");
+ }
}
- if (throwIfAborted(deps.signal)) {
- return { code: "cancelled", message: "fill aborted" };
+ } 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 (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");
+ }
}
- 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 bee6bc974..d17c06d73 100644
--- a/crates/bsk-cli/skill/SKILL.md
+++ b/crates/bsk-cli/skill/SKILL.md
@@ -91,7 +91,7 @@ Use a ref and context ID from the latest sync (one action per context):
| Need | Command |
| --- | --- |
| Click | `bsk click @e3 --session --context ID` |
-| Fill a field | `bsk fill @e3 --value "text" --session --context ID` |
+| Fill a field | `bsk fill @e3 --value "text" --session --context ID` (also `date`/`time`/`datetime-local`/`month`/`week`; use `bsk upload` for file) |
| Select an option | `bsk select @e3 --value "option-value" --session --context ID` |
| Press a key | `bsk press Enter --ref @e3 --session --context ID` |
| Reveal a hover menu | `bsk hover @e3 --session --context ID` |
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..fd80b792c 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, false, 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: ``,
})),
From 3539373f36a12c6ca5aa55748e2aef777b7adbd4 Mon Sep 17 00:00:00 2001
From: Li-john1021 <215793081+Li-john1021@users.noreply.github.com>
Date: Wed, 14 Oct 2026 15:00:37 +0800
Subject: [PATCH 2/7] fix(fill): flush deferred focus and compose date-like
input events
Address review on #443: flush deferred background focus before the
date-like value write so a later tab switch cannot overwrite the fill,
and dispatch composed input/change events so Shadow DOM hosts are notified.
---
.../src/tools/__tests__/fill.browser.test.ts | 58 ++++++++
.../src/tools/__tests__/fill.test.ts | 135 ++++++++++++++++++
apps/extension/src/tools/interaction.ts | 53 ++++++-
3 files changed, 244 insertions(+), 2 deletions(-)
diff --git a/apps/extension/src/tools/__tests__/fill.browser.test.ts b/apps/extension/src/tools/__tests__/fill.browser.test.ts
index 10e98eb73..9f18f8bfc 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",
@@ -478,6 +482,60 @@ 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)("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 }]);
+ expect(h.calls).not.toContain("Input.insertText");
+ },
+ );
+ }, 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 cb5860d7e..e9ea15f9f 100644
--- a/apps/extension/src/tools/__tests__/fill.test.ts
+++ b/apps/extension/src/tools/__tests__/fill.test.ts
@@ -185,6 +185,141 @@ describe("fill result verification", () => {
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("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[] = [];
+ host.addEventListener("input", (event) => hostInputs.push(event));
+ 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(hostInputs).toHaveLength(1);
+ expect(hostInputs[0].composed).toBe(true);
+ expect(hostInputs[0].bubbles).toBe(true);
+ });
+
it("rejects an overlong value before clearing", async () => {
const h = await setup('');
expect(await h.fill()).toMatchObject({
diff --git a/apps/extension/src/tools/interaction.ts b/apps/extension/src/tools/interaction.ts
index 5a60ce472..5a4378027 100644
--- a/apps/extension/src/tools/interaction.ts
+++ b/apps/extension/src/tools/interaction.ts
@@ -1303,6 +1303,55 @@ export async function handleFill(
}
if (preparation.write === "value") {
// Date-like inputs have no text caret; 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 (params.value !== "" && (!clearBefore || 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" };
+ }
+ // Value write overwrites the field next, so only recheck focus/editability
+ // after deferred focus handlers — not preparation.before equality.
+ 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");
+ }
+ }
if (preparation.expected !== "") {
const assigned = await nodeCdp.send>(
target.tabId,
@@ -1377,8 +1426,8 @@ export async function handleFill(
{
objectId,
functionDeclaration: `function() {
- this.dispatchEvent(new Event('input', { bubbles: true }));
- this.dispatchEvent(new Event('change', { bubbles: true }));
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
+ this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
}`,
returnByValue: true,
},
From 26cad7f6d448e94a06933634a79dfa4a4759afb8 Mon Sep 17 00:00:00 2001
From: Li-john1021 <215793081+Li-john1021@users.noreply.github.com>
Date: Fri, 23 Oct 2026 22:09:26 +0800
Subject: [PATCH 3/7] style(fill): biome-format fill.browser.test.ts
---
.../src/tools/__tests__/fill.browser.test.ts | 36 +++++++++++--------
1 file changed, 21 insertions(+), 15 deletions(-)
diff --git a/apps/extension/src/tools/__tests__/fill.browser.test.ts b/apps/extension/src/tools/__tests__/fill.browser.test.ts
index 9f18f8bfc..e32affb22 100644
--- a/apps/extension/src/tools/__tests__/fill.browser.test.ts
+++ b/apps/extension/src/tools/__tests__/fill.browser.test.ts
@@ -508,11 +508,13 @@ describe.skipIf(!process.env.BSK_FILL_CHROME)("date-like fill regressions", () =
["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(`(() => {
+ ] 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 = '';
@@ -524,16 +526,20 @@ describe.skipIf(!process.env.BSK_FILL_CHROME)("date-like fill regressions", () =
});
});
})()`);
- 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 }]);
- expect(h.calls).not.toContain("Input.insertText");
- },
- );
- }, 30_000);
+ 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 }]);
+ expect(h.calls).not.toContain("Input.insertText");
+ },
+ );
+ },
+ 30_000,
+ );
});
describe.skipIf(!process.env.BSK_FILL_CHROME)("fill caret validation", () => {
From afdc5a6da9a132acf87106bf37491fb112c2738d Mon Sep 17 00:00:00 2001
From: NianJiuZst <3235467914@qq.com>
Date: Mon, 2 Nov 2026 03:12:26 -0600
Subject: [PATCH 4/7] fix(fill): preserve background date input state
---
.../src/tools/__tests__/fill.browser.test.ts | 85 ++++++++++++++++++-
.../src/tools/__tests__/fill.test.ts | 54 +++++++++++-
apps/extension/src/tools/interaction.ts | 50 +++++------
crates/bsk-cli/skill/SKILL.md | 4 +-
.../skill/references/interaction-details.md | 14 ++-
5 files changed, 172 insertions(+), 35 deletions(-)
diff --git a/apps/extension/src/tools/__tests__/fill.browser.test.ts b/apps/extension/src/tools/__tests__/fill.browser.test.ts
index 7b2512058..4bf97c83f 100644
--- a/apps/extension/src/tools/__tests__/fill.browser.test.ts
+++ b/apps/extension/src/tools/__tests__/fill.browser.test.ts
@@ -518,6 +518,61 @@ describe.skipIf(!process.env.BSK_FILL_CHROME)("date-like fill regressions", () =
);
}, 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([
+ ":56",
+ "",
+ ])("does not overwrite a value changed by deferred focus when appending %s", async (value) => {
+ 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 = '08:00';
+ })`);
+ expect(await h.fill(value, false)).toMatchObject({
+ code: "cdp_failed",
+ data: { reason: "fill_target_changed" },
+ });
+ expect(await h.evaluate("document.querySelector('#target').value")).toBe("08:00");
+ expect(h.calls).not.toContain("Input.insertText");
+ },
+ );
+ }, 30_000);
+
it.each([
["date", "2026-10-14"],
["time", "14:30"],
@@ -549,13 +604,41 @@ describe.skipIf(!process.env.BSK_FILL_CHROME)("date-like fill regressions", () =
"document.querySelector('#host').shadowRoot.querySelector('#target').value",
),
).toBe(value);
- expect(await h.evaluate("window.hostInputEvents")).toEqual([{ composed: true, 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", () => {
diff --git a/apps/extension/src/tools/__tests__/fill.test.ts b/apps/extension/src/tools/__tests__/fill.test.ts
index e9ea15f9f..1849955f9 100644
--- a/apps/extension/src/tools/__tests__/fill.test.ts
+++ b/apps/extension/src/tools/__tests__/fill.test.ts
@@ -212,6 +212,49 @@ describe("fill result verification", () => {
);
});
+ 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("preserves a date-like value changed by deferred focus when appending", 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 = "08:00";
+ }
+ return reply;
+ });
+
+ expect(await h.fill(":56", false)).toMatchObject({
+ code: "cdp_failed",
+ data: { reason: "fill_target_changed" },
+ });
+ expect(h.element.value).toBe("08:00");
+ expect(h.insert).not.toHaveBeenCalled();
+ });
+
it("stops a background date fill when flush loses focus", async () => {
const h = await setup('');
vi.spyOn(document, "hasFocus").mockReturnValue(false);
@@ -258,7 +301,11 @@ describe("fill result verification", () => {
const ctx = await manager.start("aa11");
ctx.refStore.set("e1", 12, { tabId: 4 });
const hostInputs: Event[] = [];
- host.addEventListener("input", (event) => hostInputs.push(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})`)();
@@ -315,9 +362,8 @@ describe("fill result verification", () => {
);
expect(result).toMatchObject({ value_length: 10 });
expect(element.value).toBe("2026-10-14");
- expect(hostInputs).toHaveLength(1);
- expect(hostInputs[0].composed).toBe(true);
- expect(hostInputs[0].bubbles).toBe(true);
+ expect(hostInputValues).toEqual(["", "2026-10-14"]);
+ expect(hostInputs.every((event) => event.composed && event.bubbles)).toBe(true);
});
it("rejects an overlong value before clearing", async () => {
diff --git a/apps/extension/src/tools/interaction.ts b/apps/extension/src/tools/interaction.ts
index 1543cc3d3..3032537ea 100644
--- a/apps/extension/src/tools/interaction.ts
+++ b/apps/extension/src/tools/interaction.ts
@@ -1309,7 +1309,7 @@ export async function handleFill(
// Date-like inputs have no text caret; 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 (params.value !== "" && (!clearBefore || ready === "background")) {
+ if (ready === "background" || (params.value !== "" && !clearBefore)) {
try {
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
type: "rawKeyDown",
@@ -1321,18 +1321,20 @@ export async function handleFill(
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
- // Value write overwrites the field next, so only recheck focus/editability
- // after deferred focus handlers — not preparation.before equality.
+ // A replacement may overwrite a value set by a focus handler, but an
+ // append must not silently discard changes to the value it builds on.
const focusReply = await nodeCdp.send>(
target.tabId,
"Runtime.callFunctionOn",
{
objectId,
- functionDeclaration: `function() {
- if (!(${FILL_EDITABLE_FUNCTION}).call(this)) return 'fill_target_changed';
+ functionDeclaration: `function(before, clearBefore) {
+ if (!(${FILL_EDITABLE_FUNCTION}).call(this) ||
+ (!clearBefore && (${FILL_VALUE_FUNCTION}).call(this) !== before)) return 'fill_target_changed';
if (this.getRootNode().activeElement !== this) return 'fill_focus_lost';
return this.ownerDocument.hasFocus() ? 'ready' : 'background';
}`,
+ arguments: [{ value: preparation.before }, { value: clearBefore }],
returnByValue: true,
},
);
@@ -1356,25 +1358,25 @@ export async function handleFill(
return fillError(ready, "fill target changed or lost focus before assigning the value");
}
}
- if (preparation.expected !== "") {
- 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 (assigned.exceptionDetails) {
- return fillError("fill_failed", "fill value assignment script failed");
- }
+ // 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 (assigned.exceptionDetails) {
+ return fillError("fill_failed", "fill value assignment script failed");
}
} else {
if (params.value !== "" && (!clearBefore || ready === "background")) {
diff --git a/crates/bsk-cli/skill/SKILL.md b/crates/bsk-cli/skill/SKILL.md
index 12fa4b73c..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` (also `date`/`time`/`datetime-local`/`month`/`week`; use `bsk upload` for file) |
+| 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..f15bf43a6 100644
--- a/crates/bsk-cli/skill/references/interaction-details.md
+++ b/crates/bsk-cli/skill/references/interaction-details.md
@@ -83,17 +83,23 @@ 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. For date/time inputs,
+the combined value must be valid. 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.
From 05127ff96bf8eba1b952763f67b140379b06567b Mon Sep 17 00:00:00 2001
From: NianJiuZst <3235467914@qq.com>
Date: Mon, 2 Nov 2026 03:19:20 -0600
Subject: [PATCH 5/7] fix(vom): expose date inputs as fillable references
---
.../src/tools/__tests__/fill.browser.test.ts | 61 +++++++++++++++++++
packages/vom/src/__tests__/render.test.ts | 35 +++++++++++
packages/vom/src/render.ts | 17 +++++-
3 files changed, 111 insertions(+), 2 deletions(-)
diff --git a/apps/extension/src/tools/__tests__/fill.browser.test.ts b/apps/extension/src/tools/__tests__/fill.browser.test.ts
index 4bf97c83f..d795c448e 100644
--- a/apps/extension/src/tools/__tests__/fill.browser.test.ts
+++ b/apps/extension/src/tools/__tests__/fill.browser.test.ts
@@ -360,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 + '' },
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))
);
}
From fce6cad0bea320418fc77372e66be4b2a1b394f3 Mon Sep 17 00:00:00 2001
From: NianJiuZst <3235467914@qq.com>
Date: Mon, 2 Nov 2026 03:28:41 -0600
Subject: [PATCH 6/7] fix(fill): flush background focus before empty text fills
---
.../src/tools/__tests__/fill.browser.test.ts | 37 +++++++++++++++
.../src/tools/__tests__/fill.test.ts | 46 +++++++++++++++++++
apps/extension/src/tools/interaction.ts | 43 +++++++++++++++++
3 files changed, 126 insertions(+)
diff --git a/apps/extension/src/tools/__tests__/fill.browser.test.ts b/apps/extension/src/tools/__tests__/fill.browser.test.ts
index d795c448e..669fb6243 100644
--- a/apps/extension/src/tools/__tests__/fill.browser.test.ts
+++ b/apps/extension/src/tools/__tests__/fill.browser.test.ts
@@ -529,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,
diff --git a/apps/extension/src/tools/__tests__/fill.test.ts b/apps/extension/src/tools/__tests__/fill.test.ts
index 1849955f9..734c24371 100644
--- a/apps/extension/src/tools/__tests__/fill.test.ts
+++ b/apps/extension/src/tools/__tests__/fill.test.ts
@@ -751,6 +751,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/interaction.ts b/apps/extension/src/tools/interaction.ts
index 3032537ea..81d85cf14 100644
--- a/apps/extension/src/tools/interaction.ts
+++ b/apps/extension/src/tools/interaction.ts
@@ -1193,6 +1193,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",
From 7500107ba7cdd6a376516b74528aee1a90801fc7 Mon Sep 17 00:00:00 2001
From: NianJiuZst <3235467914@qq.com>
Date: Mon, 2 Nov 2026 04:09:51 -0600
Subject: [PATCH 7/7] fix(fill): reject --no-clear and enforce min/max for
date-like inputs
Appending to a date/time value only concatenated strings, so --no-clear
either failed with a misleading "invalid value" error or did nothing
useful. Reject it before focusing the target and drop the now-dead
append handling from the value-write path.
Validate date-like values against the input's min/max using the
browser's own range validity (which also covers reversed time ranges)
and reject out-of-range values before focus with the bounds in the
error message.
Co-Authored-By: Claude Opus 5.5
---
.../src/tools/__tests__/fill.browser.test.ts | 102 ++++++++++++++----
.../src/tools/__tests__/fill.test.ts | 68 +++++++++---
apps/extension/src/tools/interaction.ts | 29 +++--
.../skill/references/interaction-details.md | 5 +-
.../fill-editor-roots.fixture.mjs | 2 +-
5 files changed, 154 insertions(+), 52 deletions(-)
diff --git a/apps/extension/src/tools/__tests__/fill.browser.test.ts b/apps/extension/src/tools/__tests__/fill.browser.test.ts
index 669fb6243..425661fa7 100644
--- a/apps/extension/src/tools/__tests__/fill.browser.test.ts
+++ b/apps/extension/src/tools/__tests__/fill.browser.test.ts
@@ -648,28 +648,86 @@ describe.skipIf(!process.env.BSK_FILL_CHROME)("date-like fill regressions", () =
);
it.each([
- ":56",
- "",
- ])("does not overwrite a value changed by deferred focus when appending %s", async (value) => {
- 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 = '08:00';
- })`);
- expect(await h.fill(value, false)).toMatchObject({
- code: "cdp_failed",
- data: { reason: "fill_target_changed" },
- });
- expect(await h.evaluate("document.querySelector('#target').value")).toBe("08:00");
- expect(h.calls).not.toContain("Input.insertText");
- },
- );
- }, 30_000);
+ ["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"],
diff --git a/apps/extension/src/tools/__tests__/fill.test.ts b/apps/extension/src/tools/__tests__/fill.test.ts
index 734c24371..1a8b7fc62 100644
--- a/apps/extension/src/tools/__tests__/fill.test.ts
+++ b/apps/extension/src/tools/__tests__/fill.test.ts
@@ -232,27 +232,61 @@ describe("fill result verification", () => {
expect(h.insert).not.toHaveBeenCalled();
});
- it("preserves a date-like value changed by deferred focus when appending", 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"
+ 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,
) {
- h.element.value = "08:00";
- }
- return reply;
+ 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());
});
- expect(await h.fill(":56", false)).toMatchObject({
- code: "cdp_failed",
- data: { reason: "fill_target_changed" },
+ 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");
});
- expect(h.element.value).toBe("08:00");
- expect(h.insert).not.toHaveBeenCalled();
});
it("stops a background date fill when flush loses focus", async () => {
diff --git a/apps/extension/src/tools/interaction.ts b/apps/extension/src/tools/interaction.ts
index 81d85cf14..fe5a1404c 100644
--- a/apps/extension/src/tools/interaction.ts
+++ b/apps/extension/src/tools/interaction.ts
@@ -1043,6 +1043,8 @@ const FILL_PREPARATION_FUNCTION = `function(value, clearBefore) {
const native = tag === 'input' || tag === 'textarea';
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) {
@@ -1062,6 +1064,15 @@ const FILL_PREPARATION_FUNCTION = `function(value, clearBefore) {
: 'fill value is not valid for a number input' };
}
expected = normalizer.value;
+ 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' };
}
@@ -1349,10 +1360,11 @@ export async function handleFill(
return fillError(ready, "fill target changed or lost focus before typing");
}
if (preparation.write === "value") {
- // Date-like inputs have no text caret; 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" || (params.value !== "" && !clearBefore)) {
+ // 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",
@@ -1364,20 +1376,17 @@ export async function handleFill(
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
- // A replacement may overwrite a value set by a focus handler, but an
- // append must not silently discard changes to the value it builds on.
+ // The replacement may overwrite a value set by a focus handler.
const focusReply = await nodeCdp.send>(
target.tabId,
"Runtime.callFunctionOn",
{
objectId,
- functionDeclaration: `function(before, clearBefore) {
- if (!(${FILL_EDITABLE_FUNCTION}).call(this) ||
- (!clearBefore && (${FILL_VALUE_FUNCTION}).call(this) !== before)) return 'fill_target_changed';
+ 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';
}`,
- arguments: [{ value: preparation.before }, { value: clearBefore }],
returnByValue: true,
},
);
diff --git a/crates/bsk-cli/skill/references/interaction-details.md b/crates/bsk-cli/skill/references/interaction-details.md
index f15bf43a6..fd53ab412 100644
--- a/crates/bsk-cli/skill/references/interaction-details.md
+++ b/crates/bsk-cli/skill/references/interaction-details.md
@@ -97,8 +97,9 @@ 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. For date/time inputs,
-the combined value must be valid. Use the editor root's ref from a fresh
+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 fd80b792c..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
@@ -136,7 +136,7 @@ export const acceptedTargets = [
steps: [
[value, true, value],
["", true, ""],
- [value, false, value],
+ [value, true, value],
],
})),
];