diff --git a/spec/popup.spec.ts b/spec/popup.spec.ts index 11c1202..e7ab131 100644 --- a/spec/popup.spec.ts +++ b/spec/popup.spec.ts @@ -87,6 +87,41 @@ test("wrapping an already-wrapped page throws", async ({ page: basePage, context ); }); +test("a failing popup plugin finalizer does not stop the parent finalizing", async ({ + page: basePage, + context, +}, testInfo) => { + await routeAuthDemoApp(context); + const events: string[] = []; + const plugin: Plugin = { + name: "flaky-on-popups", + forPopup: () => ({ + name: "flaky-on-popups-child", + testLifecycle: (emitter) => { + emitter.on("afterTestFinalize", () => { + throw new Error("popup teardown exploded"); + }); + }, + }), + testLifecycle: (emitter) => { + emitter.on("afterTestFinalize", () => { + events.push("parent finalized"); + }); + }, + }; + const page = await addPlugins({ page: basePage, testInfo, plugins: [plugin] }); + await page.goto("https://app.middlewright.test/"); + const popupPromise = basePage.waitForEvent("popup"); + await page.getByRole("button", { name: "Sign in" }).click(); + await (await popupPromise).getByRole("button", { name: "Approve" }).click(); + await page.getByText("Signed in as mmkal").waitFor(); + + // The child's failure surfaces, but only after the parent finalized — a + // popup teardown hiccup must not drop the main page's artifacts. + await expect(page[Symbol.asyncDispose]()).rejects.toThrow("popup teardown exploded"); + expect(events).toEqual(["parent finalized"]); +}); + test("popups: false leaves popups unwrapped", async ({ page: basePage, context }, testInfo) => { await routeAuthDemoApp(context); const actions: string[] = []; diff --git a/src/plugin-system.ts b/src/plugin-system.ts index fb05a2a..7788774 100644 --- a/src/plugin-system.ts +++ b/src/plugin-system.ts @@ -287,21 +287,28 @@ export const addPlugins = async (p page.off("popup", onPopup); } // Children dispose first (newest first) so their plugins can finalize -- - // and, later, feed facts to parent plugins -- before the parent's own - // lifecycle events run. A failed child wrap must not stop the parent - // finalizing; it rethrows below once cleanup is done. + // and feed facts to parent plugins -- before the parent's own lifecycle + // events run. A failed child wrap OR a throwing child dispose must not + // stop the parent finalizing (that would drop the main page's artifacts); + // failures rethrow below once the parent's own teardown has run. + const childFailures: unknown[] = []; const settledChildren = await Promise.allSettled(childWraps); for (const result of [...settledChildren].reverse()) { - if (result.status === "fulfilled") { + if (result.status === "rejected") { + childFailures.push(result.reason); + continue; + } + try { await result.value[Symbol.asyncDispose](); + } catch (error) { + childFailures.push(error); } } await state.lifecycleEmitter.emitSerial("afterTest", { page, testInfo }); await state.lifecycleEmitter.emitSerial("afterTestFinalize", { page, testInfo }); state.lifecycleCleanups.forEach((cleanup) => cleanup()); - const failedChildWrap = settledChildren.find((result) => result.status === "rejected"); - if (failedChildWrap) { - throw failedChildWrap.reason; + if (childFailures.length > 0) { + throw childFailures[0]; } };