From 843a5d0dec649d8b8111abe831d0a7389433fd77 Mon Sep 17 00:00:00 2001 From: Wondertan Date: Tue, 15 Sep 2026 15:09:33 +0200 Subject: [PATCH] fix(popup): detect departure without interrupting pending recovery Report participating document departure and detect unavailable consent windows when no carrier or recovery remains. Preserve pending handshake acknowledgements through COOP fallback, ignore retired-carrier messages, and retain protocol version 1 for the unpublished package. Signed-off-by: Wondertan Assisted-by: GPT-6 --- ts/packages/popup/README.md | 60 ++++- ts/packages/popup/TEST_PLAN.md | 21 +- ts/packages/popup/docs/connection.md | 41 +++- ts/packages/popup/docs/control.md | 55 ++++- ts/packages/popup/docs/message-port.md | 7 +- ts/packages/popup/e2e/popup.spec.ts | 75 +++++- ts/packages/popup/e2e/server.mjs | 12 +- ts/packages/popup/src/connection.test.ts | 283 ++++++++++++++++++++++- ts/packages/popup/src/connection.ts | 65 +++++- ts/packages/popup/src/message.test.ts | 5 +- ts/packages/popup/src/message.ts | 21 +- ts/packages/popup/src/port.test.ts | 2 +- ts/packages/popup/src/port.ts | 5 + ts/packages/popup/src/testing/fakes.ts | 19 +- 14 files changed, 612 insertions(+), 59 deletions(-) diff --git a/ts/packages/popup/README.md b/ts/packages/popup/README.md index 9491545d..803055f0 100644 --- a/ts/packages/popup/README.md +++ b/ts/packages/popup/README.md @@ -165,6 +165,62 @@ control, must initiate its own departure. `close` uses an available retained handle and otherwise uses popup control, then releases both the connection and popup. +### Observing closure + +`connection.closed` also settles with `{ outcome: 'closed' }` when the connected +popup reports an unexpected document departure, including ordinary user closure, +Back or reload. The notification uses the authenticated carrier and works after +COOP severs the opener. Expected package navigation and isolation fallback do +not end the application's connection. + +This is best-effort `pagehide` reporting, not a guaranteed window-close event. +Mobile termination may skip it, and provider pages run no package code. During +periods without a carrier, the application polls its retained handle every 250 ms. +If that handle becomes unavailable and no fallback is pending, the connection +fails with `popup-unavailable` immediately on detection, without a grace period. +The error covers both window closure and provider COOP severance; it cannot +identify which happened. `PopupError.code` identifies the failure; the host owns +user-facing wording. + +A selected carrier survives handle severance. A pending fallback keeps recovery +open without a connection timeout until it resolves, rejects, or the caller +closes. Native-anchor launches gain the same detection after authentication binds +the handle; closure before that binding remains unobservable. Browser suspension +may delay polling. No heartbeat currently treats silence as closure. Neither +`closed` nor `failed` implies OAuth denial. + +### Connection failures inside the popup + +`closed` describes this endpoint's lifecycle, not the physical window or a user +intent. On Application, `{ outcome: 'failed', code: 'popup-unavailable' }` means +its retained handle became unavailable without a carrier or pending fallback; +it cannot distinguish user closure from COOP severance. A popup-originated +`DocumentDeparted` instead produces `{ outcome: 'closed' }` and can also mean +Back or reload. Planned Prover isolation preserves the carrier. + +An Application failure does not stop an external provider page or notify a +severed popup. When the user returns to a participating popup document, that +document must handle its own `ready` rejection and show the failure locally: + +```ts +try { + await connection.ready +} catch (error) { + status.textContent = error instanceof PopupError + ? `Could not connect (${error.code}).` + : 'Could not connect.' +} +``` + +Without an opener or fallback, `ready` rejects with `fallback-unavailable`. +An opener that exists but does not answer gets the existing 30-second handshake +wait before fallback selection. An available fallback is awaited; a rejection +reports `fallback-failed`. Authentication failures report `handshake-rejected`. +These are connection/setup failures, not OAuth denial or proof of user closure. +The package provides programmatic errors; the host translates their codes into +UI text. Preserve `end.code` if observing `closed` too, so a generic closure handler cannot replace +the actual readiness error. No message from Application is needed to display it. + ### Transitions that carry a reply Delivery on one carrier is ordered, and a `navigate` control is delivered in @@ -360,5 +416,5 @@ remain deferred or manual. After `ready`, `connection.peerOrigin` identifies the authenticated peer; it is `null` without a selected carrier. [Origin binding](docs/connection.md#authenticated-peer-origin) -describes preservation and fallback. ConnectionVersion 2 requires updated -Application, popup documents, and worker together. +describes preservation and fallback. Application, popup documents, and worker +use the same connection protocol version. diff --git a/ts/packages/popup/TEST_PLAN.md b/ts/packages/popup/TEST_PLAN.md index e50388bb..335f4edb 100644 --- a/ts/packages/popup/TEST_PLAN.md +++ b/ts/packages/popup/TEST_PLAN.md @@ -28,7 +28,7 @@ Coverage status: | ID | Assertion | |---|---| -| POPUP-API-001 | A caller-defined message union is accepted by `PopupConnection` without entering package-owned source, and `send` rejects the reserved `navigate` and `close-popup` discriminators. | +| POPUP-API-001 | A caller-defined message union is accepted by `PopupConnection` without entering package-owned source, and `send` rejects the reserved `navigate`, `close-popup` and `document-departed` discriminators. | | POPUP-API-002 | `on` registers one class per discriminator, rejects duplicates, calls its static `decode` exactly once, returns the same decoded object, and unsubscribes only that handler. | | POPUP-API-003 | Unknown, unregistered, malformed, or decoder-rejected input closes the connection and reaches no caller handler. An exception thrown by a caller handler propagates untouched, closes nothing, and reaches no diagnostic. | | POPUP-API-004 | Concurrent connections keep message registrations, native resources, controls, and delivery isolated by connection ID; a valid handshake for another connection ID, or any handshake from a window or origin other than the expected peer, is ignored rather than rejected. | @@ -46,7 +46,7 @@ Coverage status: | POPUP-CONTROL-001 | While native-anchor binding is pending, `navigate` performs no browser operation and leaves the same activation's default navigation intact. Without an active carrier, `navigate` and `close` use the retained handle only while it is non-null and not closed and emit no control message. | | POPUP-CONTROL-002 | With an active carrier, application-endpoint `navigate` sends one exact canonical `Navigate` even while its retained handle appears usable; popup-endpoint `navigate` sends no control. HTTPS and HTTP on exact `localhost` or `127.0.0.1` at any valid port are accepted. Either popup-side path invokes replacement only after carrier continuity is preserved or prepared; malformed, credentialed, noncanonical, relative, disallowed-HTTP, and unpreparable navigations fail before browser navigation. Real-engine tests confirm a COOP-severed retained handle reports `closed`. | | POPUP-CONTROL-003 | `close` uses a non-null, non-closed retained handle directly regardless of carrier state and otherwise sends one `ClosePopup` over an active carrier. It closes local resources, is idempotent, and is accepted only for a package-created script-closable popup. | -| POPUP-CONTROL-004 | Controls carry no version field because they travel only over a version-authenticated carrier. They are application-to-popup, one-shot per receiving document, unacknowledged, connection-bound, and private; wrong-direction, duplicate, replayed, unknown, and post-terminal controls perform no browser operation. `Navigate` may continue the logical connection in its destination, while `ClosePopup` terminates it. | +| POPUP-CONTROL-004 | Controls carry no version field because they travel only over a version-authenticated carrier. Navigation and close commands are application-to-popup, one-shot per receiving document, unacknowledged, connection-bound, and private; wrong-direction, duplicate, replayed, unknown, and post-terminal controls perform no browser operation. `Navigate` may continue the logical connection in its destination, while `ClosePopup` terminates it. | | POPUP-CONTROL-005 | `navigateAway` never sends `Navigate`. The application endpoint navigates a non-null, non-closed retained handle directly, retires its carrier without `keep`, and accepts the next participating document's handshake; it rejects once the handle is unusable and performs no browser operation while native-anchor binding is pending. The popup endpoint releases its carrier and replaces itself without `keep`. Malformed destinations fail before any browser operation. | ## Carrier authentication and selection @@ -58,7 +58,7 @@ Coverage status: | POPUP-CONNECTION-003 | One logical connection survives repeated participating-document replacements: `navigate` preserves a usable carrier when possible and otherwise replaces it transparently; callers never manage carrier identity, count, lifetime, or reconnection. If no carrier can continue or be established, the connection fails closed. | | POPUP-CONNECTION-004 | `connect` invokes a supplied fallback constructor exactly once with the connection-lifetime signal and observes its promise without awaiting it; `accept` invokes its constructor once only after MessagePort becomes unavailable. The constructor returns only an authenticated `Carrier`; connection creates no substitute `MessageChannel` and queues no caller value outside that carrier. MessagePort selection retains the unused application fallback. No-carrier navigation arms only a fresh document-local MessagePort operation; popup-side navigation from RTC uses its package-private lifecycle hooks to prepare the target and retain a pending replacement before navigating, then installs only its authenticated result. Connection closure aborts every pending operation. | | POPUP-CONNECTION-005 | Without a fallback constructor, successful MessagePort use emits no fallback diagnostic; fallback selection records exactly one sanitized `fallback-unavailable` failure and closes. | -| POPUP-CONNECTION-006 | Carrier loss, endpoint loss, popup closure, background suspension, and continuity loss are never delivery, cancellation, success, or recovery. Resumed delivery preserves order. While the popup is non-participating after a connected navigation, every `send` and `navigate` succeeds locally and delivers nothing; after `navigateAway` the carrier is retired and `send` throws. `closed` settles exactly once with the terminal outcome and `ready` rejects with the same code when selection fails. | +| POPUP-CONNECTION-006 | Carrier loss, endpoint loss, popup closure, background suspension, and continuity loss are never delivery, cancellation, success, or recovery. Resumed delivery preserves order. While the popup is non-participating after a connected navigation, every `send` and `navigate` succeeds locally and delivers nothing; after `navigateAway` the carrier is retired and `send` throws. `closed` settles exactly once with the terminal outcome and `ready` rejects with the same code when selection fails. Without a carrier or pending fallback, an unavailable bound handle fails on detection; a selected carrier or pending fallback prevents that failure. | | POPUP-CONNECTION-007 | Both constructors accept an exact lowercase RFC 4122 UUIDv4 `connectionId` and reject uppercase, noncanonical, malformed, wrong-version, and wrong-variant strings before carrier, keeper, or signaling work. Caller integration generates a different `crypto.randomUUID()` value for every logical connection and never reuses a retired value. | | POPUP-CONNECTION-008 | `PopupConnection.navigate()` directly between same-origin participating documents preserves a MessagePort or prepares a nontransferable carrier. Across origins, including across sites, it never gives a MessagePort to the source origin's worker: it retires that popup endpoint and the allowed destination establishes a fresh carrier through its opener or fallback. A navigation outside that API loses the current carrier. A later participating document may establish the first RTC carrier from the still-unused initial fallback without round metadata; after RTC is active, an unmanaged navigation terminates the logical connection without restarting round zero. | | POPUP-CONNECTION-009 | `connect` and `accept` copy nonempty, duplicate-free sets of canonical origins: HTTPS, or HTTP on exactly `localhost` or `127.0.0.1` at any valid port. Scheme and effective port match exactly; lookalikes, alternate loopback spellings, and other HTTP hosts fail. Every initial, native-anchor, replacement, MessagePort, and RTC participant binds one exact authenticated member of the peer's set. Sequential popup participants may use different admitted origins without changing the logical connection ID or caller registrations. `accept` alone also takes `'*'`, subject to the same URL and authentication rules; `connect` rejects a wildcard. An empty set or duplicate, malformed, noncanonical, credentialed, or unapproved origin fails before selection or caller delivery. | @@ -118,3 +118,18 @@ POPUP-KEEPER-001: both endpoint origins before/after readiness and retirement; origin preservation with queued delivery; destination allowlist rejection on restoration; and missing, malformed, or unlisted fallback origins rejected before subscription. Real browser coverage exercises restored origins through isolation. + +### Departure coverage + +`POPUP-CONNECTION-006` / `POPUP-CONTROL-004` additionally cover the exact, +popup-to-application `DocumentDeparted` notification: ordinary closure before +and after COOP, idempotent termination, inert failed reporting, wrong-direction +rejection, and suppression during planned navigation. Unit tests preserve silent +carrier loss as unavailable, not closure. Browser checks cover `pagehide` delivery, +provider-page closure after scripted and native-anchor launches, provider COOP +without a fallback, and preserved-carrier continuity despite a severed handle. +Unit checks cover no carrier/no fallback failure on detection, pending fallback +beyond five seconds, later fallback resolution/rejection, explicit closure during +recovery, pre-binding absence, pre-readiness failure, and polling cleanup. +Forced mobile termination and closure before native-anchor binding remain +best-effort limitations; emulation does not qualify physical mobile lifecycle. diff --git a/ts/packages/popup/docs/connection.md b/ts/packages/popup/docs/connection.md index ff6cd196..a1ed63c0 100644 --- a/ts/packages/popup/docs/connection.md +++ b/ts/packages/popup/docs/connection.md @@ -20,7 +20,7 @@ Continuity is best-effort: preserving the logical connection does not promise delivery across carrier retirement or replay messages lost during navigation. ```ts -type ConnectionVersion = 2 +type ConnectionVersion = 1 ``` `ConnectionVersion` exact-matches the connection's private authentication, @@ -315,10 +315,9 @@ opener relationship long enough to authenticate and transfer the carrier port. The anchor is a compatibility hedge for an environment or embedding policy which rejects scripted popup creation, not a second user flow. It must exist before activation so the fallback proceeds in the same tap. Both paths use the -same target and create one script-closable top-level traversable. The scripted -path exposes `popup.closed` only to decide whether a no-carrier direct operation -can be attempted; before fallback binding no handle exists to observe. Closure -is never delivery, cancellation, or another caller-protocol outcome. +same target and create one script-closable top-level traversable, using the +retained handle for direct operations and unavailable-window detection. Before native-anchor binding no handle exists to +observe. Closure is never delivery, cancellation, or another caller-protocol outcome. The application lifecycle object and both connection endpoints expose: @@ -476,9 +475,16 @@ Both constructors return synchronously and select carriers afterwards, so handlers registered before the caller yields precede every delivery; `ready` settles once a carrier is selected and rejects with the failure code if the endpoint failed first. `closed` settles exactly once with the connection's -terminal outcome, the only channel through which a failure without an -invoking operation reaches the caller. For each inbound carrier -value, the connection reads only a bounded string `type` from a plain record, +terminal outcome, including an authenticated best-effort document-departure +notification. Without a selected carrier or a pending fallback, an unavailable +retained handle instead fails with `popup-unavailable`: closure and provider COOP +severance are indistinguishable but neither can recover on this path. Detection +polls every 250 ms with no additional timeout; terminal cleanup stops polling. +A pending fallback has no connection-layer deadline; its rejection removes that +recovery option, while resolution installs the authenticated carrier. Existing +carriers remain usable despite handle severance. Unobserved loss and silence +alone still remain pending. This is the only channel through which a failure +without an invoking operation reaches the caller. For each inbound carrier value, the connection reads only a bounded string `type` from a plain record, selects the registered `MessageType`, calls `decode` exactly once, and invokes that handler. An unknown or unregistered type, malformed routing discriminator, or thrown decode closes the connection and delivers no message. An exception @@ -487,6 +493,18 @@ changes no connection state. The registered set therefore enforces participant direction without hardcoding protocol types in the connection; the handler still enforces state and order. +The endpoint's terminal outcome does not establish the physical window's state. +An Application endpoint can fail while an external provider continues running; +the returned popup then independently attempts connection. Its host MUST handle +`ready` rejection locally and retain the failure code if it also observes `closed`. +In particular, a simultaneous failed `closed` outcome must not mask readiness +failure with a generic "connection closed" message. `PopupError.code` identifies +the failure; the host owns its display text. +`PopupError.message` contains the code without user-facing explanation. An absent +opener with no configured fallback fails locally with `fallback-unavailable`, +without needing Application to send a message. A silent opener retains the +existing bounded handshake wait before fallback selection. + `navigate` is available on both connection endpoints. The application endpoint always sends the private control defined by [popup control](control.md) when a carrier is active. Without one, it uses its exact retained `WindowProxy` only @@ -743,9 +761,10 @@ continuity. ## Versioning The package supplies one `ConnectionVersion` to both endpoints; there is no -runtime negotiation. Compatible implementation changes keep the version. -Breaking private authentication, carrier, signaling, framing, or continuity -controls increment it independently of every caller protocol. +runtime negotiation. The unpublished implementation remains at version 1. +After publication, breaking private authentication, carrier, signaling, framing, +or continuity changes increment it independently of every caller protocol; +compatible changes keep the version. ## Authenticated peer origin diff --git a/ts/packages/popup/docs/control.md b/ts/packages/popup/docs/control.md index 99bbd058..7a568cef 100644 --- a/ts/packages/popup/docs/control.md +++ b/ts/packages/popup/docs/control.md @@ -1,7 +1,8 @@ # Popup control This document defines popup navigation and the control protocol by which an -application asks its connected popup to navigate or close itself. +application asks its connected popup to navigate or close itself, and the popup +reports an unexpected document departure. It is independent of caller protocols: navigation and popup lifetime are composition decisions, not protocol results. @@ -30,7 +31,11 @@ interface ClosePopup { type: 'close-popup' } -type PopupControl = Navigate | ClosePopup +interface DocumentDeparted { + type: 'document-departed' +} + +type PopupControl = Navigate | ClosePopup | DocumentDeparted ``` These discriminators are connection-reserved. They cannot appear in a @@ -40,7 +45,8 @@ Each discriminator is its own compatibility boundary. An incompatible shape or semantic change introduces a new message and decoder rather than a shared protocol-version field. Unknown controls fail closed. -Both records are application-to-popup only. The receiver exact-validates a +Navigation and close commands are Application → Popup; departure is Popup → +Application. The receiver exact-validates a plain record, discriminator, and field set before acting. They travel only over an already version-authenticated carrier. A navigation URL must equal the serialization of an absolute URL with no credentials, @@ -94,13 +100,13 @@ group switch. A same-tab or full-page presentation MUST NOT send `ClosePopup`. This is a window-creation invariant; there is no reliable post-COOP runtime probe for script closability. -The first accepted popup control is terminal and one-shot for its receiving +The first accepted navigation or close command is terminal and one-shot for its receiving document. `Navigate` may continue the logical connection in the destination; `ClosePopup` terminates it. A duplicate, replay, race loser, unknown control, wrong-direction record, or record on another connection performs no browser operation. -Neither control message has a remote acknowledgement. A pending native-anchor +None of these control messages has a remote acknowledgement. A pending native-anchor call resolves after the connection accepts that the same activation owns the navigation; it does not claim that navigation was observed. Navigation can destroy the receiver and COOP prevents the application from reliably observing either @@ -110,9 +116,46 @@ delivery. The composition must commit the authoritative successor state before acting and treat connection loss or an unavailable popup as neither success, cancellation, nor proof of delivery. +## Document departure + +`DocumentDeparted` is sent only Popup → Application over the selected +authenticated carrier. It carries no caller data. +The application settles `closed` once and releases the connection, without +attempting to close the browser window or interpreting the departure as denial. +A notification from a retired carrier cannot terminate a successor connection. + +A popup sends it on explicit local close or unexpected `pagehide`. A non-cancelling +`beforeunload` listener keeps WebKit's window-close lifecycle running; it sends +nothing and never prompts. Both listeners are removed before planned navigation. +While attached, `beforeunload` may prevent Firefox's back/forward caching; it does +not change the protocol's document replacement or best-effort mobile guarantees. + +Package-owned navigation and isolation fallback suppress it, including when continuity work +is still pending. A failed send is inert: no acknowledgement, retry or secondary +failure is possible. `pagehide` can mean close, reload or Back and may be skipped +by mobile termination. Non-participating provider pages send nothing. Neither a +COOP-severed handle nor a silent carrier establishes document departure. + +## Unavailable window + +Provider documents cannot send `DocumentDeparted`. The application polls its +retained handle every 250 ms. Without a carrier, pending authenticated handshake, +or pending fallback, an unavailable handle terminates the connection with +`popup-unavailable` on the +first observation. This means closure or opener severance (including COOP), not +a confirmed physical close or denial. The application never closes the window +as a side effect of this failure. + +A selected carrier suppresses this check, including through package-owned +isolation. A pending fallback keeps the connection recoverable indefinitely; +resolution installs its authenticated carrier, rejection removes the remaining +fallback, and explicit close aborts recovery. Native-anchor launch has no handle +to poll until its first authenticated binding. Terminal cleanup stops polling. + ## Security boundary -- Only the application endpoint of the selected connection can send a control; +- Only the application endpoint of the selected connection can send navigation + or close commands; only the popup can report document departure. In either case, cookies, URLs, storage, opener state, and caller payload fields cannot authorize one. - The connection binding prevents one concurrent operation from diff --git a/ts/packages/popup/docs/message-port.md b/ts/packages/popup/docs/message-port.md index f31d6bb8..cd3619eb 100644 --- a/ts/packages/popup/docs/message-port.md +++ b/ts/packages/popup/docs/message-port.md @@ -340,7 +340,6 @@ allowlist before installing the restored carrier. A malformed or mismatching binding fails locally and does not select a fallback. Same-origin worker code is already inside the transport's trust boundary. -ConnectionVersion 2 adds the origin to private keeper records. Deploy the updated -Application, popup documents, and worker together; an older keeper cannot preserve this binding and is not treated -as authenticated-origin evidence. There is no compatibility path that guesses -an origin or silently restores a port without it. +Application, popup documents, and worker use the same connection protocol +version. A keeper must preserve this binding; there is no path that guesses an +origin or silently restores a port without it. diff --git a/ts/packages/popup/e2e/popup.spec.ts b/ts/packages/popup/e2e/popup.spec.ts index 279be73c..803deaa2 100644 --- a/ts/packages/popup/e2e/popup.spec.ts +++ b/ts/packages/popup/e2e/popup.spec.ts @@ -387,8 +387,13 @@ test('[POPUP-CONNECTION-002] [POPUP-CONNECTION-005] direct navigation into isola const { popup } = await open(page, { id, href: `${POPUP}/isolated#c=${id}` }) await expect(popup.locator('#status')).toHaveText('failed: fallback-unavailable') expect(await diag(popup)).toEqual(['fallback-unavailable', 'connection-failed']) - await page.waitForTimeout(300) - expect(await diag(page)).toEqual(['window-opened', 'control-direct']) + await expect + .poll(() => events(page)) + .toContainEqual({ + type: 'end', + outcome: 'failed', + code: 'popup-unavailable', + }) }) test('[POPUP-CONTROL-002] malformed navigation fails before any browser operation', async ({ @@ -561,3 +566,69 @@ test('[POPUP-CONNECTION-012] a fallback that stays non-isolated fails closed wit 'connection-failed', ]) }) + +for (const isolated of [false, true]) + test(`[POPUP-CONNECTION-006] user closure notifies the app${isolated ? ' after opener severance' : ''}`, async ({ + page, + }) => { + const { id, popup } = await open(page) + await expectPong(page, 0) + if (isolated) { + await nextDocument(popup, () => navigate(page, `${POPUP}/isolated#c=${id}`)) + await expect(popup.locator('#status')).toHaveText('connected') + expect( + await page.evaluate(() => (window as unknown as { __handle: Window }).__handle.closed), + ).toBe(true) + } + expect((await events(page)).some((event) => (event as { type: string }).type === 'end')).toBe( + false, + ) + await popup.close({ runBeforeUnload: true }) + await expect.poll(() => events(page)).toContainEqual({ type: 'end', outcome: 'closed' }) + }) + +test('[POPUP-CONNECTION-006] unplanned departure ends the connection without claiming window closure', async ({ + page, +}) => { + const { popup } = await open(page) + await expectPong(page, 0) + await popup.goto(`${POPUP}/external`) + await expect.poll(() => events(page)).toContainEqual({ type: 'end', outcome: 'closed' }) + expect(popup.isClosed()).toBe(false) +}) + +for (const blocked of [false, true]) + test(`[POPUP-CONNECTION-006] provider-page closure is detected${blocked ? ' after native-anchor binding' : ''}`, async ({ + page, + }) => { + const { popup } = await open(page, { blocked }) + await expectPong(page, 0) + await nextDocument(popup, () => navigateAway(page, `${POPUP_B}/external`)) + await expect(popup.locator('#status')).toHaveText('external') + // The non-participating page cannot send a departure notification. + await popup.close() + await expect + .poll(() => events(page)) + .toContainEqual({ + type: 'end', + outcome: 'failed', + code: 'popup-unavailable', + }) + }) + +test('[POPUP-CONNECTION-006] provider COOP without recovery fails even while the window stays open', async ({ + page, +}) => { + const { popup } = await open(page) + await expectPong(page, 0) + await nextDocument(popup, () => navigateAway(page, `${POPUP_B}/external-isolated`)) + await expect(popup.locator('#status')).toHaveText('external') + await expect + .poll(() => events(page)) + .toContainEqual({ + type: 'end', + outcome: 'failed', + code: 'popup-unavailable', + }) + expect(popup.isClosed()).toBe(false) +}) diff --git a/ts/packages/popup/e2e/server.mjs b/ts/packages/popup/e2e/server.mjs index c9202965..c65f8b09 100644 --- a/ts/packages/popup/e2e/server.mjs +++ b/ts/packages/popup/e2e/server.mjs @@ -71,7 +71,7 @@ const appPage = html(` // The anchor keeps its fragment for the native path; the scripted path // passes fragment fields through the structured argument. const [base, hash = ''] = anchor.href.split('#') - void connection.navigate(base, new URLSearchParams(hash)).catch((error) => window.__events.push({ type: 'error', code: error.message })) + void connection.navigate(base, new URLSearchParams(hash)).catch((error) => window.__events.push({ type: 'error', code: error.code ?? error.message })) if (popupWindow.opened) event.preventDefault() }) @@ -119,17 +119,17 @@ const popupPage = html(` connection.send({ type: 'pong', n: ping.n, path: location.pathname, isolated: crossOriginIsolated }) }) connection.on(Go, (go) => { - connection.navigate(go.url, new URLSearchParams(go.fragment ?? '')).catch((error) => window.__events.push({ type: 'error', code: error.message })) + connection.navigate(go.url, new URLSearchParams(go.fragment ?? '')).catch((error) => window.__events.push({ type: 'error', code: error.code ?? error.message })) }) connection.on(Away, (away) => { - connection.navigateAway(away.url, new URLSearchParams(away.fragment ?? '')).catch((error) => window.__events.push({ type: 'error', code: error.message })) + connection.navigateAway(away.url, new URLSearchParams(away.fragment ?? '')).catch((error) => window.__events.push({ type: 'error', code: error.code ?? error.message })) }) await connection.ready connection.send({ type: 'pong', n: 0, path: location.pathname, isolated: crossOriginIsolated }) document.getElementById('status').textContent = 'connected' } catch (error) { - window.__events.push({ type: 'error', code: error.message }) - document.getElementById('status').textContent = 'failed: ' + error.message + window.__events.push({ type: 'error', code: error.code ?? error.message }) + document.getElementById('status').textContent = 'failed: ' + (error.code ?? error.message) } `) @@ -184,6 +184,8 @@ function popupHandler(req, res, page = popupPage) { case '/dip-broken/fallback': // COOP without COEP: never isolated, so the fallback must not loop. return send(res, 200, { ...HTML, 'Cross-Origin-Opener-Policy': 'same-origin' }, page) + case '/external-isolated': + return send(res, 200, { ...HTML, ...ISOLATED }, externalPage) case '/external': return send(res, 200, HTML, externalPage) default: diff --git a/ts/packages/popup/src/connection.test.ts b/ts/packages/popup/src/connection.test.ts index bedfda8f..cfb52960 100644 --- a/ts/packages/popup/src/connection.test.ts +++ b/ts/packages/popup/src/connection.test.ts @@ -591,9 +591,9 @@ describe('navigateAway [POPUP-CONTROL-005]', () => { await acceptPopup(pair).connection await tick() pair.popupProxy.closed = true - await expect(app.connection.navigateAway('https://provider.example/')).rejects.toThrow( - 'popup-unavailable', - ) + await expect(app.connection.navigateAway('https://provider.example/')).rejects.toMatchObject({ + code: 'popup-unavailable', + }) expect(pair.popupProxy.replaced).toEqual([]) const blocked = connectApp(fakePair(), { blocked: true }) @@ -656,7 +656,7 @@ describe('popup-side wildcard allowlist [POPUP-CONNECTION-009]', () => { source: pair.appProxy, ports: [new MessageChannel().port1], }) - await expect(pending.ready).rejects.toThrow('handshake-rejected') + await expect(pending.ready).rejects.toMatchObject({ code: 'handshake-rejected' }) } }) @@ -764,6 +764,45 @@ describe('isolation fallback [POPUP-CONNECTION-011/012]', () => { expect(codes(app.events).filter((c) => c === 'carrier-message-port')).toHaveLength(1) }) + it('waits for the pending handshake when COOP precedes acknowledgement delivery', async () => { + const pair = fakePair() + pair.relocate(POPUP_ORIGIN, '/prover') + const scope = fakeScope() + const intervals = vi.spyOn(globalThis, 'setInterval') + const postToPopup = pair.popupProxy.postMessage.bind(pair.popupProxy) + let acknowledge!: () => void + vi.spyOn(pair.popupProxy, 'postMessage').mockImplementationOnce((message, origin, transfer) => { + const port = transfer![0] as MessagePort + const post = port.postMessage.bind(port) + vi.spyOn(port, 'postMessage').mockImplementationOnce((value) => { + // The popup can complete its worker handoff before the app processes this ACK. + acknowledge = () => post(value) + }) + postToPopup(message, origin, transfer) + }) + const app = connectApp(pair) + const side = acceptIsolating(pair, { worker: scope.worker }) + const ended = vi.fn() + void app.connection.closed.then(ended) + try { + await side.endpoint.closed + expect(pair.popupProxy.replaced).toEqual([`${POPUP_ORIGIN}${FALLBACK}`]) + pair.popupProxy.closed = true + expect(app.connection.peerOrigin).toBeNull() + const poll = intervals.mock.calls[0][0] as () => void + poll() + await Promise.resolve() + expect(ended).not.toHaveBeenCalled() + acknowledge() + await app.connection.ready + expect(app.connection.peerOrigin).toBe(POPUP_ORIGIN) + } finally { + await app.connection.close() + await side.endpoint.close() + intervals.mockRestore() + } + }) + it('fails without looping when the fallback itself is not isolated', async () => { const pair = fakePair() pair.relocate(POPUP_ORIGIN, FALLBACK, '#c=1') @@ -1123,7 +1162,7 @@ describe('isolation fallback over a non-transferable carrier [POPUP-CONNECTION-0 pair.setIsolated(true) hub.failNext = true const second = acceptWith(pair, hub, '/prover/fallback') - await expect(second.endpoint.ready).rejects.toThrow('fallback-failed') + await expect(second.endpoint.ready).rejects.toMatchObject({ code: 'fallback-failed' }) // The application is not told and still awaits its first carrier, so it // cannot send into a gap: there is none. let appEnded = false @@ -1149,7 +1188,7 @@ describe('fallback seam [POPUP-CONNECTION-002/004/005] [POPUP-DIAGNOSTIC-003]', it('fails closed with fallback-unavailable exactly once when no opener and no constructor', async () => { const pair = fakePair() const side = acceptPopup(pair, { opener: false }) - await expect(side.connection).rejects.toThrow('fallback-unavailable') + await expect(side.connection).rejects.toMatchObject({ code: 'fallback-unavailable' }) expect(codes(side.events)).toEqual(['fallback-unavailable', 'connection-failed']) }) @@ -1367,7 +1406,7 @@ describe('selection order at accept level [POPUP-CONNECTION-002]', () => { source: pair.appProxy, ports: [new MessageChannel().port1], }) - await expect(side.connection).rejects.toThrow('handshake-rejected') + await expect(side.connection).rejects.toMatchObject({ code: 'handshake-rejected' }) expect(fallback).not.toHaveBeenCalled() expect(codes(side.events)).toEqual(['handshake-rejected', 'connection-failed']) }) @@ -1457,7 +1496,7 @@ describe('authenticated peer origin [POPUP-CONNECTION-007] [POPUP-KEEPER-001]', expect(received).toHaveBeenCalledOnce() await next.close() } else { - await expect(next.ready).rejects.toThrow('handshake-rejected') + await expect(next.ready).rejects.toMatchObject({ code: 'handshake-rejected' }) expect(next.peerOrigin).toBeNull() expect(scope.pending).toHaveLength(1) // No second keep or isolation navigation. } @@ -1477,10 +1516,236 @@ describe('authenticated peer origin [POPUP-CONNECTION-007] [POPUP-KEEPER-001]', close: vi.fn(), } as unknown as Carrier const side = acceptPopup(pair, { opener: false, fallback: carrier }) - await expect(side.endpoint.ready).rejects.toThrow('handshake-rejected') + await expect(side.endpoint.ready).rejects.toMatchObject({ code: 'handshake-rejected' }) expect(side.endpoint.peerOrigin).toBeNull() expect(carrier.on).not.toHaveBeenCalled() expect(carrier.close).toHaveBeenCalledOnce() }, ) }) + +describe('reported document departure [POPUP-CONNECTION-006]', () => { + it.each([false, true])( + 'ends exactly once on pagehide with a severed handle: %s', + async (severed) => { + const pair = fakePair() + const app = connectApp(pair) + const popup = await acceptPopup(pair).connection + const ends = vi.fn() + void app.connection.closed.then(ends) + if (severed) pair.popupProxy.closed = true + await tick() + expect(ends).not.toHaveBeenCalled() + pair.popupView.pagehide() + pair.popupView.pagehide() + await tick() + expect(ends).toHaveBeenCalledExactlyOnceWith({ outcome: 'closed' }) + await expect(popup.closed).resolves.toEqual({ outcome: 'closed' }) + expect(codes(app.events)).not.toContain('connection-failed') + expect(() => app.connection.send({ type: 'document-departed' } as never)).toThrow('reserved') + }, + ) + + it('notifies the application when the popup closes itself', async () => { + const pair = fakePair() + const app = connectApp(pair) + const popup = await acceptPopup(pair).connection + await popup.close() + await tick() + expect(pair.popupProxy.closed).toBe(true) + await expect(app.connection.closed).resolves.toEqual({ outcome: 'closed' }) + }) + + it('does not report expected navigation, or departure over a retired carrier', async () => { + const pair = fakePair() + const app = connectApp(pair) + const scope = fakeScope() + const popup = await acceptPopup(pair, { worker: scope.worker }).connection + const ends = vi.fn() + void app.connection.closed.then(ends) + await popup.navigate(`${POPUP_ORIGIN}/next`) + pair.popupView.pagehide() + await tick() + expect(ends).not.toHaveBeenCalled() + pair.relocate(POPUP_ORIGIN, '/next') + await acceptPopup(pair, { worker: scope.worker }).connection + await app.connection.navigateAway('https://provider.example/consent') + pair.popupView.pagehide() + await tick() + expect(ends).not.toHaveBeenCalled() + await app.connection.close() + }) + + it('rejects a departure notification in the wrong direction', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const pair = fakePair() + const app = connectApp(pair) + const popup = await acceptPopup(pair).connection + await app.connection.ready + ;(app.connection as unknown as { carrier: Carrier }).carrier.send({ + type: 'document-departed', + }) + await tick() + await expect(popup.closed).resolves.toEqual({ outcome: 'failed', code: 'control-rejected' }) + expect(pair.popupProxy.closed).toBe(false) + await app.connection.close() + } finally { + log.mockRestore() + } + }) + + it('a failed departure send still closes locally without reporting another failure', async () => { + const pair = fakePair() + const app = connectApp(pair) + const popup = await acceptPopup(pair).connection + const carrier = (popup as unknown as { carrier: Carrier }).carrier + vi.spyOn(carrier, 'send').mockImplementation(() => { + throw new Error('unavailable') + }) + pair.popupView.pagehide() + await expect(popup.closed).resolves.toEqual({ outcome: 'closed' }) + await app.connection.close() + }) +}) + +describe('unreachable window [POPUP-CONNECTION-006]', () => { + for (const blocked of [false, true]) + it(`fails on provider closure after ${blocked ? 'native-anchor' : 'scripted'} binding`, async () => { + const pair = fakePair() + const app = connectApp(pair, { blocked }) + const popup = await acceptPopup(pair).connection + await app.connection.ready + await app.connection.navigateAway('https://provider.example/consent') + // No package code or departure message runs on the provider page. + pair.popupProxy.closed = true + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + await expect(app.connection.closed).resolves.toEqual({ + outcome: 'failed', + code: 'popup-unavailable', + }) + expect(pair.appView.listeners.size).toBe(0) + expect(new PopupError('popup-unavailable').message).toBe('popup-unavailable') + } finally { + log.mockRestore() + await app.connection.close() + await popup.close() + } + }) + + it('keeps an unbound anchor pending and stops polling on explicit closure', async () => { + vi.useFakeTimers() + const app = connectApp(fakePair(), { blocked: true }) + const ended = vi.fn() + void app.connection.closed.then(ended) + try { + await vi.advanceTimersByTimeAsync(60_000) + expect(ended).not.toHaveBeenCalled() + await app.connection.close() + expect(vi.getTimerCount()).toBe(0) + } finally { + await app.connection.close() + vi.useRealTimers() + } + }) + + it('rejects readiness when the handle is lost before any carrier is selected', async () => { + vi.useFakeTimers() + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const pair = fakePair() + const app = connectApp(pair) + const ready = app.connection.ready.catch((error) => error) + pair.popupProxy.closed = true + try { + await vi.advanceTimersByTimeAsync(250) + expect(await ready).toMatchObject({ code: 'popup-unavailable' }) + await expect(app.connection.closed).resolves.toEqual({ + outcome: 'failed', + code: 'popup-unavailable', + }) + expect(vi.getTimerCount()).toBe(0) + } finally { + await app.connection.close() + vi.useRealTimers() + log.mockRestore() + } + }) + + it('does not mistake isolation with a preserved carrier for disconnection', async () => { + vi.useFakeTimers() + const pair = fakePair() + const [carrier, peer] = carrierPair() + const app = connectApp(pair, { fallback: carrier }) + await app.connection.ready + pair.popupProxy.closed = true + const ended = vi.fn() + void app.connection.closed.then(ended) + try { + await vi.advanceTimersByTimeAsync(60_000) + expect(ended).not.toHaveBeenCalled() + expect(app.connection.peerOrigin).toBe(POPUP_ORIGIN) + } finally { + await app.connection.close() + peer.close() + vi.useRealTimers() + } + }) + + for (const outcome of ['connected', 'rejected', 'closed'] as const) + it(`waits for fallback without a deadline until ${outcome}`, async () => { + vi.useFakeTimers() + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const pair = fakePair() + const [carrier, peer] = carrierPair() + const dispose = vi.spyOn(carrier, 'close') + let resolve!: (carrier: Carrier) => void + let reject!: () => void + let signal!: AbortSignal + const connection = PopupConnection.connect( + new OpenedWindow(pair.popupProxy as unknown as WindowProxy, pair.appView), + { + connectionId: ID, + allowedPopupOrigins: [POPUP_ORIGIN], + fallback: (abort) => { + signal = abort + return new Promise((yes, no) => { + resolve = yes + reject = no + }) + }, + }, + ) + pair.popupProxy.closed = true + const ended = vi.fn() + void connection.closed.then(ended) + try { + await vi.advanceTimersByTimeAsync(60_000) + expect(ended).not.toHaveBeenCalled() + if (outcome === 'closed') await connection.close() + if (outcome === 'rejected') reject() + else resolve(carrier) + await vi.advanceTimersByTimeAsync(250) + if (outcome === 'connected') { + await connection.ready + expect(connection.peerOrigin).toBe(POPUP_ORIGIN) + expect(ended).not.toHaveBeenCalled() + } else { + await expect(connection.closed).resolves.toEqual( + outcome === 'closed' + ? { outcome: 'closed' } + : { outcome: 'failed', code: 'popup-unavailable' }, + ) + expect(signal.aborted).toBe(true) + expect(vi.getTimerCount()).toBe(0) + if (outcome === 'closed') expect(dispose).toHaveBeenCalledTimes(1) + } + } finally { + await connection.close() + carrier.close() + peer.close() + vi.useRealTimers() + log.mockRestore() + } + }) +}) diff --git a/ts/packages/popup/src/connection.ts b/ts/packages/popup/src/connection.ts index 26af18f0..6438d17a 100644 --- a/ts/packages/popup/src/connection.ts +++ b/ts/packages/popup/src/connection.ts @@ -1,7 +1,7 @@ // The logical connection (docs/connection.md, docs/control.md): one // application endpoint that may see several popup documents, and one popup // endpoint per document. Both share registration, routing, and closure; the -// popup side additionally consumes the two reserved controls. +// popup side consumes navigation/close controls and reports document departure. import { createReporter, @@ -41,7 +41,11 @@ export type ConnectionEnd = { outcome: 'closed' } | { outcome: 'failed'; code: P export interface PopupConnection { /** Settles when this endpoint has selected its first carrier, or rejects if it failed first. */ readonly ready: Promise - /** Settles exactly once, when the logical connection ends; never rejects. */ + /** + * Settles once on explicit close, reported document departure, or detected failure. + * Never rejects. An unreachable handle fails only without a carrier or pending recovery. + * Silence alone does not establish closure. + */ readonly closed: Promise /** Authenticated peer of the selected carrier; null before selection or after retirement. */ readonly peerOrigin: string | null @@ -66,6 +70,7 @@ export interface PopupConnection export interface ConnectOptions { connectionId: string allowedPopupOrigins: readonly string[] + /** Armed once; pending recovery survives handle loss without a connection-layer timeout. */ fallback?: CarrierConstructor onDiagnostic?: (event: PopupDiagnostic) => void } @@ -219,7 +224,9 @@ abstract class Endpoint this.dropCarrier() this.carrier = carrier this.boundOrigin = carrier.peerOrigin - this.unsubscribe = carrier.on((value) => this.receive(value)) + this.unsubscribe = carrier.on((value) => { + if (this.carrier === carrier) this.receive(value) + }) if (code) this.report(code) this.resolveReady() } @@ -308,6 +315,8 @@ abstract class Endpoint class ApplicationEndpoint extends Endpoint { private readonly stopListening: () => void private stopReplacement: (() => void) | null = null + private fallbackPending: boolean + private handshakePending = false constructor( private readonly popup: OpenedWindow, @@ -318,6 +327,7 @@ class ApplicationEndpoint extends Endpo const connectionId = requireConnectionId(options.connectionId) if (popup.connected) throw new Error('PopupWindow is already connected') popup.connected = true + this.fallbackPending = options.fallback !== undefined this.report(popup.opened ? 'window-opened' : 'window-blocked') this.stopListening = listenForPopupPorts( @@ -335,27 +345,44 @@ class ApplicationEndpoint extends Endpo onPort: (port, peerOrigin) => this.install(new PortCarrier(port, peerOrigin), 'carrier-message-port'), onFail: () => this.fail('handshake-rejected'), + onPending: (pending) => { + this.handshakePending = pending + }, }, ) + // Provider pages cannot report departure. A closed handle also means COOP + // severance, so fail only when neither a carrier nor recovery is available. + const poll = setInterval(() => this.checkWindow(), 250) + this.controller.signal.addEventListener('abort', () => clearInterval(poll), { once: true }) + if (options.fallback) { // Armed exactly once for the logical connection; observed, never awaited. const { fallback } = options new Promise((resolve) => resolve(fallback(this.controller.signal))).then( (carrier) => { + this.fallbackPending = false if (this.ended) carrier.close() else this.install(carrier, 'carrier-fallback') }, () => { - // A rejected standby is silent unless its path was selected. + // Losing standby alone is harmless while the opener/carrier still works. + this.fallbackPending = false + this.checkWindow() }, ) } } - protected onControl(): void { - // Controls are application-to-popup only. - this.fail('control-rejected') + private checkWindow(): void { + if (this.ended || this.carrier || this.fallbackPending || this.handshakePending) return + // Native-anchor creation has no handle until the first authenticated binding. + if (this.popup.opened && !this.popup.direct) this.fail('popup-unavailable') + } + + protected onControl(control: PopupControl): void { + if (control.type === 'document-departed') this.release() + else this.fail('control-rejected') } /** @@ -468,6 +495,16 @@ class PopupEndpoint extends Endpoint { + if (this.ended || this.controlsDone) return + this.notifyDeparture() + this.release() + } + // WebKit otherwise skips pagehide when closing a window. This listener never + // prompts or reports departure; a beforeunload can still be cancelled. + const beforeUnload = () => {} + popup.view.addEventListener('beforeunload', beforeUnload, { signal: this.controller.signal }) + popup.view.addEventListener('pagehide', departed, { signal: this.controller.signal }) void this.select(allowedOrigins, options.fallback) } @@ -566,6 +603,10 @@ class PopupEndpoint extends Endpoint extends Endpoint { describe('controls [POPUP-CONTROL-004]', () => { it('decodes exact records only', () => { expect(decodeControl({ type: 'close-popup' })).toEqual({ type: 'close-popup' }) + expect(decodeControl({ type: 'document-departed' })).toEqual({ type: 'document-departed' }) expect(decodeControl({ type: 'navigate', url: 'https://p.example/' })).toEqual({ type: 'navigate', url: 'https://p.example/', }) for (const bad of [ { type: 'close-popup', extra: 1 }, + { type: 'document-departed', extra: 1 }, { type: 'navigate' }, { type: 'navigate', url: 'http://p.example/' }, { type: 'navigate', url: 'https://p.example/', extra: 1 }, @@ -70,9 +72,10 @@ describe('controls [POPUP-CONTROL-004]', () => { } }) - it('reserves both discriminators', () => { + it('reserves control discriminators', () => { expect(isReservedType('navigate')).toBe(true) expect(isReservedType('close-popup')).toBe(true) + expect(isReservedType('document-departed')).toBe(true) expect(isReservedType('ready')).toBe(false) }) }) diff --git a/ts/packages/popup/src/message.ts b/ts/packages/popup/src/message.ts index 61743ff5..f5eae1f6 100644 --- a/ts/packages/popup/src/message.ts +++ b/ts/packages/popup/src/message.ts @@ -1,9 +1,9 @@ // The wire leaf: the transport version, the caller-owned message contract, -// the carrier seam, the two reserved controls, and the validators every +// the carrier seam, the reserved controls, and the validators every // other module shares. Nothing here touches a browser global. /** Exact-matched in every private transport record; never negotiated. */ -export const CONNECTION_VERSION = 2 as const +export const CONNECTION_VERSION = 1 as const export type ConnectionVersion = typeof CONNECTION_VERSION export interface Message { @@ -56,11 +56,20 @@ export interface ClosePopup { readonly type: 'close-popup' } -export type PopupControl = Navigate | ClosePopup +/** Best-effort notification that a participating popup document has departed. */ +export interface DocumentDeparted { + readonly type: 'document-departed' +} + +export type PopupControl = Navigate | ClosePopup | DocumentDeparted export const MAX_TYPE_LENGTH = 64 -const RESERVED_TYPES: ReadonlySet = new Set(['navigate', 'close-popup']) +const RESERVED_TYPES: ReadonlySet = new Set([ + 'navigate', + 'close-popup', + 'document-departed', +]) export function isReservedType(type: string): boolean { return RESERVED_TYPES.has(type) @@ -105,8 +114,8 @@ export function isCanonicalWebUrl(url: string): boolean { } export function decodeControl(value: Record): PopupControl | null { - if (value.type === 'close-popup') { - return hasExactKeys(value, ['type']) ? { type: 'close-popup' } : null + if (value.type === 'close-popup' || value.type === 'document-departed') { + return hasExactKeys(value, ['type']) ? { type: value.type } : null } if (value.type === 'navigate') { return hasExactKeys(value, ['type', 'url']) && diff --git a/ts/packages/popup/src/port.test.ts b/ts/packages/popup/src/port.test.ts index fd2d17aa..84f668f7 100644 --- a/ts/packages/popup/src/port.test.ts +++ b/ts/packages/popup/src/port.test.ts @@ -227,7 +227,7 @@ describe('popup request', () => { for (const event of cases) { const pending = request(pair) pair.popupView.dispatch(event) - await expect(pending).rejects.toThrow('handshake-rejected') + await expect(pending).rejects.toMatchObject({ code: 'handshake-rejected' }) } expect(pair.popupView.listeners.size).toBe(0) }) diff --git a/ts/packages/popup/src/port.ts b/ts/packages/popup/src/port.ts index cadbd4ff..09041ea2 100644 --- a/ts/packages/popup/src/port.ts +++ b/ts/packages/popup/src/port.ts @@ -63,6 +63,8 @@ export interface ListenHandlers { onPort: (port: MessagePort, peerOrigin: string) => void /** The expected peer sent a malformed handshake or acknowledgement. */ onFail: () => void + /** Whether a validated handshake is awaiting its port acknowledgement. */ + onPending?: (pending: boolean) => void } /** @@ -82,6 +84,7 @@ export function listenForPopupPorts(options: ListenOptions, handlers: ListenHand pending.onmessage = null pending.close() pending = null + handlers.onPending?.(false) } } @@ -102,6 +105,7 @@ export function listenForPopupPorts(options: ListenOptions, handlers: ListenHand const channel = new MessageChannel() const port = channel.port1 pending = port + handlers.onPending?.(true) port.onmessage = (ack: MessageEvent): void => { if (pending !== port) return if (!isExactHandshake(ack.data, connectionId) || ack.ports.length !== 0) { @@ -110,6 +114,7 @@ export function listenForPopupPorts(options: ListenOptions, handlers: ListenHand return } pending = null + handlers.onPending?.(false) port.onmessage = null handlers.onPort(port, event.origin) } diff --git a/ts/packages/popup/src/testing/fakes.ts b/ts/packages/popup/src/testing/fakes.ts index 1e8a687c..b7606de9 100644 --- a/ts/packages/popup/src/testing/fakes.ts +++ b/ts/packages/popup/src/testing/fakes.ts @@ -33,15 +33,30 @@ type Listener = (event: MessageEvent) => void export interface FakeView extends View { readonly listeners: Set + pagehide(): void + beforeunload(): void dispatch(event: { data: unknown; origin: string; source: unknown; ports?: MessagePort[] }): void } export function fakeView(): FakeView { const listeners = new Set() + const lifecycle = new EventTarget() return { listeners, - addEventListener: (_type, listener) => void listeners.add(listener), - removeEventListener: (_type, listener) => void listeners.delete(listener), + addEventListener: (type, listener, options?: AddEventListenerOptions | boolean) => { + if (type === 'message') listeners.add(listener) + else lifecycle.addEventListener(type, listener as EventListener, options) + }, + removeEventListener: (type, listener) => { + if (type === 'message') listeners.delete(listener) + else lifecycle.removeEventListener(type, listener as EventListener) + }, + pagehide: () => { + lifecycle.dispatchEvent(new Event('pagehide')) + }, + beforeunload: () => { + lifecycle.dispatchEvent(new Event('beforeunload', { cancelable: true })) + }, dispatch(event) { const message = { ports: [], ...event } as unknown as MessageEvent // Tasks, not microtasks: listeners run after the current stack.