allwright
← All posts
5 min readThe allwright team

Hooks: catching popups, uploads, and downloads without racing your test

allwright v0.1.5–v0.1.7 shipped typed hooks — a register-then-wait lifecycle across all five clients for new tabs, native file pickers, and downloads, so you never race a browser-native event again.

hooksreleasechangelogweb testingfile uploaddownloadpopup
Without a hookclick()new tab firesmissedRegister, then waitpageregisterHook()New tab● caughtFile chooser● caughtDownload● caught

Three kinds of browser behavior never happen on your test's schedule: a link opens a new tab, a button pops the OS-native file picker, an anchor kicks off a download. All three start asynchronously, outside anything your test called directly, and all three are gone — the tab closed, the picker dismissed, the download finished — if you start looking for them a beat too late. v0.1.5 through v0.1.7 shipped the fix: typed hooks, one register-then-wait lifecycle, reused for all three, across every client language.

The shape of the problem

You can't click() your way to a new tab's Page object, a file chooser, or a Download handle — those only exist once the browser-native event actually fires, which is after the action that triggers it. Wait for the event first and you might miss the click. Click first and look afterward and you might miss the event. The only correct order is: start listening, take the action, then wait for what you started listening for.

That's exactly the two-phase shape allwright's hooks give you:

const hook = await page.registerHook(hooks.newPage); // 1. start listening
await page.click("a[target=_blank]");                // 2. take the action
const newPage = await hook.wait();                    // 3. wait for it

Registering captures the page's state before the triggering action runs, so the event can't sneak in and get missed between steps 2 and 3. Under the hood, RegisterHookCommand and WaitForHookCommand are the only engine primitives involved — generic, typed, and identical regardless of which of the three event kinds you're waiting for.

The three hooks, today

New tabs — hooks.newPage

import { expect, test } from "@allwright.dev/vitest";
import { hooks } from "@allwright.dev/core";
 
const WEB_URL = "https://themoderninternet.vercel.app";
const ENTRY_SELECTOR =
  "xpath=//div[contains(@class,'card')][.//h2[normalize-space()='Multiple Windows']]//button[normalize-space()='Visit page']";
const HEADING_SELECTOR = 'xpath=//h1[text()="Multiple Windows"]';
 
test("opens the Multiple Windows page", { timeout: 30_000 }, async ({ page }) => {
  await page.goto(WEB_URL);
  await page.click(ENTRY_SELECTOR);
  await expect(page.locator(HEADING_SELECTOR)).toHaveText("Multiple Windows");
 
  const hook = await page.registerHook(hooks.newPage);
  await page.click('[data-testid="open-link"]');
  const np = await hook.wait();
  await expect(np.getByText("Marketing announcement")).toBeVisible();
});

hook.wait() resolves to a normal Page — same click, getByText, same retrying expect as the page that opened it. Nothing app-specific to learn for a second window.

File uploads — hooks.fileChooser

import { expect, test } from "@allwright.dev/vitest";
import { hooks } from "@allwright.dev/core";
 
const Page = "File Upload";
const WEB_URL = "https://themoderninternet.vercel.app";
const ENTRY_SELECTOR =
  `xpath=//div[contains(@class,'card')][.//h2[normalize-space()='${Page}']]//button[normalize-space()='Visit page']`;
const HEADING_SELECTOR = `xpath=//h1[text()="${Page}"]`;
 
test(`opens the ${Page} page`, { timeout: 30_000 }, async ({ page }) => {
  await page.goto(WEB_URL);
  await page.click(ENTRY_SELECTOR);
  const hook = await page.registerHook(hooks.fileChooser);
  await page.click("#file-upload-input");
  const chooser = await hook.wait();
  await chooser.setFiles("README.md");
  await page.click('[data-testid="upload-button"]');
});

No OS dialog ever actually opens — allwright intercepts the file chooser at the browser level, so setFiles hands it a path (or an array of paths, for a multi-select input) and the native picker never has to render at all.

Downloads — hooks.download

import { expect, test } from "@allwright.dev/vitest";
import { hooks } from "@allwright.dev/core";
 
const Page = "File Download";
const WEB_URL = "https://themoderninternet.vercel.app";
const ENTRY_SELECTOR =
  `xpath=//div[contains(@class,'card')][.//h2[normalize-space()='${Page}']]//button[normalize-space()='Visit page']`;
const HEADING_SELECTOR = `xpath=//h1[text()="${Page}"]`;
 
test(`opens the ${Page} page`, { timeout: 30_000 }, async ({ page }) => {
  await page.goto(WEB_URL);
  await page.click(ENTRY_SELECTOR);
  const hook = await page.registerHook(hooks.download);
  await page.click(`[download="README.md"]`);
  const download = await hook.wait();
  await download.saveAs("README1.md");
});

hook.wait() resolves as soon as the download starts — it hands back the download's url and suggestedFilename immediately, so you can branch on metadata without stalling on a large file. saveAs() is the part that actually waits for the bytes to land, then copies the finished file to the path you gave it.

The same lifecycle, every language

register / wait is the whole API. Only the spelling changes:

LanguageRegisterWait
TypeScriptpage.registerHook(hooks.newPage)hook.wait()
Pythonpage.register_hook(hooks.new_page)hook.wait()
Javapage.registerHook(Hooks.NEW_PAGE)hook.waitFor()
Rustpage.register_hook(allwright::NEW_PAGE).await?hook.wait().await?
Goallwright.RegisterHook(ctx, page, allwright.Hooks.NewPage)hook.Wait(ctx)

Go spells it as a free function rather than a method because Go doesn't support generic methods — RegisterHook[T] has to live at package scope to stay typed. Every other client hangs it directly off Page.

What changed across v0.1.5–v0.1.7

Hooks didn't ship complete on day one — three releases in three days, each correcting course on the last:

  • v0.1.5 — the first hook type: newPage, registered on the browser. It snapshotted every open page at registration time, then resolved to whichever page turned up afterward that wasn't in that snapshot.
  • v0.1.6 — a real bug fix, not just an API tweak. Registering on the browser and picking "the first new page since registration" breaks the moment two tabs can plausibly open around the same time — you could get back a tab that had nothing to do with the click you were coordinating. The fix moved registration to the page that triggers the event and changed resolution to match the new page whose opener is specifically that page — browser.registerHook(...) became page.registerHook(...), and the hook now can't be misattributed to an unrelated tab.
  • v0.1.7fileChooser and download landed on the same corrected, page-scoped lifecycle, across all five clients at once, plus Download and FileChooser result types with saveAs / setFiles.

The full tag-by-tag history — including everything else that shipped alongside these — is on the Changelog.

Why one lifecycle, not three APIs

The engine only knows about two commands, RegisterHookCommand and WaitForHookCommand, plus two events, HookRegisteredEvent and HookCompletedEvent — opaque hook ids, retry timing, and lifecycle cleanup, nothing event-specific. RegisterNewPageHook, RegisterFileChooserHook, RegisterDownloadHook, and their typed results live in the web surface's own proto, not in core. That split is deliberate: it means the next hook — beforeunload/native dialog handling is the obvious next candidate — is a new surface-owned registration and result type reusing the exact same register/wait plumbing, not a fourth bespoke API to design, implement five times, and document.

Where to go next

  • Read the Changelog for the full v0.1.5–v0.1.7 history, and everything shipped since.
  • Read the hooks section of the README for the complete, current API across all five clients.
  • Check Availability for the current, capability-by-capability picture of what's real today.
  • Star or watch the repo — the next hook type is already the obvious next step.

Try it in your own project

allwright is building in public. Star the repo to track progress, or keep reading the rest of the blog.