mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user