diff --git a/apps/extension/src/tools/__tests__/fill.test.ts b/apps/extension/src/tools/__tests__/fill.test.ts
index 53adbf5..c7dba29 100644
--- a/apps/extension/src/tools/__tests__/fill.test.ts
+++ b/apps/extension/src/tools/__tests__/fill.test.ts
@@ -35,13 +35,24 @@ async function setup(markup = '') {
return { exceptionDetails: { text: "test page script failed" } };
}
});
+ // Number/email controls have a browser caret even though their selection
+ // getters return null. Model that caret instead of assuming it is at the end.
+ const hiddenCaret = { start: 0, end: 0 };
+ const moveToEnd = vi.fn(async () => {
+ if (typeof element.selectionStart === "number") {
+ element.setSelectionRange(element.value.length, element.value.length);
+ } else {
+ hiddenCaret.start = hiddenCaret.end = element.value.length;
+ }
+ });
const insert = vi.fn(async (text: string) => {
const focused = document.activeElement as HTMLInputElement;
if (focused.readOnly || focused.disabled) return;
- const start = focused.selectionStart ?? focused.value.length;
- const end = focused.selectionEnd ?? start;
+ const start = focused.selectionStart ?? hiddenCaret.start;
+ const end = focused.selectionEnd ?? hiddenCaret.end;
const value = focused.value.slice(0, start) + text + focused.value.slice(end);
focused.value = focused.maxLength >= 0 ? value.slice(0, focused.maxLength) : value;
+ hiddenCaret.start = hiddenCaret.end = Math.min(start + text.length, focused.value.length);
focused.dispatchEvent(new Event("input", { bubbles: true }));
});
const release = vi.fn(async () => ({}));
@@ -68,9 +79,7 @@ async function setup(markup = '') {
return {};
case "Input.dispatchKeyEvent":
if ((params as { commands?: string[] }).commands?.includes("moveToEndOfDocument")) {
- if (typeof element.selectionStart === "number") {
- element.setSelectionRange(element.value.length, element.value.length);
- }
+ await moveToEnd();
}
return {};
case "Runtime.releaseObject":
@@ -81,6 +90,8 @@ async function setup(markup = '') {
});
return {
element,
+ hiddenCaret,
+ moveToEnd,
script,
insert,
release,
@@ -410,11 +421,21 @@ describe("fill result verification", () => {
expect(h.element.value).toBe("old🙂");
});
- it.each([
- "number",
- "email",
- ])("appends to %s without setting an unsupported selection API", async (type) => {
+ it.each(
+ ["number", "email"].flatMap((type) =>
+ [
+ [0, 0],
+ [1, 1],
+ [0, 2],
+ ].map(([start, end]) => ({ type, start, end })),
+ ),
+ )("appends to $type from selection $start:$end without using its unsupported API", async ({
+ type,
+ start,
+ end,
+ }) => {
const h = await setup(``);
+ Object.assign(h.hiddenCaret, { start, end });
vi.spyOn(h.element, "selectionStart", "get").mockReturnValue(null);
vi.spyOn(h.element, "selectionEnd", "get").mockReturnValue(null);
const selection = vi.spyOn(h.element, "setSelectionRange").mockImplementation(() => {
@@ -425,6 +446,22 @@ describe("fill result verification", () => {
expect(selection).not.toHaveBeenCalled();
});
+ it.each([
+ "number",
+ "email",
+ ])("does not report successful %s append when the caret command is ignored", async (type) => {
+ const h = await setup(``);
+ vi.spyOn(h.element, "selectionStart", "get").mockReturnValue(null);
+ vi.spyOn(h.element, "selectionEnd", "get").mockReturnValue(null);
+ h.moveToEnd.mockResolvedValueOnce(undefined);
+ expect(await h.fill("3", false)).toMatchObject({
+ code: "cdp_failed",
+ data: { reason: "fill_value_mismatch" },
+ });
+ expect(h.element.value).toBe("312");
+ expect(h.insert).toHaveBeenCalledOnce();
+ });
+
it("rejects invalid number text before clearing", async () => {
const h = await setup('');
expect(await h.fill("abc")).toMatchObject({
diff --git a/packages/dsh-plugin-browserskill/src/tools.ts b/packages/dsh-plugin-browserskill/src/tools.ts
index 9bdbabe..db8738f 100644
--- a/packages/dsh-plugin-browserskill/src/tools.ts
+++ b/packages/dsh-plugin-browserskill/src/tools.ts
@@ -636,7 +636,9 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
session: SESSION_PARAM,
noClear: {
type: "boolean",
- description: "Skip the default wipe-the-field-first pass (append instead).",
+ description:
+ "Append to the end of the existing value instead of clearing it. " +
+ "The tool moves the caret automatically; no prior click or selection is needed.",
},
},
output: {