Add the Sandbar mobile companion PWA
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ad165ef6a5
18 files changed
+1143
No files matched your search
@@ -0,0 +1,59 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Client, ApiError, normalizeServer, ago, urlBase64ToUint8Array } from '../public/api.js';
|
||||
|
||||
const jsonRes = (status, body) => ({ ok: status < 300, status, text: async () => JSON.stringify(body) });
|
||||
|
||||
test('normalizeServer adds a scheme and drops paths', () => {
|
||||
assert.equal(normalizeServer('sandbar.example.com/app/'), 'https://sandbar.example.com');
|
||||
assert.equal(normalizeServer('localhost:8090'), 'http://localhost:8090');
|
||||
assert.equal(normalizeServer(' https://x.test:8443/y '), 'https://x.test:8443');
|
||||
assert.throws(() => normalizeServer(''), /address/);
|
||||
});
|
||||
|
||||
test('login posts credentials and returns the token', async () => {
|
||||
let seen;
|
||||
const f = async (url, init) => { seen = { url, init }; return jsonRes(200, { token: 'sb_abc', keyId: 'k1', user: { name: 'Ana' } }); };
|
||||
const r = await Client.login('http://s', 'a@b.c', 'pw', 'Pixel', f);
|
||||
assert.equal(r.token, 'sb_abc');
|
||||
assert.equal(seen.url, 'http://s/api/v1/mobile/login');
|
||||
assert.deepEqual(JSON.parse(seen.init.body), { email: 'a@b.c', password: 'pw', device: 'Pixel' });
|
||||
assert.equal(seen.init.headers.Authorization, undefined);
|
||||
});
|
||||
|
||||
test('errors carry the server message and status', async () => {
|
||||
const c = new Client('http://s', 't', async () => jsonRes(401, { error: 'Wrong email or password' }));
|
||||
await assert.rejects(c.alerts(), (e) => e instanceof ApiError && e.status === 401 && e.message === 'Wrong email or password');
|
||||
const down = new Client('http://s', 't', async () => { throw new TypeError('fetch failed'); });
|
||||
await assert.rejects(down.alerts(), (e) => e.status === 0 && /Could not reach/.test(e.message));
|
||||
});
|
||||
|
||||
test('alert actions hit the right endpoints with a bearer token', async () => {
|
||||
const calls = [];
|
||||
const c = new Client('http://s', 'tok', async (url, init) => { calls.push([init.method, url, init.headers.Authorization, init.body]); return jsonRes(200, { ok: true }); });
|
||||
await c.ack('a1'); await c.snooze('a1', 30); await c.escalate('a1'); await c.resolve('a1');
|
||||
assert.deepEqual(calls.map((x) => x.slice(0, 3)), [
|
||||
['POST', 'http://s/api/v1/alerts/a1/ack', 'Bearer tok'],
|
||||
['POST', 'http://s/api/v1/alerts/a1/snooze', 'Bearer tok'],
|
||||
['POST', 'http://s/api/v1/alerts/a1/escalate', 'Bearer tok'],
|
||||
['POST', 'http://s/api/v1/alerts/a1/resolve', 'Bearer tok'],
|
||||
]);
|
||||
assert.equal(calls[1][3], '{"minutes":30}');
|
||||
});
|
||||
|
||||
test('stream parses newline-delimited messages split across chunks', async () => {
|
||||
const enc = new TextEncoder();
|
||||
const chunks = ['{"event":"open"}\n{"event":"message","id":"1","mess', 'age":"hi"}\n\n{"event":"message","id":"2","message":"yo"}\n'];
|
||||
const body = new ReadableStream({ start(c) { chunks.forEach((x) => c.enqueue(enc.encode(x))); c.close(); } });
|
||||
const got = [];
|
||||
const c = new Client('http://s', 't', async () => ({ ok: true, status: 200, body }));
|
||||
await c.stream('alerts', 'all', (m) => got.push(m.message), new AbortController().signal);
|
||||
assert.deepEqual(got, ['hi', 'yo']);
|
||||
});
|
||||
|
||||
test('ago and key decoding', () => {
|
||||
assert.equal(ago(1000, 1030), 'just now');
|
||||
assert.equal(ago(1000, 1000 + 5 * 60), '5 min ago');
|
||||
assert.equal(ago(1000, 1000 + 3 * 3600), '3 h ago');
|
||||
assert.deepEqual([...urlBase64ToUint8Array('AQID')], [1, 2, 3]);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
// Runs sw.js in a stand-in service worker scope and checks what it shows and calls.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
function load({ visible = false, session = { server: 'http://s', token: 'tok', device: 'Pixel' } } = {}) {
|
||||
const handlers = {}, shown = [], closed = [], fetches = [], opened = [], posted = [];
|
||||
const win = { url: 'http://s/app/', visibilityState: visible ? 'visible' : 'hidden', focus: async () => {}, postMessage: (m) => posted.push(m) };
|
||||
const self = {
|
||||
location: { origin: 'http://s' },
|
||||
addEventListener: (n, f) => { handlers[n] = f; },
|
||||
skipWaiting: () => {},
|
||||
clients: { claim: async () => {}, matchAll: async () => (visible ? [win] : []), openWindow: async (u) => opened.push(u) },
|
||||
registration: {
|
||||
scope: 'http://s/app/',
|
||||
showNotification: async (title, o) => shown.push({ title, ...o }),
|
||||
getNotifications: async ({ tag }) => [{ tag, close: () => closed.push(tag) }],
|
||||
pushManager: {},
|
||||
},
|
||||
};
|
||||
const idb = { open: () => { const r = {}; setTimeout(() => { r.result = { createObjectStore() {}, transaction: () => ({ objectStore: () => ({ get: () => { const g = {}; setTimeout(() => { g.result = session; g.onsuccess(); }); return g; } }) }) }; r.onsuccess(); }); return r; } };
|
||||
const ctx = vm.createContext({ self, indexedDB: idb, fetch: async (u, i) => { fetches.push([u, i]); return { ok: true, status: 200, json: async () => ({ ok: true }) }; }, caches: {}, URL, console, setTimeout, Uint8Array, atob });
|
||||
vm.runInContext(readFileSync(new URL('../public/sw.js', import.meta.url), 'utf8'), ctx);
|
||||
const run = async (name, ev) => { let p; await handlers[name]({ ...ev, waitUntil: (x) => { p = x; } }); await p; };
|
||||
return { run, shown, closed, fetches, opened, posted };
|
||||
}
|
||||
|
||||
const push = (o) => ({ data: { json: () => o } });
|
||||
|
||||
test('a firing alert shows a notification with Acknowledge and Snooze', async () => {
|
||||
const w = load();
|
||||
await w.run('push', push({ id: 'm1', topic: 'alerts', title: 'Disk full', message: 'db1 98%', priority: 5, alert: 'a1', status: 'firing', time: 1 }));
|
||||
assert.equal(w.shown.length, 1);
|
||||
assert.equal(w.shown[0].title, 'Disk full');
|
||||
assert.equal(w.shown[0].tag, 'a1');
|
||||
assert.equal(w.shown[0].requireInteraction, true);
|
||||
assert.equal(JSON.stringify(w.shown[0].actions.map((a) => a.action)), '["ack","snooze"]');
|
||||
});
|
||||
|
||||
test('a plain message has no alert buttons, and an open app shows its own toast instead', async () => {
|
||||
const w = load();
|
||||
await w.run('push', push({ id: 'm2', topic: 'news', title: 'Hi', message: 'x', priority: 3 }));
|
||||
assert.equal(w.shown[0].actions.length, 0);
|
||||
const v = load({ visible: true });
|
||||
await v.run('push', push({ id: 'm3', topic: 'news', title: 'Hi', message: 'x' }));
|
||||
assert.equal(v.shown.length, 0);
|
||||
assert.equal(v.posted[0].type, 'push');
|
||||
});
|
||||
|
||||
test('a resolved alert closes its earlier notification', async () => {
|
||||
const w = load();
|
||||
await w.run('push', push({ id: 'm4', topic: 'alerts', title: 'Resolved', message: 'ok', alert: 'a1', status: 'resolved' }));
|
||||
assert.equal(w.closed.join(), 'a1');
|
||||
});
|
||||
|
||||
test('Acknowledge on the notification calls the API without opening the app', async () => {
|
||||
const w = load();
|
||||
await w.run('notificationclick', { action: 'ack', notification: { data: { alert: 'a1' }, close() {} } });
|
||||
assert.equal(w.fetches.length, 1);
|
||||
assert.equal(w.fetches[0][0], 'http://s/api/v1/alerts/a1/ack');
|
||||
assert.equal(w.fetches[0][1].headers.Authorization, 'Bearer tok');
|
||||
assert.equal(w.opened.length, 0);
|
||||
});
|
||||
|
||||
test('Snooze sends 30 minutes, and tapping the body opens the alerts view', async () => {
|
||||
const w = load();
|
||||
await w.run('notificationclick', { action: 'snooze', notification: { data: { alert: 'a1' }, close() {} } });
|
||||
assert.equal(w.fetches[0][1].body, '{"minutes":30}');
|
||||
await w.run('notificationclick', { action: '', notification: { data: { alert: 'a1' }, close() {} } });
|
||||
assert.equal(w.opened[0], 'http://s/app/#alerts');
|
||||
});
|
||||
Reference in new issue
Block a user