Skip to content
303 changes: 303 additions & 0 deletions apps/extension/src/tools/__tests__/fill.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ async function withFillBrowser(
ref?: string,
) => ReturnType<typeof handleFill>;
afterCommand: (hook: (method: string, params?: object) => void | Promise<void>) => void;
activate: () => Promise<void>;
calls: string[];
observe: () => ReturnType<typeof handleObserve>;
}) => Promise<void>,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 = `<input id="target" type="${type}" aria-label="Booking field">`;
const markup =
kind === "document"
? input
: kind === "shadow" || kind === "closed-shadow"
? '<div id="container"></div>'
: '<iframe id="container"></iframe>';
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 + '<div id="container"></div>' },
Expand Down Expand Up @@ -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", '<input id="target" value="old">', "value"],
["textarea", '<textarea id="target">old</textarea>', "value"],
["contenteditable", '<div id="target" contenteditable="true">old</div>', "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 + '<input id="target" value="old">' },
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,
Expand Down Expand Up @@ -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 + '<input id="target" type="date">' },
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 + `<input id="target" type="${type}" value="${defaultValue}">`,
},
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 + `<input id="target" type="${type}" step="1" value="${initial}">`,
},
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 + `<input id="target" type="${type}" ${attrs}>`,
},
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 + `<input id="target" type="${type}" ${attrs}>` },
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 + '<div id="host"></div>' },
async (h) => {
await h.evaluate(`(() => {
const host = document.querySelector('#host');
const root = host.attachShadow({ mode: 'open' });
root.innerHTML = '<input id="target" type="${type}">';
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: '<div id="host"></div>' }, async (h) => {
await h.evaluate(`(() => {
const host = document.querySelector('#host');
const root = host.attachShadow({ mode: 'open' });
root.innerHTML = '<input id="target" type="${type}">';
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(
Expand Down
Loading
Loading