Submit forms with Puppeteer and verify the actual result
Fill real controls, distinguish native navigation from AJAX submission, start waits before actions, and avoid duplicate side effects after uncertain outcomes.
Fill the real form controls, start the appropriate wait before Submit, then verify the response and visible result. A completed click is not proof that the application accepted the submission.
Submit forms step by step
Run npm install puppeteer in your project. The steps below explain the browser workflow using a native form and an AJAX form. These snippets use the page, browser and local origin supplied by the complete script below. That script includes both forms and their test server; no real account or payment is involved.
1. Open the form and fill the required field
Open the local page, then fill the actual email input. A locator waits for the control to be ready for interaction; it does not bypass the form’s validation.
await page.goto(origin, {
waitUntil: "domcontentloaded",
});
await page.locator("#native input").fill(
"[email protected]",
);
2. Wait before submitting the native form
A native submission loads another document. Register the navigation wait before clicking Save, check the response, and read the result from the destination page.
const [navigation] = await Promise.all([
page.waitForNavigation({
waitUntil: "domcontentloaded",
timeout: 5000,
}),
page.locator("#native button").click(),
]);
if (!navigation || !navigation.ok()) {
throw new Error("Native submission failed");
}
const result = await page.$eval(
"#success",
(element) => element.textContent,
);
console.log(result); // Saved
3. Submit the AJAX form and match its response
Return to the demo page and fill the second form. This submission stays on the page, so wait for its exact POST response instead of navigation. Start that wait before the click too.
await page.goto(origin, {
waitUntil: "domcontentloaded",
});
await page.locator("#ajax input").fill(
"[email protected]",
);
const [saved] = await Promise.all([
page.waitForResponse(
(response) =>
response.url() === `${origin}/api/save` &&
response.request().method() === "POST",
{ timeout: 5000 },
),
page.locator("#ajax button").click(),
]);
4. Check the HTTP response and visible result
An HTTP success is not necessarily an application success. First reject a failed response, then wait for either Saved or Failed and inspect which result the page displays.
if (!saved.ok()) {
throw new Error(`Save HTTP ${saved.status()}`);
}
await page.waitForFunction(
() => {
const status =
document.querySelector("#status");
return ["Saved", "Failed"].includes(
status?.textContent,
);
},
{ timeout: 5000 },
);
const status = await page.$eval(
"#status",
(element) => element.textContent,
);
if (status !== "Saved") {
throw new Error("AJAX success state absent");
}
console.log(status); // Saved
5. Close the browser and local server
Use cleanup in finally, as the complete script does. The nested finally below still shuts down the local server if closing the browser throws.
try {
if (browser) {
await browser.close();
}
} finally {
server.closeAllConnections();
await new Promise((resolve) => {
server.close(resolve);
});
}
Complete script
Here are all the pieces together, including setup and cleanup. Save this as forms.mjs and run node forms.mjs.
import { createServer } from "node:http";
import puppeteer from "puppeteer";
const server = createServer((request, response) => {
request.resume();
if (
request.method === "POST" &&
request.url === "/submitted"
) {
response.setHeader("Content-Type", "text/html");
response.end('<p id="success">Saved</p>');
return;
}
if (
request.method === "POST" &&
request.url === "/api/save"
) {
response.setHeader(
"Content-Type",
"application/json",
);
setTimeout(() => {
response.end('{"saved":true}');
}, 50);
return;
}
if (request.url !== "/") {
response.writeHead(404);
response.end();
return;
}
response.setHeader("Content-Type", "text/html");
response.end(`
<form id="native" action="/submitted" method="post">
<input name="email" type="email" required>
<button>Save</button>
</form>
<form id="ajax">
<input name="email" type="email" required>
<button>Save</button>
</form>
<p id="status"></p>
<script>
const form = document.querySelector("#ajax");
const status =
document.querySelector("#status");
async function submit(event) {
event.preventDefault();
try {
const response = await fetch(
"/api/save",
{ method: "POST" },
);
if (!response.ok) {
throw new Error("Save failed");
}
const result = await response.json();
status.textContent = result.saved
? "Saved"
: "Failed";
} catch {
status.textContent = "Failed";
}
}
form.addEventListener("submit", submit);
</script>
`);
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const origin = `http://127.0.0.1:${server.address().port}`;
let browser;
try {
browser = await puppeteer.launch({
headless: true,
});
const page = await browser.newPage();
await page.goto(origin, {
waitUntil: "domcontentloaded",
});
await page.locator("#native input").fill(
"[email protected]",
);
const [navigation] = await Promise.all([
page.waitForNavigation({
waitUntil: "domcontentloaded",
timeout: 5000,
}),
page.locator("#native button").click(),
]);
if (!navigation || !navigation.ok()) {
throw new Error("Native submission failed");
}
const result = await page.$eval(
"#success",
(element) => element.textContent,
);
console.log(result); // Saved
await page.goto(origin, {
waitUntil: "domcontentloaded",
});
await page.locator("#ajax input").fill(
"[email protected]",
);
const [saved] = await Promise.all([
page.waitForResponse(
(response) =>
response.url() === `${origin}/api/save` &&
response.request().method() === "POST",
{ timeout: 5000 },
),
page.locator("#ajax button").click(),
]);
if (!saved.ok()) {
throw new Error(`Save HTTP ${saved.status()}`);
}
await page.waitForFunction(
() => {
const status =
document.querySelector("#status");
return ["Saved", "Failed"].includes(
status?.textContent,
);
},
{ timeout: 5000 },
);
const status = await page.$eval(
"#status",
(element) => element.textContent,
);
if (status !== "Saved") {
throw new Error("AJAX success state absent");
}
console.log(status); // Saved
} finally {
try {
if (browser) {
await browser.close();
}
} finally {
server.closeAllConnections();
await new Promise((resolve) => {
server.close(resolve);
});
}
}
This small server acknowledges synthetic submissions; it is not production validation/authentication code. Replace its controls, endpoint and success condition with the actual application’s authorized contract. Validate initial navigation responses too when automating a real URL; see navigation handling.
Fill actual controls
Locators wait for an element to be ready for interaction, but cannot turn a wrapper div into an input. For HTML <select>, use page.select() with real string option values. Custom dropdowns need their own interaction.
Native validation can prevent any request/navigation. Inside a live page, inspect the actual control:
const valid = await page.$eval(
"#ajax input",
(input) => input.checkValidity(),
);
if (!valid) {
throw new Error("Required form value is invalid");
}
Fill required values and check visible errors/disabled controls instead of assuming a missing response means slow networking.
Match the form’s result model
For navigation, register waitForNavigation before the click. For AJAX, match the exact request URL/method with waitForResponse, then inspect the application result. “Any response” can match an image or analytics request.
A 200 response alone can still carry an application error. Verify task-specific data and the final destination/state rather than treating every status-success response as business success.
Do not blindly repeat side effects
A timeout cannot distinguish “server did not process” from “server processed but the response was lost.” Check the application outcome or its documented idempotency mechanism before repeating a payment, email, account creation or deletion. Fresh references do not provide exactly-once execution.
For wrong-frame or hidden controls, see node geometry and detached frames. Keep one page owner per workflow and close owned resources. For file controls, see uploads/downloads.