@spooky-sync/core 0.0.1-canary.280 → 0.0.1-canary.281
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -0
- package/dist/index.d.ts +7 -329
- package/dist/index.js +748 -39
- package/dist/live.d.ts +141 -0
- package/dist/live.js +709 -0
- package/dist/pure.d.ts +322 -0
- package/dist/pure.js +24 -0
- package/dist/sw.d.ts +207 -0
- package/dist/sw.js +393 -0
- package/dist/types.d.ts +742 -3
- package/dist/worker-edges.js +415 -0
- package/dist/worker-impersonation.js +60 -0
- package/dist/worker-ref-tables.d.ts +46 -0
- package/dist/worker-types.d.ts +112 -0
- package/dist/worker-types.js +73 -0
- package/package.json +16 -4
- package/scripts/check-worker-entries.mjs +172 -0
- package/src/client/services.ts +10 -38
- package/src/client/sp00ky-client.ts +13 -0
- package/src/index.ts +32 -0
- package/src/live/index.test.ts +478 -0
- package/src/live/index.ts +1013 -0
- package/src/modules/auth/index.ts +52 -0
- package/src/modules/web-push/index.test.ts +797 -0
- package/src/modules/web-push/index.ts +997 -0
- package/src/pure.ts +65 -0
- package/src/push/types.ts +174 -0
- package/src/sw/index.test.ts +582 -0
- package/src/sw/index.ts +607 -0
- package/src/sw/store.ts +96 -0
- package/src/testing/fake-services.ts +1 -1
- package/src/types.ts +11 -0
- package/src/utils/edges.ts +39 -0
- package/src/utils/sha256.ts +15 -0
- package/tsdown.config.ts +31 -2
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
installPushHandlers,
|
|
4
|
+
getBridgedToken,
|
|
5
|
+
memoryStore,
|
|
6
|
+
notificationOptions,
|
|
7
|
+
DEFAULT_PLACEHOLDER,
|
|
8
|
+
type SwScope,
|
|
9
|
+
} from './index';
|
|
10
|
+
import type { PushPayload } from '../push/types';
|
|
11
|
+
import { base64UrlToBytes, bytesToBase64Url } from '../push/types';
|
|
12
|
+
|
|
13
|
+
function jwt(claims: Record<string, unknown>): string {
|
|
14
|
+
const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
|
15
|
+
return `${b64({ alg: 'HS512' })}.${b64(claims)}.sig`;
|
|
16
|
+
}
|
|
17
|
+
const token = jwt({ ID: 'user:alice', exp: Math.floor(Date.now() / 1000) + 3600 });
|
|
18
|
+
const KEY = bytesToBase64Url(new Uint8Array([4, 1, 2, 3, 4, 5]));
|
|
19
|
+
|
|
20
|
+
interface FakeWindow {
|
|
21
|
+
url: string;
|
|
22
|
+
visibilityState: string;
|
|
23
|
+
focused?: boolean;
|
|
24
|
+
focus: ReturnType<typeof vi.fn>;
|
|
25
|
+
navigate: ReturnType<typeof vi.fn>;
|
|
26
|
+
postMessage: ReturnType<typeof vi.fn>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function win(url: string, visibilityState = 'hidden', navigateFails = false): FakeWindow {
|
|
30
|
+
return {
|
|
31
|
+
url,
|
|
32
|
+
visibilityState,
|
|
33
|
+
focus: vi.fn(async () => undefined),
|
|
34
|
+
navigate: vi.fn(async () => {
|
|
35
|
+
if (navigateFails) throw new TypeError('not controlled');
|
|
36
|
+
}),
|
|
37
|
+
postMessage: vi.fn(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fakeScope() {
|
|
42
|
+
const listeners = new Map<string, (e: any) => void>();
|
|
43
|
+
const shown: Array<{ title: string; options: any; closed: boolean }> = [];
|
|
44
|
+
const windows: FakeWindow[] = [];
|
|
45
|
+
const subscribe = vi.fn(async (_o: unknown) => ({
|
|
46
|
+
endpoint: 'https://push.example/new',
|
|
47
|
+
toJSON: () => ({ endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } }),
|
|
48
|
+
}));
|
|
49
|
+
const scope = {
|
|
50
|
+
registration: {
|
|
51
|
+
scope: 'https://app.example/',
|
|
52
|
+
showNotification: vi.fn(async (title: string, options: any = {}) => {
|
|
53
|
+
for (const n of shown)
|
|
54
|
+
if (!n.closed && options.tag && n.options.tag === options.tag) n.closed = true;
|
|
55
|
+
shown.push({ title, options, closed: false });
|
|
56
|
+
}),
|
|
57
|
+
getNotifications: async () =>
|
|
58
|
+
shown
|
|
59
|
+
.filter((n) => !n.closed)
|
|
60
|
+
.map((n) => ({
|
|
61
|
+
title: n.title,
|
|
62
|
+
tag: n.options.tag ?? '',
|
|
63
|
+
body: n.options.body,
|
|
64
|
+
data: n.options.data,
|
|
65
|
+
close: () => void (n.closed = true),
|
|
66
|
+
})),
|
|
67
|
+
pushManager: { getSubscription: async () => null, subscribe },
|
|
68
|
+
},
|
|
69
|
+
clients: { matchAll: vi.fn(async () => windows), openWindow: vi.fn(async () => null) },
|
|
70
|
+
location: { origin: 'https://app.example' },
|
|
71
|
+
navigator: { userAgent: 'sw-ua' },
|
|
72
|
+
addEventListener: (type: string, l: (e: any) => void) => void listeners.set(type, l),
|
|
73
|
+
removeEventListener: (type: string) => void listeners.delete(type),
|
|
74
|
+
};
|
|
75
|
+
const dispatch = async (type: string, event: Record<string, unknown> = {}) => {
|
|
76
|
+
let work: Promise<unknown> | undefined;
|
|
77
|
+
listeners.get(type)!({ ...event, waitUntil: (p: Promise<unknown>) => void (work = p) });
|
|
78
|
+
await work;
|
|
79
|
+
};
|
|
80
|
+
const open = () => shown.filter((n) => !n.closed);
|
|
81
|
+
return {
|
|
82
|
+
scope: scope as unknown as SwScope,
|
|
83
|
+
raw: scope,
|
|
84
|
+
shown,
|
|
85
|
+
open,
|
|
86
|
+
windows,
|
|
87
|
+
dispatch,
|
|
88
|
+
listeners,
|
|
89
|
+
subscribe,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const pushEvent = (payload: unknown) => ({
|
|
94
|
+
data: { json: () => payload, text: () => JSON.stringify(payload) },
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const content: PushPayload = {
|
|
98
|
+
v: 1,
|
|
99
|
+
kind: 'rule',
|
|
100
|
+
rule: 'new-message',
|
|
101
|
+
table: 'message',
|
|
102
|
+
id: 'message:1',
|
|
103
|
+
op: 'create',
|
|
104
|
+
topic: 'dm:1',
|
|
105
|
+
notification: {
|
|
106
|
+
title: 'Ada',
|
|
107
|
+
body: 'hi there',
|
|
108
|
+
url: '/m/1',
|
|
109
|
+
icon: '/i.png',
|
|
110
|
+
vibrate: [100, 50],
|
|
111
|
+
actions: [{ action: 'reply', title: 'Reply', url: '/m/1/reply' }],
|
|
112
|
+
data: { k: 1 },
|
|
113
|
+
},
|
|
114
|
+
data: { conversation: 'conversation:1' },
|
|
115
|
+
ts: 1,
|
|
116
|
+
};
|
|
117
|
+
const nudge: PushPayload = {
|
|
118
|
+
v: 1,
|
|
119
|
+
kind: 'rule',
|
|
120
|
+
rule: 'feed',
|
|
121
|
+
table: 'notification',
|
|
122
|
+
id: 'notification:9',
|
|
123
|
+
op: 'create',
|
|
124
|
+
ts: 1,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
function fakeFeed(rows: Record<string, any>[], opts: { fail?: boolean } = {}) {
|
|
128
|
+
const feed = {
|
|
129
|
+
closed: false,
|
|
130
|
+
subscribe: vi.fn((_q: unknown, h: { onSet(r: unknown[]): void }) => {
|
|
131
|
+
if (!opts.fail) h.onSet(rows);
|
|
132
|
+
return {
|
|
133
|
+
ready: opts.fail ? Promise.reject(new Error('register failed')) : Promise.resolve(rows),
|
|
134
|
+
rows: () => rows,
|
|
135
|
+
key: 'k',
|
|
136
|
+
unsubscribe: async () => {},
|
|
137
|
+
};
|
|
138
|
+
}),
|
|
139
|
+
idle: vi.fn(async () => true),
|
|
140
|
+
close: vi.fn(async () => void (feed.closed = true)),
|
|
141
|
+
query: vi.fn(async (sql: string, _vars?: unknown) =>
|
|
142
|
+
sql.includes('fn::push::list')
|
|
143
|
+
? [[{ endpoint: 'https://push.example/old', label: 'Laptop', rules: ['dm'] }]]
|
|
144
|
+
: ['ok']
|
|
145
|
+
),
|
|
146
|
+
connect: async () => {},
|
|
147
|
+
userId: 'user:alice',
|
|
148
|
+
listRefTable: '_00_list_ref_user_alice',
|
|
149
|
+
};
|
|
150
|
+
return feed;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe('installPushHandlers: push', () => {
|
|
154
|
+
it('shows a content push with every option, tag defaulting to the topic, url and payload in data', async () => {
|
|
155
|
+
const f = fakeScope();
|
|
156
|
+
installPushHandlers({ scope: f.scope, store: memoryStore() });
|
|
157
|
+
await f.dispatch('push', pushEvent(content));
|
|
158
|
+
expect(f.raw.registration.showNotification).toHaveBeenCalledTimes(1);
|
|
159
|
+
const [title, options] = f.raw.registration.showNotification.mock.calls[0];
|
|
160
|
+
expect(title).toBe('Ada');
|
|
161
|
+
expect(options).toEqual({
|
|
162
|
+
body: 'hi there',
|
|
163
|
+
icon: '/i.png',
|
|
164
|
+
vibrate: [100, 50],
|
|
165
|
+
tag: 'dm:1',
|
|
166
|
+
actions: [{ action: 'reply', title: 'Reply' }],
|
|
167
|
+
data: { k: 1, url: '/m/1', payload: content, actionUrls: { reply: '/m/1/reply' } },
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('posts to a visible window instead of showing; shows anyway with suppressWhenVisible: false', async () => {
|
|
172
|
+
const f = fakeScope();
|
|
173
|
+
const visible = win('https://app.example/inbox', 'visible');
|
|
174
|
+
const foreign = win('https://evil.example/', 'visible');
|
|
175
|
+
f.windows.push(visible, foreign, win('https://app.example/other'));
|
|
176
|
+
installPushHandlers({ scope: f.scope, store: memoryStore() });
|
|
177
|
+
await f.dispatch('push', pushEvent(content));
|
|
178
|
+
expect(visible.postMessage).toHaveBeenCalledWith({ type: 'sp00ky:push', payload: content });
|
|
179
|
+
expect(foreign.postMessage).not.toHaveBeenCalled();
|
|
180
|
+
expect(f.raw.registration.showNotification).not.toHaveBeenCalled();
|
|
181
|
+
|
|
182
|
+
const g = fakeScope();
|
|
183
|
+
g.windows.push(win('https://app.example/inbox', 'visible'));
|
|
184
|
+
installPushHandlers({ scope: g.scope, store: memoryStore(), suppressWhenVisible: false });
|
|
185
|
+
await g.dispatch('push', pushEvent(content));
|
|
186
|
+
expect(g.raw.registration.showNotification).toHaveBeenCalledTimes(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('shows the placeholder for a nudge nobody renders, an unknown version and garbage', async () => {
|
|
190
|
+
const f = fakeScope();
|
|
191
|
+
installPushHandlers({
|
|
192
|
+
scope: f.scope,
|
|
193
|
+
store: memoryStore(),
|
|
194
|
+
placeholder: { title: 'WhitePawn', body: 'New activity' },
|
|
195
|
+
});
|
|
196
|
+
await f.dispatch('push', pushEvent(nudge));
|
|
197
|
+
await f.dispatch('push', pushEvent({ ...content, v: 2 }));
|
|
198
|
+
await f.dispatch('push', { data: { json: () => JSON.parse('{') } });
|
|
199
|
+
await f.dispatch('push', { data: null });
|
|
200
|
+
expect(f.raw.registration.showNotification).toHaveBeenCalledTimes(4);
|
|
201
|
+
for (const [title, options] of f.raw.registration.showNotification.mock.calls) {
|
|
202
|
+
expect(title).toBe('WhitePawn');
|
|
203
|
+
expect(options.tag).toBe('sp00ky-placeholder');
|
|
204
|
+
}
|
|
205
|
+
const h = fakeScope();
|
|
206
|
+
installPushHandlers({ scope: h.scope, store: memoryStore() });
|
|
207
|
+
await h.dispatch('push', pushEvent(nudge));
|
|
208
|
+
expect(h.raw.registration.showNotification.mock.calls[0][0]).toBe(DEFAULT_PLACEHOLDER.title);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('onPush can take over; onNudge and render handle nudges; a throwing handler falls back to the placeholder', async () => {
|
|
212
|
+
const f = fakeScope();
|
|
213
|
+
const onPush = vi.fn(async () => true);
|
|
214
|
+
installPushHandlers({ scope: f.scope, store: memoryStore(), onPush });
|
|
215
|
+
await f.dispatch('push', pushEvent(content));
|
|
216
|
+
expect(onPush).toHaveBeenCalledWith(
|
|
217
|
+
content,
|
|
218
|
+
expect.objectContaining({ registration: f.raw.registration })
|
|
219
|
+
);
|
|
220
|
+
expect(f.raw.registration.showNotification).not.toHaveBeenCalled();
|
|
221
|
+
|
|
222
|
+
const g = fakeScope();
|
|
223
|
+
installPushHandlers({
|
|
224
|
+
scope: g.scope,
|
|
225
|
+
store: memoryStore(),
|
|
226
|
+
onNudge: async (p, ctx) => ctx.show({ title: `nudge ${p.rule}`, tag: 'n' }),
|
|
227
|
+
});
|
|
228
|
+
await g.dispatch('push', pushEvent(nudge));
|
|
229
|
+
expect(g.raw.registration.showNotification.mock.calls[0][0]).toBe('nudge feed');
|
|
230
|
+
|
|
231
|
+
const h = fakeScope();
|
|
232
|
+
installPushHandlers({
|
|
233
|
+
scope: h.scope,
|
|
234
|
+
store: memoryStore(),
|
|
235
|
+
render: (p) => (p.notification ? null : { title: `rendered ${p.id}` }),
|
|
236
|
+
});
|
|
237
|
+
await h.dispatch('push', pushEvent(nudge));
|
|
238
|
+
await h.dispatch('push', pushEvent(content));
|
|
239
|
+
expect(h.raw.registration.showNotification.mock.calls.map((c) => c[0])).toEqual([
|
|
240
|
+
'rendered notification:9',
|
|
241
|
+
'Ada',
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
const k = fakeScope();
|
|
245
|
+
installPushHandlers({
|
|
246
|
+
scope: k.scope,
|
|
247
|
+
store: memoryStore(),
|
|
248
|
+
onNudge: async () => {
|
|
249
|
+
throw new Error('boom');
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
await k.dispatch('push', pushEvent(nudge));
|
|
253
|
+
expect(k.raw.registration.showNotification.mock.calls[0][1].tag).toBe('sp00ky-placeholder');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('renders a nudge from live data: one notification per visible row, stale tags closed, feed closed', async () => {
|
|
257
|
+
const f = fakeScope();
|
|
258
|
+
const store = memoryStore();
|
|
259
|
+
await store.set('token', {
|
|
260
|
+
token,
|
|
261
|
+
userId: 'user:alice',
|
|
262
|
+
endpoint: 'wss://db/rpc',
|
|
263
|
+
namespace: 'n',
|
|
264
|
+
database: 'd',
|
|
265
|
+
at: 1,
|
|
266
|
+
});
|
|
267
|
+
// A notification the live renderer showed earlier for a row that is now read.
|
|
268
|
+
await f.raw.registration.showNotification('old', { tag: 'n:old', data: { sp00kyLive: true } });
|
|
269
|
+
await f.raw.registration.showNotification('unrelated', { tag: 'other' });
|
|
270
|
+
const rows = [
|
|
271
|
+
{ id: 'notification:1', title: 'A', read: false },
|
|
272
|
+
{ id: 'notification:2', title: 'B', read: true },
|
|
273
|
+
{ id: 'notification:3', title: 'C', read: false },
|
|
274
|
+
];
|
|
275
|
+
const feed = fakeFeed(rows);
|
|
276
|
+
const factory = vi.fn(() => feed);
|
|
277
|
+
const query = vi.fn((userId: string) => ({
|
|
278
|
+
surql: 'SELECT * FROM notification WHERE user = $u',
|
|
279
|
+
params: { u: userId },
|
|
280
|
+
}));
|
|
281
|
+
installPushHandlers({
|
|
282
|
+
scope: f.scope,
|
|
283
|
+
store,
|
|
284
|
+
live: {
|
|
285
|
+
feed: factory as never,
|
|
286
|
+
query,
|
|
287
|
+
visible: (r) => !r.read,
|
|
288
|
+
render: (r) => ({
|
|
289
|
+
title: r.title,
|
|
290
|
+
body: String(r.id),
|
|
291
|
+
tag: `n:${r.id}`,
|
|
292
|
+
url: `/n/${r.id}`,
|
|
293
|
+
}),
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
await f.dispatch('push', pushEvent(nudge));
|
|
297
|
+
expect(factory).toHaveBeenCalledWith(
|
|
298
|
+
expect.objectContaining({ token, userId: 'user:alice', endpoint: 'wss://db/rpc' })
|
|
299
|
+
);
|
|
300
|
+
expect(query).toHaveBeenCalledWith('user:alice', nudge);
|
|
301
|
+
expect(feed.close).toHaveBeenCalled();
|
|
302
|
+
expect(
|
|
303
|
+
f
|
|
304
|
+
.open()
|
|
305
|
+
.map((n) => n.options.tag)
|
|
306
|
+
.sort()
|
|
307
|
+
).toEqual(['n:notification:1', 'n:notification:3', 'other']);
|
|
308
|
+
const shownLive = f.open().find((n) => n.options.tag === 'n:notification:1')!;
|
|
309
|
+
expect(shownLive.options.data).toEqual({ url: '/n/notification:1', sp00kyLive: true });
|
|
310
|
+
|
|
311
|
+
// Nothing visible any more: the stale one closes, and closing was the
|
|
312
|
+
// push's effect, so nothing else is shown.
|
|
313
|
+
const g = fakeScope();
|
|
314
|
+
await g.raw.registration.showNotification('old', { tag: 'n:old', data: { sp00kyLive: true } });
|
|
315
|
+
installPushHandlers({
|
|
316
|
+
scope: g.scope,
|
|
317
|
+
store,
|
|
318
|
+
live: {
|
|
319
|
+
feed: () => fakeFeed([]) as never,
|
|
320
|
+
query,
|
|
321
|
+
visible: () => true,
|
|
322
|
+
render: (r: any) => ({ title: r.title, tag: 'x' }),
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
await g.dispatch('push', pushEvent(nudge));
|
|
326
|
+
expect(g.open()).toEqual([]);
|
|
327
|
+
|
|
328
|
+
// `silentClose: false` keeps a notification on screen for every push.
|
|
329
|
+
const h = fakeScope();
|
|
330
|
+
await h.raw.registration.showNotification('old', { tag: 'n:old', data: { sp00kyLive: true } });
|
|
331
|
+
installPushHandlers({
|
|
332
|
+
scope: h.scope,
|
|
333
|
+
store,
|
|
334
|
+
live: {
|
|
335
|
+
feed: () => fakeFeed([]) as never,
|
|
336
|
+
query,
|
|
337
|
+
visible: () => true,
|
|
338
|
+
render: (r: any) => ({ title: r.title, tag: 'x' }),
|
|
339
|
+
silentClose: false,
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
await h.dispatch('push', pushEvent(nudge));
|
|
343
|
+
expect(h.open().map((n) => n.options.tag)).toEqual(['sp00ky-placeholder']);
|
|
344
|
+
|
|
345
|
+
// Nothing to show and nothing closed: the placeholder.
|
|
346
|
+
const k = fakeScope();
|
|
347
|
+
installPushHandlers({
|
|
348
|
+
scope: k.scope,
|
|
349
|
+
store,
|
|
350
|
+
live: {
|
|
351
|
+
feed: () => fakeFeed([]) as never,
|
|
352
|
+
query,
|
|
353
|
+
visible: () => true,
|
|
354
|
+
render: (r: any) => ({ title: r.title, tag: 'x' }),
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
await k.dispatch('push', pushEvent(nudge));
|
|
358
|
+
expect(k.open().map((n) => n.options.tag)).toEqual(['sp00ky-placeholder']);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('falls back to the placeholder when the feed fails or no session was bridged', async () => {
|
|
362
|
+
const store = memoryStore();
|
|
363
|
+
const f = fakeScope();
|
|
364
|
+
const live = {
|
|
365
|
+
feed: () => fakeFeed([], { fail: true }) as never,
|
|
366
|
+
query: () => ({ surql: 'SELECT * FROM x' }),
|
|
367
|
+
visible: () => true,
|
|
368
|
+
render: () => ({ title: 't', tag: 't' }),
|
|
369
|
+
};
|
|
370
|
+
installPushHandlers({ scope: f.scope, store, live });
|
|
371
|
+
await f.dispatch('push', pushEvent(nudge));
|
|
372
|
+
expect(f.open().map((n) => n.options.tag)).toEqual(['sp00ky-placeholder']);
|
|
373
|
+
await store.set('token', {
|
|
374
|
+
token,
|
|
375
|
+
userId: 'user:alice',
|
|
376
|
+
endpoint: 'wss://db/rpc',
|
|
377
|
+
namespace: 'n',
|
|
378
|
+
database: 'd',
|
|
379
|
+
at: 1,
|
|
380
|
+
});
|
|
381
|
+
const g = fakeScope();
|
|
382
|
+
installPushHandlers({ scope: g.scope, store, live });
|
|
383
|
+
await g.dispatch('push', pushEvent(nudge));
|
|
384
|
+
expect(g.open().map((n) => n.options.tag)).toEqual(['sp00ky-placeholder']);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe('installPushHandlers: clicks', () => {
|
|
389
|
+
const clickEvent = (data: unknown, action = '') => {
|
|
390
|
+
const close = vi.fn();
|
|
391
|
+
return { event: { notification: { title: 't', tag: 't', data, close }, action }, close };
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
it('opens the url when no window is open', async () => {
|
|
395
|
+
const f = fakeScope();
|
|
396
|
+
installPushHandlers({ scope: f.scope, store: memoryStore() });
|
|
397
|
+
const { event, close } = clickEvent({ url: '/m/1', payload: content });
|
|
398
|
+
await f.dispatch('notificationclick', event);
|
|
399
|
+
expect(close).toHaveBeenCalled();
|
|
400
|
+
expect(f.raw.clients.openWindow).toHaveBeenCalledWith('https://app.example/m/1');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('focuses an open window and navigates it; action urls win; message mode posts instead', async () => {
|
|
404
|
+
const f = fakeScope();
|
|
405
|
+
const w = win('https://app.example/inbox');
|
|
406
|
+
f.windows.push(w);
|
|
407
|
+
installPushHandlers({ scope: f.scope, store: memoryStore() });
|
|
408
|
+
await f.dispatch(
|
|
409
|
+
'notificationclick',
|
|
410
|
+
clickEvent({ url: '/m/1', actionUrls: { reply: '/m/1/reply' }, payload: content }, 'reply')
|
|
411
|
+
.event
|
|
412
|
+
);
|
|
413
|
+
expect(w.focus).toHaveBeenCalled();
|
|
414
|
+
expect(w.navigate).toHaveBeenCalledWith('https://app.example/m/1/reply');
|
|
415
|
+
expect(f.raw.clients.openWindow).not.toHaveBeenCalled();
|
|
416
|
+
|
|
417
|
+
const g = fakeScope();
|
|
418
|
+
const uncontrolled = win('https://app.example/inbox', 'visible', true);
|
|
419
|
+
g.windows.push(uncontrolled);
|
|
420
|
+
installPushHandlers({ scope: g.scope, store: memoryStore() });
|
|
421
|
+
await g.dispatch('notificationclick', clickEvent({ url: '/m/2', payload: content }).event);
|
|
422
|
+
expect(uncontrolled.postMessage).toHaveBeenCalledWith({
|
|
423
|
+
type: 'sp00ky:navigate',
|
|
424
|
+
url: 'https://app.example/m/2',
|
|
425
|
+
payload: content,
|
|
426
|
+
action: undefined,
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const h = fakeScope();
|
|
430
|
+
const spa = win('https://app.example/inbox', 'visible');
|
|
431
|
+
h.windows.push(spa);
|
|
432
|
+
installPushHandlers({
|
|
433
|
+
scope: h.scope,
|
|
434
|
+
store: memoryStore(),
|
|
435
|
+
clickMode: 'message',
|
|
436
|
+
resolveUrl: (u) => `https://app.example/#${u}`,
|
|
437
|
+
});
|
|
438
|
+
await h.dispatch('notificationclick', clickEvent({ url: '/m/3' }).event);
|
|
439
|
+
expect(spa.navigate).not.toHaveBeenCalled();
|
|
440
|
+
expect(spa.postMessage).toHaveBeenCalledWith({
|
|
441
|
+
type: 'sp00ky:navigate',
|
|
442
|
+
url: 'https://app.example/#/m/3',
|
|
443
|
+
payload: undefined,
|
|
444
|
+
action: undefined,
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('onClick can take over; onClose sees the payload', async () => {
|
|
449
|
+
const f = fakeScope();
|
|
450
|
+
const onClick = vi.fn(async () => true);
|
|
451
|
+
const onClose = vi.fn();
|
|
452
|
+
installPushHandlers({ scope: f.scope, store: memoryStore(), onClick, onClose });
|
|
453
|
+
const { event, close } = clickEvent({ url: '/x', payload: content }, 'reply');
|
|
454
|
+
await f.dispatch('notificationclick', event);
|
|
455
|
+
expect(onClick).toHaveBeenCalledWith(expect.anything(), content, 'reply');
|
|
456
|
+
expect(close).not.toHaveBeenCalled();
|
|
457
|
+
expect(f.raw.clients.openWindow).not.toHaveBeenCalled();
|
|
458
|
+
await f.dispatch('notificationclose', clickEvent({ payload: content }).event);
|
|
459
|
+
expect(onClose).toHaveBeenCalledWith(expect.anything(), content);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe('installPushHandlers: token bridge', () => {
|
|
464
|
+
const tokenMessage = {
|
|
465
|
+
type: 'sp00ky:token',
|
|
466
|
+
token,
|
|
467
|
+
userId: 'user:alice',
|
|
468
|
+
endpoint: 'wss://db/rpc',
|
|
469
|
+
namespace: 'n',
|
|
470
|
+
database: 'd',
|
|
471
|
+
publicKey: KEY,
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
it('stores the bridged session, ignores foreign origins, wipes it and every notification on sign-out', async () => {
|
|
475
|
+
const f = fakeScope();
|
|
476
|
+
const store = memoryStore();
|
|
477
|
+
installPushHandlers({ scope: f.scope, store });
|
|
478
|
+
await f.dispatch('message', {
|
|
479
|
+
data: { ...tokenMessage, token: 'foreign' },
|
|
480
|
+
origin: 'https://evil.example',
|
|
481
|
+
});
|
|
482
|
+
expect(await getBridgedToken(store)).toBeNull();
|
|
483
|
+
await f.dispatch('message', { data: tokenMessage, origin: 'https://app.example' });
|
|
484
|
+
expect(await getBridgedToken(store)).toMatchObject({
|
|
485
|
+
token,
|
|
486
|
+
userId: 'user:alice',
|
|
487
|
+
endpoint: 'wss://db/rpc',
|
|
488
|
+
namespace: 'n',
|
|
489
|
+
database: 'd',
|
|
490
|
+
publicKey: KEY,
|
|
491
|
+
});
|
|
492
|
+
await f.dispatch('message', { data: { type: 'sp00ky:token', token: 'x' } });
|
|
493
|
+
expect((await getBridgedToken(store))!.token).toBe(token);
|
|
494
|
+
|
|
495
|
+
await f.dispatch('push', pushEvent(content));
|
|
496
|
+
expect(f.open()).toHaveLength(1);
|
|
497
|
+
await f.dispatch('message', {
|
|
498
|
+
data: { type: 'sp00ky:signout' },
|
|
499
|
+
origin: 'https://app.example',
|
|
500
|
+
});
|
|
501
|
+
expect(await getBridgedToken(store)).toBeNull();
|
|
502
|
+
expect(f.open()).toHaveLength(0);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it('treats an expired bridged token as none', async () => {
|
|
506
|
+
const store = memoryStore();
|
|
507
|
+
await store.set('token', {
|
|
508
|
+
token: jwt({ ID: 'user:a', exp: 1 }),
|
|
509
|
+
userId: 'user:a',
|
|
510
|
+
endpoint: 'e',
|
|
511
|
+
namespace: 'n',
|
|
512
|
+
database: 'd',
|
|
513
|
+
at: 1,
|
|
514
|
+
});
|
|
515
|
+
expect(await getBridgedToken(store)).toBeNull();
|
|
516
|
+
const broken = {
|
|
517
|
+
get: async () => Promise.reject(new Error('idb gone')),
|
|
518
|
+
set: async () => {},
|
|
519
|
+
delete: async () => {},
|
|
520
|
+
};
|
|
521
|
+
expect(await getBridgedToken(broken as never)).toBeNull();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('pushsubscriptionchange: re-subscribes with the stored key, moves the device on the server, tells the pages', async () => {
|
|
525
|
+
const f = fakeScope();
|
|
526
|
+
const store = memoryStore();
|
|
527
|
+
await store.set('token', { ...tokenMessage, pushEndpoint: 'https://push.example/old', at: 1 });
|
|
528
|
+
const w = win('https://app.example/');
|
|
529
|
+
f.windows.push(w);
|
|
530
|
+
const feed = fakeFeed([]);
|
|
531
|
+
installPushHandlers({ scope: f.scope, store, feed: () => feed as never });
|
|
532
|
+
await f.dispatch('pushsubscriptionchange', { oldSubscription: null, newSubscription: null });
|
|
533
|
+
expect(f.subscribe).toHaveBeenCalledWith({
|
|
534
|
+
userVisibleOnly: true,
|
|
535
|
+
applicationServerKey: base64UrlToBytes(KEY),
|
|
536
|
+
});
|
|
537
|
+
const sqls = feed.query.mock.calls.map((c) => c[0]);
|
|
538
|
+
expect(sqls).toEqual([
|
|
539
|
+
'RETURN fn::push::list()',
|
|
540
|
+
'RETURN fn::push::subscribe($sub, $opts)',
|
|
541
|
+
'RETURN fn::push::unsubscribe($endpoint)',
|
|
542
|
+
]);
|
|
543
|
+
expect(feed.query.mock.calls[1][1]).toEqual({
|
|
544
|
+
sub: { endpoint: 'https://push.example/new', keys: { p256dh: 'p', auth: 'a' } },
|
|
545
|
+
opts: { label: 'Laptop', rules: ['dm'], userAgent: 'sw-ua' },
|
|
546
|
+
});
|
|
547
|
+
expect(feed.query.mock.calls[2][1]).toEqual({ endpoint: 'https://push.example/old' });
|
|
548
|
+
expect(feed.close).toHaveBeenCalled();
|
|
549
|
+
expect((await getBridgedToken(store))!.pushEndpoint).toBe('https://push.example/new');
|
|
550
|
+
expect(w.postMessage).toHaveBeenCalledWith({
|
|
551
|
+
type: 'sp00ky:subscriptionchange',
|
|
552
|
+
endpoint: 'https://push.example/new',
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('uninstall removes every listener', () => {
|
|
557
|
+
const f = fakeScope();
|
|
558
|
+
const off = installPushHandlers({ scope: f.scope, store: memoryStore() });
|
|
559
|
+
expect([...f.listeners.keys()].sort()).toEqual([
|
|
560
|
+
'message',
|
|
561
|
+
'notificationclick',
|
|
562
|
+
'notificationclose',
|
|
563
|
+
'push',
|
|
564
|
+
'pushsubscriptionchange',
|
|
565
|
+
]);
|
|
566
|
+
off();
|
|
567
|
+
expect(f.listeners.size).toBe(0);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
describe('notificationOptions', () => {
|
|
572
|
+
it('drops renotify without a tag and keeps unknown keys', () => {
|
|
573
|
+
const [title, options] = notificationOptions({ title: 'x', renotify: true, timestamp: 5 });
|
|
574
|
+
expect(title).toBe('x');
|
|
575
|
+
expect(options).toEqual({ timestamp: 5, data: {} });
|
|
576
|
+
const [, tagged] = notificationOptions(
|
|
577
|
+
{ title: 'x', renotify: true },
|
|
578
|
+
{ v: 1, kind: 'message', topic: 't', ts: 1 }
|
|
579
|
+
);
|
|
580
|
+
expect(tagged).toMatchObject({ renotify: true, tag: 't' });
|
|
581
|
+
});
|
|
582
|
+
});
|