fix(puter-js): push syncs skip sign-in and follow disable() across tabs (#4470)

* fix(puter-js): background push syncs never prompt for sign-in

The load-time sync and the resync after the browser replaces a
subscription ask for the key and announce the subscription without
opening Puter's sign-in; only enable() may. The confirmation and the
removal never open it, and looking up the user for the opt-in uses a
non-interactive /whoami. A rejected token during a background sync is
not reported as a sync failure.

* fix(puter-js): turning push off in one tab stops other tabs' syncs

Tabs of a site take a shared Web Lock to subscribe and announce, and
again to confirm, without holding it while the confirmation push is on
its way. A load sync re-reads the opt-in inside the lock and stops when
it is gone. disable() tells the other tabs first, so their syncs and any
enable() in flight stop, then forgets the opt-ins and removes the
subscription inside the same lock. Without Web Locks the re-reads and
the message still apply.

* fix(puter-js): wait for a push confirmation only once the lock is held

A sync queued behind another tab's lock started its 30 second
confirmation timer and listener before it got the lock, so a long wait
failed the sync or left the timeout unhandled. The listener now starts
inside the locked step, right before the subscription is announced.

* test(puter-js): reach the push sync in the boot sign-in e2e

The push page now has the user's opt-in and cached user from an earlier
load, starts its sync before boot's own requests come back, and gets a
rejected token from the push routes, so the spec covers the key request
that must not open a sign-in.

* test(puter-js): run the push boot test on full Chromium and skip it without push support

The headless shell keeps Notification.permission at 'denied' after the
permission is granted, so onPush() never synced there and the test saw
no key request. The test now launches full Chromium itself, since a
channel can't be set per test, and is skipped on WebKit and wherever the
page has no PushManager or notification permission.

* test(puter-js): keep the push boot test comment to one line
This commit is contained in:
Juan Fernando Castro authored and GitHub committed 2026-11-03 10:06:15 -05:00
1 parent cdb3110857
commit 96d91cd74e
5 files changed
+578 -60

No files matched your search

+42 -14
View File
@@ -5,6 +5,7 @@ import { cancel } from './cancel.js';
import { disable } from './disable.js';
import { enable } from './enable.js';
import { enablePush } from './enablePush.js';
import { request } from './lib/api.js';
import {
CLICKS_STORE,
HANDLERS_STORE,
@@ -48,6 +49,8 @@ export const CLICK_PARAM = 'puter.notification';
/** Where a site's tabs tell each other that a user's opt-in changed. */
const OPT_IN_CHANNEL = 'puter-push-opt-in';
/** Held by a site's tabs while one changes this browser's subscription. */
const PUSH_LOCK = 'puter-push';
/** As long as `push-sw.js` gives a handler. */
const HANDLER_TIMEOUT_MS = 5_000;
@@ -60,6 +63,8 @@ const QUIET_SYNC_ERRORS = new Set([
'push_revoked',
'push_unavailable',
'push_verification_canceled',
'reauth_required',
'token_auth_failed',
'too_many_requests',
]);
@@ -73,6 +78,14 @@ const warnStorage = (error) => {
);
};
/** Runs `run` alone among the site's tabs, where the browser has Web Locks. */
const withPushLock = (run) => {
const locks = globalThis.navigator?.locks;
return typeof locks?.request === 'function'
? locks.request(PUSH_LOCK, () => run())
: run();
};
/** Hidden, or in a window without focus. */
const pageAway = () => {
const doc = globalThis.document;
@@ -363,6 +376,7 @@ export class NotificationsModule extends EventListener {
*/
async _enableWebPush(worker) {
this.#useWorker(worker);
this.#listenForOptInChanges();
/** @type {() => void} */
let pending = () => {};
const sent = new Promise((resolve) => {
@@ -374,6 +388,8 @@ export class NotificationsModule extends EventListener {
onPending: pending,
signal,
renewRemoved: true,
interactive: true,
withLock: withPushLock,
});
try {
// The GUI reads the registered device right after.
@@ -409,12 +425,12 @@ export class NotificationsModule extends EventListener {
* @internal
*/
async _disableWebPush(worker) {
this.#cancelVerification();
try {
return await removeWebPush(this.puter, { worker });
} finally {
this.#stopSyncs();
if (this.puter.env === 'web') this.#optInChanged('off');
return await withPushLock(async () => {
if (this.puter.env === 'web') await this.#forgetOptIns();
}
return await removeWebPush(this.puter, { worker });
});
}
/**
@@ -460,8 +476,7 @@ export class NotificationsModule extends EventListener {
*/
async _forgetPush(authToken) {
if (this.puter.env !== 'web') return;
this.#syncs.clear();
this.#cancelVerification();
this.#stopSyncs();
try {
if (authToken && webPushSupported()) {
await removeWebPush(this.puter, {
@@ -510,8 +525,14 @@ export class NotificationsModule extends EventListener {
const known =
this.puter.whoami ?? (await this.puter.whoamiCache_) ?? null;
if (typeof known?.uuid === 'string') return known.uuid;
const user = await this.puter.auth?.whoami?.();
return typeof user?.uuid === 'string' ? user.uuid : null;
try {
const user = await request(this.puter, 'GET', '/whoami', null, {
interactiveReauth: false,
});
return typeof user?.uuid === 'string' ? user.uuid : null;
} catch {
return null;
}
}
async #recordOptIn(on) {
@@ -558,11 +579,11 @@ export class NotificationsModule extends EventListener {
return on;
}
/** Other tabs of the site re-read the opt-in this one just wrote. */
#optInChanged() {
/** Other tabs re-read the opt-in; `off` also stops their syncs. */
#optInChanged(kind = 'changed') {
this.#listenForOptInChanges();
try {
this.#optInChannel?.postMessage('changed');
this.#optInChannel?.postMessage(kind);
} catch {
// A closed channel; the other tabs read it on their next sync.
}
@@ -575,7 +596,10 @@ export class NotificationsModule extends EventListener {
)
return;
this.#optInChannel = new BroadcastChannel(OPT_IN_CHANNEL);
this.#optInChannel.onmessage = () => void this.#readOptIn();
this.#optInChannel.onmessage = (event) => {
if (event.data === 'off') this.#stopSyncs();
void this.#readOptIn();
};
}
async #syncIfOptedIn(worker) {
@@ -584,6 +608,8 @@ export class NotificationsModule extends EventListener {
syncWebPush(this.puter, {
worker,
signal: this.#stopVerifying.signal,
stillWanted: () => this.#readOptIn(),
withLock: withPushLock,
}),
worker,
'could not keep the push subscription current',
@@ -625,9 +651,11 @@ export class NotificationsModule extends EventListener {
await this.#recordOptIn(false);
}
#cancelVerification() {
/** Ends this page's syncs in flight; the next `onPush` syncs again. */
#stopSyncs() {
this.#stopVerifying.abort();
this.#stopVerifying = new AbortController();
this.#syncs.clear();
}
/**
@@ -220,11 +220,23 @@ const subscribeWith = async (registration, key) => {
};
/** Tells the server about `subscription`; `active` or `pending`. */
const announce = (puter, subscription, label) =>
request(puter, 'POST', '/push/web/subscriptions', {
subscription: subscriptionBody(subscription),
...(label ? { label } : {}),
});
const announce = (puter, subscription, label, interactive) =>
request(
puter,
'POST',
'/push/web/subscriptions',
{
subscription: subscriptionBody(subscription),
...(label ? { label } : {}),
},
{ interactiveReauth: interactive },
);
const turnedOff = () =>
new PuterJSError(
'Push notifications were turned off before this browser was confirmed',
'push_verification_canceled',
);
/** Whether `buffer` is the key Puter last subscribed this origin with. */
const earlierPuterKey = async (buffer) => {
@@ -265,41 +277,44 @@ const currentSubscription = async (puter, registration, key) => {
return null;
};
/**
* Announces `subscription` and, for one the server doesn't have yet, waits for
* the confirmation push and verifies it.
*/
/** Announces what `subscribe` gives, then confirms it if the server asks. */
const register = async (
puter,
nav,
subscription,
{ label, onPending, signal },
subscribe,
{ label, onPending, signal, interactive, whileWanted },
) => {
// Listening first: the confirmation can arrive before the POST returns.
const challenge = nextChallenge(nav, signal);
/** @type {ReturnType<typeof nextChallenge> | null} */
let challenge = null;
try {
const result = await announce(puter, subscription, label);
const { subscription, result } = await whileWanted(async () => {
const subscription = await subscribe();
// Before the POST: the confirmation can arrive before it returns.
challenge = nextChallenge(nav, signal);
return {
subscription,
result: await announce(puter, subscription, label, interactive),
};
});
if (result.status === 'active') return result.device;
onPending?.();
const answer = await challenge.promise;
if (answer === null) {
throw new PuterJSError(
'Push notifications were turned off before this browser was confirmed',
'push_verification_canceled',
);
}
const { device } = await request(
puter,
'POST',
'/push/web/subscriptions/verify',
{
subscription: subscriptionBody(subscription),
challenge: answer,
},
if (answer === null) throw turnedOff();
const { device } = await whileWanted(() =>
request(
puter,
'POST',
'/push/web/subscriptions/verify',
{
subscription: subscriptionBody(subscription),
challenge: answer,
},
{ interactiveReauth: false },
),
);
return device;
} finally {
challenge.cancel();
challenge?.cancel();
}
};
@@ -316,12 +331,18 @@ const register = async (
* onPending?: () => void;
* signal?: AbortSignal;
* renewRemoved?: boolean;
* interactive?: boolean;
* stillWanted?: () => boolean | Promise<boolean>;
* withLock?: <T>(run: () => Promise<T>) => Promise<T>;
* }} options
* `onPending` runs once the server has the subscription and only its
* confirmation is left. Aborting `signal` stops waiting for that
* confirmation, with `push_verification_canceled`. `renewRemoved` replaces a
* subscription the user removed in Puter instead of rejecting with
* `push_device_removed`.
* `push_device_removed`. `interactive` lets a rejected token open Puter's
* sign-in for the key and the subscription; the confirmation never does.
* Subscribing and confirming each run in `withLock` while `stillWanted`
* holds, else reject with `push_verification_canceled`.
* @returns {Promise<Record<string, unknown>>} The server's device record.
*/
export async function syncWebPush(
@@ -333,9 +354,18 @@ export async function syncWebPush(
onPending,
signal,
renewRemoved = false,
interactive = false,
stillWanted = () => true,
withLock = (run) => run(),
},
) {
const { publicKey } = await request(puter, 'GET', '/push/web/key');
const { publicKey } = await request(
puter,
'GET',
'/push/web/key',
undefined,
{ interactiveReauth: interactive },
);
if (typeof publicKey !== 'string' || publicKey === '') {
throw new PuterJSError(
"Push notifications aren't available on this deployment",
@@ -347,24 +377,50 @@ export async function syncWebPush(
keepSiteWorker: puter.env !== 'gui',
});
let subscription =
(await currentSubscription(puter, registration, key)) ??
(await subscribeWith(registration, key));
await rememberPuterKey(publicKey);
const options = { label, onPending, signal };
/** @type {PushSubscription | null} */
let subscription = null;
const options = {
label,
onPending,
signal,
interactive,
whileWanted: (run) =>
withLock(async () => {
if (signal?.aborted || !(await stillWanted()))
throw turnedOff();
return await run();
}),
};
try {
return await register(puter, nav, subscription, options);
return await register(
puter,
nav,
async () => {
subscription =
(await currentSubscription(puter, registration, key)) ??
(await subscribeWith(registration, key));
await rememberPuterKey(publicKey);
return subscription;
},
options,
);
} catch (error) {
const renewable =
RENEWABLE_SUBSCRIPTION_ERRORS.has(error?.code) ||
(renewRemoved && error?.code === 'push_device_removed');
if (!renewable || signal?.aborted) throw error;
if (!renewable || !subscription || signal?.aborted) throw error;
// A brand-new subscription the push service doesn't know yet, an
// endpoint another account turned off here, or one the user removed.
await subscription.unsubscribe();
subscription = await subscribeWith(registration, key);
return await register(puter, nav, subscription, options);
const stale = subscription;
return await register(
puter,
nav,
async () => {
await stale.unsubscribe();
return await subscribeWith(registration, key);
},
options,
);
}
}
@@ -408,7 +464,7 @@ export async function removeWebPush(
'DELETE',
'/push/web/subscriptions',
{ endpoint: subscription.endpoint },
{ authToken },
{ authToken, interactiveReauth: false },
);
} finally {
await subscription.unsubscribe();
@@ -178,6 +178,7 @@ describe('syncWebPush', () => {
keys: { p256dh: 'p', auth: 'a' },
},
},
{ interactiveReauth: false },
);
expect(store.get('handlers:puter:server-key')).toBe(KEY);
});
@@ -545,6 +546,145 @@ describe('syncWebPush', () => {
),
).toHaveLength(2);
});
/** A server that answers pending; `confirm()` hands back the challenge. */
const pendingServer = (nav) => {
server((route) =>
route === '/push/web/subscriptions'
? { status: 'pending' }
: { device: { uid: 'd8' } },
);
return () =>
nav.post({ type: 'puter-push-challenge', challenge: 'c.sig' });
};
const reauthByRoute = () =>
Object.fromEntries(
mockRequest.mock.calls.map(([, , route, , options]) => [
route,
options?.interactiveReauth,
]),
);
it.each([
[false, undefined],
[true, true],
])(
'with interactive: %s, lets only the key and the announce open a sign-in',
async (interactive, given) => {
const nav = makeNav();
const confirm = pendingServer(nav);
const onPending = vi.fn();
const syncing = syncWebPush(puter, {
worker,
nav,
onPending,
interactive: given,
});
await vi.waitFor(() => expect(onPending).toHaveBeenCalled());
confirm();
await syncing;
expect(reauthByRoute()).toEqual({
'/push/web/key': interactive,
'/push/web/subscriptions': interactive,
'/push/web/subscriptions/verify': false,
});
},
);
it('subscribes and announces inside withLock, and not at all once push is no longer wanted', async () => {
const nav = makeNav();
const order = [];
server((route) => {
order.push(route);
return { status: 'active', device: { uid: 'd1' } };
});
const withLock = async (run) => {
order.push('lock');
try {
return await run();
} finally {
order.push('unlock');
}
};
nav.pushManager.subscribe.mockImplementation(async () => {
order.push('subscribe');
return makeSubscription();
});
await syncWebPush(puter, { worker, nav, withLock });
expect(order).toEqual([
'lock',
'subscribe',
'/push/web/subscriptions',
'unlock',
]);
mockRequest.mockClear();
nav.pushManager.subscribe.mockClear();
await expect(
syncWebPush(puter, { worker, nav, stillWanted: () => false }),
).rejects.toMatchObject({ code: 'push_verification_canceled' });
expect(nav.pushManager.subscribe).not.toHaveBeenCalled();
expect(Object.keys(reauthByRoute())).toEqual(['/push/web/key']);
});
it.each(['active', 'pending'])(
'waits for the lock as long as it takes when the server answers %s',
async (status) => {
vi.useFakeTimers();
const unhandled = [];
const note = (reason) => unhandled.push(reason);
process.on('unhandledRejection', note);
try {
const nav = makeNav();
server(() => ({ status, device: { uid: 'd9' } }));
let release;
const held = new Promise((resolve) => {
release = resolve;
});
const syncing = syncWebPush(puter, {
worker,
nav,
onPending: () =>
nav.post({
type: 'puter-push-challenge',
challenge: 'c.sig',
}),
withLock: async (run) => {
await held;
return await run();
},
});
await vi.advanceTimersByTimeAsync(31_000);
release();
await expect(syncing).resolves.toEqual({ uid: 'd9' });
await vi.advanceTimersByTimeAsync(0);
expect(unhandled).toEqual([]);
} finally {
process.off('unhandledRejection', note);
}
},
);
it('verifies only while push is still wanted when the confirmation arrives', async () => {
const nav = makeNav();
const confirm = pendingServer(nav);
let wanted = true;
const syncing = syncWebPush(puter, {
worker,
nav,
onPending: () => {
wanted = false;
confirm();
},
stillWanted: () => wanted,
});
await expect(syncing).rejects.toMatchObject({
code: 'push_verification_canceled',
});
expect(Object.keys(reauthByRoute())).not.toContain(
'/push/web/subscriptions/verify',
);
});
});
describe('removeWebPush', () => {
@@ -558,7 +698,7 @@ describe('removeWebPush', () => {
'DELETE',
'/push/web/subscriptions',
{ endpoint: subscription.endpoint },
{ authToken: undefined },
{ authToken: undefined, interactiveReauth: false },
);
expect(subscription.unsubscribe).toHaveBeenCalled();
});
@@ -849,6 +989,8 @@ describe('onPush', () => {
'push_disabled',
'push_revoked',
'push_unavailable',
'reauth_required',
'token_auth_failed',
'too_many_requests',
])('keeps quiet about %s', async (code) => {
store.set(OPT_IN, true);
@@ -869,6 +1011,33 @@ describe('onPush', () => {
error.mockRestore();
});
it('never opens a sign-in, nor when the browser replaces the subscription', async () => {
store.set(OPT_IN, true);
const nav = makeNav({ existing: makeSubscription() });
withBrowser(nav);
server();
const { module } = makeModule();
await module.onPush('chat', () => {});
await vi.waitFor(() =>
expect(mockRequest).toHaveBeenCalledTimes(2),
);
nav.post({ type: 'puter-push-subscription-changed' });
await vi.waitFor(() =>
expect(mockRequest).toHaveBeenCalledTimes(4),
);
expect(
mockRequest.mock.calls.map(([, , route, , options]) => [
route,
options,
]),
).toEqual([
['/push/web/key', { interactiveReauth: false }],
['/push/web/subscriptions', { interactiveReauth: false }],
['/push/web/key', { interactiveReauth: false }],
['/push/web/subscriptions', { interactiveReauth: false }],
]);
});
it('ends a subscription the user removed in Puter, quietly, and keeps pushes off until enable()', async () => {
store.set(OPT_IN, true);
const removed = makeSubscription();
@@ -1347,6 +1516,7 @@ describe('enable', () => {
'POST',
'/push/web/subscriptions',
expect.anything(),
{ interactiveReauth: false },
);
});
@@ -1376,6 +1546,55 @@ describe('enable', () => {
await expect(enabling).resolves.toBe(true);
});
it('lets the key and the announce open a sign-in, but not the confirmation', async () => {
const nav = makeNav();
withBrowser(nav);
server((route) =>
route === '/push/web/subscriptions'
? { status: 'pending' }
: { device: { uid: 'd2' } },
);
const { module } = makeModule();
await module.enable();
nav.post({ type: 'puter-push-challenge', challenge: 'c.sig' });
await vi.waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(3));
expect(
mockRequest.mock.calls.map(([, , route, , options]) => [
route,
options,
]),
).toEqual([
['/push/web/key', { interactiveReauth: true }],
['/push/web/subscriptions', { interactiveReauth: true }],
['/push/web/subscriptions/verify', { interactiveReauth: false }],
]);
});
it('looks the user up without opening a sign-in when no user is cached', async () => {
withBrowser(makeNav({ existing: makeSubscription() }));
server((route) =>
route === '/whoami'
? { uuid: 'u-1' }
: { status: 'active', device: { uid: 'd1' } },
);
const auth = { whoami: vi.fn(async () => ({ uuid: 'u-1' })) };
const { module } = makeModule({
whoami: undefined,
whoamiCache_: Promise.resolve(null),
auth,
});
await module.enable();
expect(store.get(OPT_IN)).toBe(true);
expect(auth.whoami).not.toHaveBeenCalled();
expect(mockRequest).toHaveBeenCalledWith(
expect.anything(),
'GET',
'/whoami',
null,
{ interactiveReauth: false },
);
});
it('rejects user_gesture_required outside a click, without prompting', async () => {
withBrowser(makeNav(), 'default');
Object.defineProperty(globalThis.navigator, 'userActivation', {
@@ -1619,8 +1838,9 @@ describe('disable', () => {
const { module } = makeModule();
const enabling = module.enable();
await vi.waitFor(() => expect(announce).toBeDefined());
await module.disable();
const disabling = module.disable();
announce();
await expect(disabling).resolves.toBe(true);
await expect(enabling).rejects.toMatchObject({
code: 'push_verification_canceled',
});
@@ -1639,6 +1859,115 @@ describe('disable', () => {
});
});
describe.each([
['with Web Locks', false],
['without Web Locks', true],
])('another tab turning push off, %s', (_label, noLocks) => {
beforeEach(() => {
if (noLocks)
Object.defineProperty(globalThis.navigator, 'locks', {
value: undefined,
configurable: true,
});
});
afterEach(() => {
if (noLocks) delete globalThis.navigator.locks;
});
/** Requests these tabs made; earlier tests' tabs still hear the channel. */
const routes = (...tabs) =>
mockRequest.mock.calls
.filter(([host]) => tabs.some((tab) => tab.puter === host))
.map(([, method, route]) => `${method} ${route}`);
it('stops a load sync before it announces, and the device stays removed', async () => {
store.set(OPT_IN, true);
withBrowser(makeNav({ existing: makeSubscription() }));
let releaseKey;
mockRequest.mockImplementation(async (_p, method, route) => {
if (route === '/push/web/key') {
await new Promise((resolve) => {
releaseKey = resolve;
});
return { publicKey: KEY };
}
return method === 'DELETE'
? { removed: true }
: { status: 'active', device: { uid: 'd1' } };
});
const onError = vi.fn();
const { module: loading } = makeModule();
await loading.onPush('chat', () => {}, { onError });
await vi.waitFor(() => expect(releaseKey).toBeDefined());
const { module: other } = makeModule();
await expect(other.disable()).resolves.toBe(true);
releaseKey();
await flush();
await flush();
expect(routes(loading, other)).toEqual([
'GET /push/web/key',
'DELETE /push/web/subscriptions',
]);
expect(onError).not.toHaveBeenCalled();
expect(store.has(OPT_IN)).toBe(false);
});
it('stops a load sync waiting for its confirmation, without verifying', async () => {
store.set(OPT_IN, true);
const nav = makeNav();
withBrowser(nav);
server((route) =>
route === '/push/web/subscriptions'
? { status: 'pending' }
: { device: { uid: 'd1' } },
);
const { module: loading } = makeModule();
await loading.onPush('chat', () => {}, { onError: () => {} });
await vi.waitFor(() =>
expect(routes(loading)).toContain('POST /push/web/subscriptions'),
);
const { module: other } = makeModule();
await other.disable();
nav.post({ type: 'puter-push-challenge', challenge: 'c.sig' });
await flush();
await flush();
expect(routes(loading)).not.toContain(
'POST /push/web/subscriptions/verify',
);
});
it('makes an enable() in flight reject', async () => {
withBrowser(makeNav({ existing: makeSubscription() }));
let announce;
mockRequest.mockImplementation(async (_p, method, route) => {
if (route === '/push/web/key') return { publicKey: KEY };
if (method === 'POST' && route === '/push/web/subscriptions') {
return await new Promise((resolve) => {
announce = () => resolve({ status: 'pending' });
});
}
return { removed: true };
});
const { module: enablingTab } = makeModule();
const enabling = enablingTab.enable();
await vi.waitFor(() => expect(announce).toBeDefined());
const { module: other } = makeModule();
const disabling = other.disable();
await flush();
announce();
await expect(enabling).rejects.toMatchObject({
code: 'push_verification_canceled',
});
await expect(disabling).resolves.toBe(true);
expect(store.has(OPT_IN)).toBe(false);
});
});
describe('sign-out', () => {
it('removes the subscription with the leaving user`s token and forgets their push state', async () => {
store.set(OPT_IN, true);
@@ -1656,7 +1985,7 @@ describe('sign-out', () => {
'DELETE',
'/push/web/subscriptions',
{ endpoint: subscription.endpoint },
{ authToken: 'leaving-token' },
{ authToken: 'leaving-token', interactiveReauth: false },
);
expect(subscription.unsubscribe).toHaveBeenCalled();
expect(store.has(OPT_IN)).toBe(false);
@@ -25,6 +25,13 @@
window.PUTER_API_ORIGIN = 'http://api.puter.localhost:4100';
</script>
<script src="/dist/puter.dev.js"></script>
<script>
// With ?push, a user known from an earlier load keeps push current.
if (new URLSearchParams(location.search).has('push')) {
puter.whoami = { uuid: 'boot-reauth-user' };
puter.notifications.onPush('boot', () => {}, { onError: () => {} });
}
</script>
<script>
const statusEl = document.getElementById('status');
@@ -64,6 +64,104 @@ test.describe('a stale token does not raise sign-in UI at load', () => {
});
}
test.describe('with push support', () => {
// Playwright's WebKit has no PushManager or Notification.
test.skip(
({ browserName }) => browserName !== 'chromium',
'Needs the Push API, which only Chromium has here',
);
test('no popup or dialog on load from a page that keeps push current', async ({
playwright,
}) => {
// Headless shell keeps notifications denied, so use full Chromium.
const browser = await playwright.chromium.launch({
channel: 'chromium',
});
try {
const context = await browser.newContext();
const page = await context.newPage();
await keepsPushCurrent(page, context);
} finally {
await browser.close();
}
});
});
async function keepsPushCurrent(page, context) {
const popups = [];
page.on('popup', (p) => popups.push(p.url()));
const fixtureOrigin = new URL(FIXTURE).origin;
await context.grantPermissions(['notifications'], {
origin: fixtureOrigin,
});
// The opt-in the fixture's user recorded when they turned push on.
await page.addInitScript(() => {
const open = indexedDB.open('puter-push', 1);
open.onupgradeneeded = () => {
open.result.createObjectStore('handlers');
open.result.createObjectStore('clicks');
};
open.onsuccess = () => {
const tx = open.result.transaction('handlers', 'readwrite');
tx.objectStore('handlers').put(
true,
'puter:opt-in:boot-reauth-user',
);
tx.oncomplete = () => open.result.close();
};
});
// The push routes answer the stale token as Puter's API does.
let keyRequests = 0;
const cors = {
'Access-Control-Allow-Origin': fixtureOrigin,
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Headers': '*',
'Access-Control-Allow-Methods': 'GET, POST, DELETE',
};
await page.route('**/push/web/**', (route) => {
if (route.request().method() === 'OPTIONS')
return route.fulfill({ status: 204, headers: cors });
keyRequests++;
return route.fulfill({
status: 401,
headers: cors,
contentType: 'application/json',
body: JSON.stringify({
code: 'token_auth_failed',
message: 'Authentication failed',
}),
});
});
// Boot's own 401s come later, so the sync starts while signed in.
await page.route(/\/(whoami|rao)(\?|$)/, async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
await route.continue();
});
await seedStaleToken(page, 'puter.auth.token.v2');
await page.goto(`${FIXTURE}?push`);
await page.locator('body.ready').waitFor({ timeout: 60_000 });
const canPush = await page.evaluate(
() =>
'PushManager' in window &&
globalThis.Notification?.permission === 'granted',
);
test.skip(
!canPush,
'This browser has no Push API or no notification permission, so onPush() has no sync to run',
);
await page.waitForFunction(() => !window.puter?.authToken, null, {
timeout: 30_000,
});
await page.waitForTimeout(2000);
expect(keyRequests).toBeGreaterThan(0);
expect(popups).toEqual([]);
await expect(page.locator('puter-dialog')).toHaveCount(0);
}
test('a call the user makes still prompts', async ({ page }) => {
const popupPromise = page.waitForEvent('popup', { timeout: 30_000 });