diff --git a/packages/desktop/e2e/browser-fill.electron.mjs b/packages/desktop/e2e/browser-fill.electron.mjs new file mode 100644 index 0000000000..78ce935012 --- /dev/null +++ b/packages/desktop/e2e/browser-fill.electron.mjs @@ -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 `React fill fixture
+

+
+ `; +} + +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 }); + } +} diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 93e6bd61d1..668d884b80 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -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 }); diff --git a/packages/desktop/src/features/browser-automation/snapshot-engine.ts b/packages/desktop/src/features/browser-automation/snapshot-engine.ts index 2ee1a68a5e..71a688c7b2 100644 --- a/packages/desktop/src/features/browser-automation/snapshot-engine.ts +++ b/packages/desktop/src/features/browser-automation/snapshot-engine.ts @@ -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 };