fix(desktop): fill controlled form fields before submission

Write text controls through native DOM setters so page-level value tracking sees the input event as a change. Cover state updates, submission, replacement and clearing through the real browser tool journey.
This commit is contained in:
paseo-bot[bot]
2026-11-07 15:48:13 +00:00
parent ef7de3d066
commit 940a2cd1cd
3 changed files with 122 additions and 1 deletions
@@ -0,0 +1,108 @@
import { build } from "esbuild";
import { fileURLToPath } from "node:url";
import { expect } from "@playwright/test";
export async function buildFillFixture() {
const { outputFiles } = await build({
stdin: {
resolveDir: fileURLToPath(new URL("../../app", import.meta.url)),
contents: `
import { createElement as e, useState } from 'react';
import { createRoot } from 'react-dom/client';
function Form() {
const [title, setTitle] = useState('');
const [notes, setNotes] = useState('');
const [submitted, setSubmitted] = useState(null);
return e('form', { onSubmit: ev => {
ev.preventDefault(); setSubmitted({ title, notes });
} },
e('input', { 'aria-label': 'React title', value: title,
onChange: ev => setTitle(ev.target.value) }),
e('textarea', { 'aria-label': 'React notes', value: notes,
onChange: ev => setNotes(ev.target.value) }),
e('p', null, 'React state: ' + JSON.stringify({ title, notes })),
e('button', { type: 'submit' }, 'Submit'),
e('p', null, 'Submitted: ' + JSON.stringify(submitted)));
}
createRoot(document.getElementById('root')).render(e(Form));
document.getElementById('plain').addEventListener('input', ev => {
document.getElementById('plain-state').textContent = 'Plain state: ' + ev.target.value;
});
`,
},
bundle: true,
write: false,
format: "iife",
platform: "browser",
define: { "process.env.NODE_ENV": '"production"' },
});
return `<!doctype html><title>React fill fixture</title><div id="root"></div>
<input aria-label="Plain input" id="plain"><p id="plain-state"></p>
<div role="textbox" aria-label="Editable text" contenteditable="true"></div>
<script>${outputFiles[0].text}</script>`;
}
async function findRef(call, browserId, role, name) {
const { snapshot } = await call("browser_snapshot", { browserId });
const line = snapshot.split("\n").find((candidate) => candidate.includes(`${role} "${name}"`));
const ref = line?.match(/\[ref=([^\]]+)\]/)?.[1];
expect(ref, `Snapshot must expose ${role} ${name}: ${snapshot}`).toBeTruthy();
return ref;
}
async function fillField(call, browserId, name, value) {
const ref = await findRef(call, browserId, "textbox", name);
await call("browser_fill", { browserId, ref, value });
}
async function submitForm(call, browserId) {
const ref = await findRef(call, browserId, "button", "Submit");
await call("browser_click", { browserId, ref });
}
async function expectPageText(call, browserId, text) {
await expect
.poll(async () => {
const result = await call("browser_evaluate", {
browserId,
function: "() => document.body.innerText",
});
return JSON.parse(result.resultJson);
})
.toContain(text);
}
export async function verifyReactFormFill(call, targetUrl) {
const { browserId } = await call("browser_new_tab", {
url: new URL("/react-fill", targetUrl).href,
});
try {
await call("browser_wait", { browserId, text: "React state:", timeoutMs: 15_000 });
await fillField(call, browserId, "Plain input", "plain value");
await expectPageText(call, browserId, "Plain state: plain value");
await fillField(call, browserId, "Editable text", "editable value");
await expectPageText(call, browserId, "editable value");
await fillField(call, browserId, "React title", "Hello from browser_fill");
await fillField(call, browserId, "React notes", "First line\nSecond line");
await expectPageText(
call,
browserId,
'React state: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}',
);
await submitForm(call, browserId);
await expectPageText(
call,
browserId,
'Submitted: {"title":"Hello from browser_fill","notes":"First line\\nSecond line"}',
);
await fillField(call, browserId, "React title", "replacement");
await fillField(call, browserId, "React notes", "");
await submitForm(call, browserId);
await expectPageText(call, browserId, 'Submitted: {"title":"replacement","notes":""}');
await fillField(call, browserId, "React title", "");
await submitForm(call, browserId);
await expectPageText(call, browserId, 'Submitted: {"title":"","notes":""}');
} finally {
await call("browser_close_tab", { browserId });
}
}
@@ -17,6 +17,7 @@ import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
import { buildFillFixture, verifyReactFormFill } from "./browser-fill.electron.mjs";
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
import { seedPluginLinks, runPluginLinksRegression } from "./plugin-links.electron.mjs";
@@ -216,7 +217,13 @@ async function settleDesktopStartup(page) {
const CSP_PAGE_PATH = "/csp-no-eval";
async function startTargetPage() {
const fillFixture = await buildFillFixture();
const server = createServer((request, response) => {
if (request.url === "/react-fill") {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(fillFixture);
return;
}
if (request.url === CSP_PAGE_PATH) {
response.writeHead(200, {
"content-type": "text/html; charset=utf-8",
@@ -782,6 +789,7 @@ async function runRegression({
document.body.appendChild(overlayRoot);
});
await verifyReactFormFill((name, args) => callBrowserTool(client, name, args), targetUrl);
await verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl);
const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl });
@@ -316,7 +316,12 @@ function buildFillScript(metadata: BrowserRefMetadata, value: string): string {
element.focus();
const nextValue = ${JSON.stringify(value)};
if ('value' in element) {
element.value = nextValue;
const view = element.ownerDocument.defaultView;
const prototype = element.tagName === 'INPUT' ? view.HTMLInputElement.prototype
: element.tagName === 'TEXTAREA' ? view.HTMLTextAreaElement.prototype : null;
// Bypass instance setters so framework value trackers observe the input event as a change.
if (prototype) Object.getOwnPropertyDescriptor(prototype, 'value').set.call(element, nextValue);
else element.value = nextValue;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return { ok: true };