mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-05 21:46:51 +00:00
A notification tap's ready callback sends the click only to a window of the tapped app, and a private-app redirect launches its fallback without it. The worker waits at most 600 ms from the click, lookup included, before opening a window, and the page acknowledges only a well-formed tap.
267 lines
10 KiB
JavaScript
267 lines
10 KiB
JavaScript
/*
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
pushMessageFor,
|
|
pushWorkerEnabled,
|
|
shouldShowLocalBanner,
|
|
showLocalBanner,
|
|
} from './pushWorker.js';
|
|
|
|
afterEach(() => {
|
|
vi.resetModules();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const share = {
|
|
type: 'share.received',
|
|
title: 'alice shared a file with you',
|
|
icon: 'shared.svg',
|
|
fields: { target: { path: '/alice/Shared/a.txt', name: 'a.txt' } },
|
|
};
|
|
|
|
describe('shouldShowLocalBanner', () => {
|
|
const live = { notification: share, visibilityState: 'hidden', permission: 'granted' };
|
|
|
|
it('shows one for a live arrival in a hidden tab with permission', () => {
|
|
expect(shouldShowLocalBanner(live)).toBe(true);
|
|
});
|
|
|
|
it('shows none while the tab is visible', () => {
|
|
expect(shouldShowLocalBanner({ ...live, visibilityState: 'visible' })).toBe(false);
|
|
});
|
|
|
|
it('shows none for a replay, an acknowledged or a silent one', () => {
|
|
expect(shouldShowLocalBanner({ ...live, replay: true })).toBe(false);
|
|
expect(shouldShowLocalBanner({ ...live, acknowledged: true })).toBe(false);
|
|
expect(shouldShowLocalBanner({ ...live, notification: { ...share, silent: true } })).toBe(false);
|
|
});
|
|
|
|
it('shows none without permission granted', () => {
|
|
expect(shouldShowLocalBanner({ ...live, permission: 'default' })).toBe(false);
|
|
expect(shouldShowLocalBanner({ ...live, permission: 'denied' })).toBe(false);
|
|
expect(shouldShowLocalBanner({ ...live, permission: undefined })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('pushMessageFor', () => {
|
|
it('targets the app and path an app message names, with its data and https icon', () => {
|
|
expect(pushMessageFor('n1', {
|
|
type: 'app.message',
|
|
title: 'New reply',
|
|
text: 'see you',
|
|
icon: 'https://example.com/i.png',
|
|
target: '/rooms/7',
|
|
data: { room: 7 },
|
|
group: 'room-7',
|
|
app: { uid: 'app-1', name: 'Chat' },
|
|
})).toEqual({
|
|
uid: 'n1',
|
|
group: 'room-7',
|
|
title: 'New reply',
|
|
body: 'see you',
|
|
icon: 'https://example.com/i.png',
|
|
target: { app: 'app-1', path: '/rooms/7' },
|
|
data: { room: 7 },
|
|
});
|
|
});
|
|
|
|
it('shows only the app name for a sensitive app message', () => {
|
|
expect(pushMessageFor('n1', {
|
|
type: 'app.message',
|
|
title: 'Code 1234',
|
|
text: 'secret',
|
|
sensitive: true,
|
|
app: { uid: 'app-1', name: 'Bank' },
|
|
})).toEqual({ uid: 'n1', group: 'n1', title: 'Bank', target: { app: 'app-1' } });
|
|
});
|
|
|
|
it('points a share at the item it names and drops a non-https icon', () => {
|
|
expect(pushMessageFor('n2', share)).toEqual({
|
|
uid: 'n2',
|
|
group: 'n2',
|
|
title: 'alice shared a file with you',
|
|
target: { path: '/alice/Shared/a.txt' },
|
|
});
|
|
});
|
|
|
|
it("uses the row's app uid over the one the message names", () => {
|
|
expect(pushMessageFor('n1', { type: 'app.message', title: 't' }, 'app-9').target).toEqual({ app: 'app-9' });
|
|
expect(pushMessageFor('n1', { type: 'app.message', title: 't', app: { uid: 'app-b' } }, 'app-a').target)
|
|
.toEqual({ app: 'app-a' });
|
|
expect(pushMessageFor('n1', { type: 'app.message', title: 't', app: { uid: 'app-b' } }, '').target)
|
|
.toEqual({ app: 'app-b' });
|
|
});
|
|
});
|
|
|
|
describe('showLocalBanner', () => {
|
|
const env = ({
|
|
visibilityState = 'hidden',
|
|
permission = 'granted',
|
|
active = { postMessage: vi.fn() },
|
|
pushEnabled = true,
|
|
} = {}) => ({
|
|
params: { pushEnabled },
|
|
doc: { visibilityState },
|
|
NotificationApi: { permission },
|
|
nav: {
|
|
serviceWorker: {
|
|
register: vi.fn(),
|
|
getRegistration: vi.fn(async () => (active ? { active } : undefined)),
|
|
},
|
|
},
|
|
active,
|
|
});
|
|
|
|
it('hands one notification to the worker, tagged by its uid', async () => {
|
|
const e = env();
|
|
expect(await showLocalBanner({ uid: 'n1', notification: share }, {}, e)).toBe(true);
|
|
expect(e.nav.serviceWorker.getRegistration).toHaveBeenCalledWith('/');
|
|
expect(e.active.postMessage).toHaveBeenCalledOnce();
|
|
expect(e.active.postMessage).toHaveBeenCalledWith({
|
|
type: 'puter-show-notification',
|
|
puter: pushMessageFor('n1', share),
|
|
});
|
|
});
|
|
|
|
it('hands nothing over while visible, for a replay, or without a worker', async () => {
|
|
const visible = env({ visibilityState: 'visible' });
|
|
expect(await showLocalBanner({ uid: 'n1', notification: share }, {}, visible)).toBe(false);
|
|
expect(visible.nav.serviceWorker.getRegistration).not.toHaveBeenCalled();
|
|
|
|
const replay = env();
|
|
expect(await showLocalBanner({ uid: 'n1', notification: share }, { replay: true }, replay)).toBe(false);
|
|
expect(replay.active.postMessage).not.toHaveBeenCalled();
|
|
|
|
expect(await showLocalBanner({ uid: 'n1', notification: share }, {}, env({ active: null }))).toBe(false);
|
|
});
|
|
|
|
it('hands nothing over where push is off for the deployment', async () => {
|
|
const off = env({ pushEnabled: false });
|
|
expect(await showLocalBanner({ uid: 'n1', notification: share }, {}, off)).toBe(false);
|
|
expect(off.nav.serviceWorker.getRegistration).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('pushWorkerEnabled', () => {
|
|
it('needs the deployment flag and a browser with service workers', () => {
|
|
const nav = { serviceWorker: { register: () => {} } };
|
|
expect(pushWorkerEnabled({ pushEnabled: true }, nav)).toBe(true);
|
|
expect(pushWorkerEnabled({ pushEnabled: false }, nav)).toBe(false);
|
|
expect(pushWorkerEnabled({}, nav)).toBe(false);
|
|
expect(pushWorkerEnabled({ pushEnabled: true }, {})).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('startPushWorker', () => {
|
|
const UID = '0b6a2c1e-6f1d-4a57-9c1e-3d2f8a9b7c10';
|
|
|
|
const makeNav = (registrations = []) => {
|
|
const listeners = {};
|
|
return {
|
|
listeners,
|
|
serviceWorker: {
|
|
register: vi.fn(async () => ({})),
|
|
getRegistrations: vi.fn(async () => registrations),
|
|
addEventListener: vi.fn((type, fn) => {
|
|
listeners[type] = fn;
|
|
}),
|
|
},
|
|
};
|
|
};
|
|
|
|
const load = async () => {
|
|
const worker = await import('./pushWorker.js');
|
|
const tap = await import('./notificationTap.js');
|
|
return { ...worker, ...tap };
|
|
};
|
|
|
|
const start = (mod, over) => mod.startPushWorker({
|
|
params: { pushEnabled: true },
|
|
query: new URLSearchParams(),
|
|
clearParam: vi.fn(),
|
|
framed: false,
|
|
...over,
|
|
});
|
|
|
|
it('registers /sw.js at the root scope, acks its taps and routes them', async () => {
|
|
const mod = await load();
|
|
const handler = vi.fn();
|
|
mod.setPushTapHandler(handler);
|
|
const nav = makeNav();
|
|
await start(mod, { nav });
|
|
expect(nav.serviceWorker.register).toHaveBeenCalledWith('/sw.js', { scope: '/' });
|
|
|
|
const source = { postMessage: vi.fn() };
|
|
nav.listeners.message({ data: { type: 'other', notification: { uid: 'x' } }, source });
|
|
nav.listeners.message({ data: { type: 'puter-notification-click', id: 'click-0', notification: { uid: '' } }, source });
|
|
nav.listeners.message({
|
|
data: { type: 'puter-notification-click', id: 'click-1', notification: { uid: 'n1', target: { app: 'a' } } },
|
|
source,
|
|
});
|
|
expect(source.postMessage.mock.calls).toEqual([[{ type: 'puter-notification-click-ack', id: 'click-1' }]]);
|
|
expect(handler.mock.calls).toEqual([[{ uid: 'n1', target: { app: 'a' } }]]);
|
|
});
|
|
|
|
it('registers nothing inside a frame', async () => {
|
|
const nav = makeNav();
|
|
await start(await load(), { nav, framed: true });
|
|
expect(nav.serviceWorker.register).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('unregisters an existing /sw.js where push is off, and registers nothing', async () => {
|
|
const puter = { active: { scriptURL: 'https://puter.test/sw.js' }, unregister: vi.fn(async () => true) };
|
|
const other = { active: { scriptURL: 'https://puter.test/other.js' }, unregister: vi.fn() };
|
|
const nav = makeNav([puter, other]);
|
|
await start(await load(), { nav, params: { pushEnabled: false } });
|
|
expect(nav.serviceWorker.register).not.toHaveBeenCalled();
|
|
expect(puter.unregister).toHaveBeenCalledOnce();
|
|
expect(other.unregister).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('hands over the notification uid a page was opened for and clears it', async () => {
|
|
const mod = await load();
|
|
const handler = vi.fn();
|
|
mod.setPushTapHandler(handler);
|
|
const clearParam = vi.fn();
|
|
await start(mod, {
|
|
nav: makeNav(),
|
|
query: new URLSearchParams({ 'puter.notification': UID }),
|
|
clearParam,
|
|
});
|
|
expect(clearParam).toHaveBeenCalledWith('puter.notification');
|
|
expect(handler).toHaveBeenCalledWith({ uid: UID, stored: true });
|
|
});
|
|
|
|
it('clears a malformed parameter and hands nothing over', async () => {
|
|
const mod = await load();
|
|
const handler = vi.fn();
|
|
mod.setPushTapHandler(handler);
|
|
const clearParam = vi.fn();
|
|
await start(mod, {
|
|
nav: makeNav(),
|
|
query: new URLSearchParams({ 'puter.notification': JSON.stringify({ uid: UID }) }),
|
|
clearParam,
|
|
});
|
|
expect(clearParam).toHaveBeenCalledWith('puter.notification');
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
});
|