Compare commits

..

27 Commits

Author SHA1 Message Date
Jeff c684e2e31c Add json content type
Test / test (push) Failing after 53s Details
2024-10-14 15:30:39 -04:00
Jeff f3e3077374 Log on service worker end
Test / test (push) Failing after 54s Details
2024-10-14 15:11:18 -04:00
Jeff 4f0e68be41 Log subscription
Test / test (push) Failing after 52s Details
2024-10-14 15:08:17 -04:00
Jeff 65185a4e9f Try converting subscription to json first
Test / test (push) Failing after 53s Details
2024-10-14 15:05:09 -04:00
Jeff b966b30c06 Add pwa build variables
Test / test (push) Failing after 32s Details
2024-10-14 14:59:04 -04:00
Jeff 43ee91c8e4 Remove window. in b64ToUInt8
Test / test (push) Failing after 55s Details
2024-10-13 00:21:41 -04:00
Jeff cc7faccecc Narrow further
Test / test (push) Failing after 53s Details
2024-10-13 00:19:25 -04:00
Jeff 478cd10022 Even more logs
Test / test (push) Failing after 56s Details
2024-10-13 00:16:01 -04:00
Jeff 5b5c8fa847 Log even more garbage
Test / test (push) Failing after 54s Details
2024-10-12 17:46:29 -04:00
Jeff b816cb56da Log even more garbage
Test / test (push) Failing after 54s Details
2024-10-12 17:43:30 -04:00
Jeff 121cc90de9 Log even more garbage
Test / test (push) Failing after 54s Details
2024-10-12 17:40:06 -04:00
Jeff 3ff16c58b2 Log more, try adding /
Test / test (push) Failing after 52s Details
2024-10-12 17:30:53 -04:00
Jeff 00876f08e9 Log what the stupid event data is
Test / test (push) Failing after 52s Details
2024-10-12 17:27:34 -04:00
Jeff fc043461a2 Use event.data instead of event in service worker subscription logic
Test / test (push) Failing after 53s Details
2024-10-12 17:18:15 -04:00
Jeff e3a0b6e730 Register for messages on the serviceWorker property I guess
Test / test (push) Failing after 53s Details
2024-10-12 17:13:45 -04:00
Jeff b92750e5d5 Add more log
Test / test (push) Failing after 30s Details
2024-10-12 17:06:34 -04:00
Jeff a07f028b75 Add even more logging
Test / test (push) Failing after 57s Details
2024-10-12 16:58:54 -04:00
Jeff 5741b9fee2 Fix jsonification of message, try sending reply immediately upon receiving message event
Test / test (push) Failing after 54s Details
2024-10-12 15:08:40 -04:00
Jeff 16250ef533 Fix error in render method
Test / test (push) Failing after 53s Details
2024-10-12 14:47:12 -04:00
Jeff 1d3edec7bc Actually display messages
Test / test (push) Failing after 52s Details
2024-10-12 14:44:17 -04:00
Jeff 07d638b66c Send debug messages back to client
Test / test (push) Failing after 52s Details
2024-10-12 14:38:35 -04:00
Jeff 4a7d752230 Convert subscription to JSON
Test / test (push) Failing after 55s Details
2024-10-12 14:07:13 -04:00
Jeff 0c3e6f60b1 Catch errors in subscribe callback
Test / test (push) Failing after 54s Details
2024-10-12 14:03:22 -04:00
Jeff d655492d16 Show error when enabling notifications
Test / test (push) Failing after 54s Details
2024-10-12 13:58:46 -04:00
Jeff a23f5bdd4a getSubscription returns subscription at the start of the current session, so rely on the result of the subscribe call
Test / test (push) Failing after 53s Details
2024-10-12 13:34:57 -04:00
Jeff 8890093dfd Send subscribed event to worker
Test / test (push) Failing after 53s Details
2024-10-12 12:33:30 -04:00
Jeff 93340fb1dd See what's going on with notifications
Test / test (push) Failing after 51s Details
2024-10-12 12:16:53 -04:00
24 changed files with 376 additions and 524 deletions

View File

@ -1,2 +1 @@
data/ data/
data-test/

View File

@ -1,6 +0,0 @@
DATABASE="postgres"
POSTGRES_PASSWORD="testpassword"
POSTGRES_HOST="database"
POSTGRES_PORT=5432
VAPID_PRIVATE_KEY="privatekey"
BASE_URL="http://localhost:5173"

View File

@ -1,6 +1,4 @@
DATABASE="postgres" DATABASE="postgres"
POSTGRES_PASSWORD="testpassword" POSTGRES_PASSWORD="testpassword"
POSTGRES_HOST="localhost"
POSTGRES_PORT=5434
VAPID_PRIVATE_KEY="privatekey" VAPID_PRIVATE_KEY="privatekey"
BASE_URL="http://localhost:5173" BASE_URL="http://localhost:5173"

View File

@ -5,27 +5,6 @@ on: [push]
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: mcr.microsoft.com/playwright:v1.48.1-jammy
services:
database:
image: postgres:16
env:
POSTGRES_PASSWORD: testpassword
POSTGRES_USER: postgres
POSTGRES_DB: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5434:5432
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Run Tests - run: npm install && npm run test && npx playwright install && npx playwright test
env: NODE_OPTIONS=--max-old-space-size=8192
run: |
npm install
npm run ci
npm ci
npx playwright test

1
.gitignore vendored
View File

@ -8,4 +8,3 @@ build/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
send-it.js

View File

@ -8,7 +8,7 @@ app.use(express.json())
app.get("/api", (req, res) => res.send("HI")) app.get("/api", (req, res) => res.send("HI"))
app.post("/api/subscription", postSubscription) app.post("/api/subscription/", postSubscription)
app.put("/api/subscription/:id/", putSubscription) app.put("/api/subscription/:id/", putSubscription)
export default app export default app

View File

@ -16,13 +16,6 @@ const dialect = new PostgresDialect({
}), }),
}) })
export let db = new Kysely<Database>({ export const db = new Kysely<Database>({
dialect, dialect,
}) })
export async function resetDbInstance() {
await db.destroy()
db = new Kysely<Database>({
dialect,
})
}

View File

@ -1,7 +1,7 @@
export default { export default {
database: process.env.DATABASE ?? "postgres", database: process.env.DATABASE ?? "postgres",
host: process.env.POSTGRES_HOST ?? "database", host: "database",
user: "postgres", user: "postgres",
password: process.env.POSTGRES_PASSWORD, password: process.env.POSTGRES_PASSWORD,
port: process.env.POSTGRES_PORT ?? 5432, port: 5432,
} }

View File

@ -1,4 +1,4 @@
import { db, resetDbInstance } from "./database" import { db } from "./database"
import migrateToLatest, { resetAll } from "./migrate" import migrateToLatest, { resetAll } from "./migrate"
import { import {
createSubscription, createSubscription,
@ -26,29 +26,17 @@ const pushSubscription2 = {
} }
jest.mock("./settings", () => ({ jest.mock("./settings", () => ({
port: process.env.POSTGRES_PORT, port: 5434,
user: "postgres", user: "postgres",
password: process.env.POSTGRES_PASSWORD, password: process.env.POSTGRES_PASSWORD,
database: "test", database: "test",
host: process.env.POSTGRES_HOST, host: "localhost",
})) }))
describe("subscriptions", () => { describe("subscriptions", () => {
beforeAll(async () => { beforeEach(migrateToLatest)
await resetDbInstance()
})
beforeEach(async () => { afterEach(resetAll)
await migrateToLatest()
})
afterEach(async () => {
await resetAll()
})
afterAll(async () => {
await db.destroy()
})
test("createSubscription", async () => { test("createSubscription", async () => {
const { id } = await createSubscription(pushSubscription1) const { id } = await createSubscription(pushSubscription1)

View File

@ -21,10 +21,6 @@ describe("/api/subscription", () => {
mockedCreateSubscription.mockImplementation((subscription) => mockedCreateSubscription.mockImplementation((subscription) =>
Promise.resolve({ id: 40, subscription }) Promise.resolve({ id: 40, subscription })
) )
mockedUpdateSubscription.mockImplementation((subscription, id) =>
Promise.resolve({ id, subscription })
)
}) })
test("POST", async () => { test("POST", async () => {

View File

@ -5,6 +5,8 @@ import { createSubscription } from "../data/subscription"
export async function postSubscription(req: Request, res: Response) { export async function postSubscription(req: Request, res: Response) {
const subscriptionBody = req.body const subscriptionBody = req.body
console.log("Posted subscription", subscriptionBody)
const { id: subscriptionId } = await createSubscription(subscriptionBody) const { id: subscriptionId } = await createSubscription(subscriptionBody)
res.status(200).send({ subscriptionId }) res.status(200).send({ subscriptionId })

View File

@ -1,8 +1,9 @@
import React from "react" import React, { useState } from "react"
import { useEffect } from "react" import { useEffect } from "react"
import { usePush } from "../usePush" import { usePush } from "../usePush"
import pushPublicKey from "../../pushPublicKey" import { request } from "http"
import urlB64ToUint8Array from "../../b64ToUInt8" import urlB64ToUint8Array from "../../b64ToUInt8"
import pushPublicKey from "../../pushPublicKey"
export default function EnableNotifications({ export default function EnableNotifications({
onSubscribe, onSubscribe,
@ -24,6 +25,9 @@ export default function EnableNotifications({
function EnableButton({ onSubscribe }: { onSubscribe: () => void }) { function EnableButton({ onSubscribe }: { onSubscribe: () => void }) {
const { subscribeToPush, requestPermission, canSendPush } = usePush() const { subscribeToPush, requestPermission, canSendPush } = usePush()
const [error, setError] = useState<Error>()
const [log, setLog] = useState<string[]>([])
function subscribe() { function subscribe() {
requestPermission() requestPermission()
} }
@ -31,19 +35,45 @@ function EnableButton({ onSubscribe }: { onSubscribe: () => void }) {
useEffect(() => { useEffect(() => {
if (!canSendPush) return if (!canSendPush) return
setLog((prev) => [...prev, "Subscribing to push notifications"])
subscribeToPush( subscribeToPush(
urlB64ToUint8Array(pushPublicKey) as any, urlB64ToUint8Array(pushPublicKey) as any,
async (subscription) => { (subscription) => {
await navigator.serviceWorker.ready.then((registration) => { setLog((prev) => [
registration.active?.postMessage({ ...prev,
type: "subscribed", `controller is undefined? ${navigator.serviceWorker.controller === undefined}`,
subscription: subscription.toJSON(), ])
setLog((prev) => [
...prev,
`subscriptions: ${JSON.stringify(subscription.toJSON())}`,
])
try {
navigator.serviceWorker.ready.then((registration) => {
registration.active?.postMessage({
type: "subscribed",
subscription: subscription.toJSON(),
})
setLog((prev) => [...prev, "After post message"])
}) })
}) // onSubscribe()
onSubscribe() } catch (error) {
setError(error as Error)
}
},
(error) => {
setError(error)
} }
) )
}, [canSendPush]) }, [canSendPush])
return <button onClick={subscribe}>Enable Notifications</button> return (
<>
<button onClick={subscribe}>Enable Notifications</button>
<div>{error?.toString()}</div>
{log.map((log, index) => (
<div key={index}>{log}</div>
))}
</>
)
} }

View File

@ -93,21 +93,33 @@ function TackUpNow({
const [isInstalled, setIsInstalled] = useState(installed) const [isInstalled, setIsInstalled] = useState(installed)
const [messages, setMessages] = useState<any[]>([])
useEffect(() => {
navigator.serviceWorker.onmessage = (event) =>
setMessages((prev) => [...prev, JSON.stringify(event.data)])
}, [])
return ( return (
<ClientOnly fallback={<div>Loading</div>}> <ClientOnly fallback={<div>Loading</div>}>
{() => {() => (
isInstalled ? ( <>
<div>Your Notifications</div> {messages.map((message, index) => (
) : ( <div key={index}>{JSON.stringify(message)}</div>
<InstallPrompts ))}
isMobileSafari={isMobileSafari} {isInstalled ? (
isSupported={isSupported} <div>Your Notifications</div>
isIOS={isIOS} ) : (
notificationsEnabled={false} <InstallPrompts
onInstallComplete={() => setIsInstalled(true)} isMobileSafari={isMobileSafari}
/> isSupported={isSupported}
) isIOS={isIOS}
} notificationsEnabled={false}
onInstallComplete={() => setIsInstalled(true)}
/>
)}
</>
)}
</ClientOnly> </ClientOnly>
) )
} }

View File

@ -3,167 +3,64 @@ import "core-js/stable/structured-clone"
import initWorker from "./worker" import initWorker from "./worker"
import Dexie, { EntityTable } from "dexie" import Dexie, { EntityTable } from "dexie"
function createSubscription(testToken = "HI") {
return {
toJSON: () => ({
subscription: testToken,
}),
}
}
const createSelf = () => const createSelf = () =>
({ ({
addEventListener: jest.fn(), addEventListener: jest.fn(),
skipWaiting: jest.fn(() => Promise.resolve()), skipWaiting: jest.fn(() => Promise.resolve()),
registration: { registration: {
pushManager: { pushManager: {
subscribe: jest.fn(() => Promise.resolve(createSubscription())), subscribe: jest.fn(() => Promise.resolve({ subscription: "HI" })),
getSubscription: jest.fn(() => Promise.resolve(createSubscription())), getSubscription: jest.fn(() => Promise.resolve({ subscription: "HI" })),
}, },
showNotification: jest.fn(),
}, },
clients: { clients: {
claim: jest.fn(() => Promise.resolve()), claim: jest.fn(() => Promise.resolve()),
openWindow: jest.fn(() => Promise.resolve()),
matchAll: jest.fn(() => Promise.resolve([])),
}, },
}) as unknown as ServiceWorkerGlobalScope }) as unknown as ServiceWorkerGlobalScope
let originalWindow: Window & typeof globalThis
describe("service worker", () => { describe("service worker", () => {
let self: ServiceWorkerGlobalScope let self: ServiceWorkerGlobalScope
let controlledClients: Client[]
let uncontrolledClients: Client[]
beforeEach(() => { beforeEach(() => {
originalWindow = global.window
global.window = undefined as any
indexedDB = new IDBFactory() indexedDB = new IDBFactory()
self = createSelf() self = createSelf()
controlledClients = []
uncontrolledClients = [] // fetchMock.mockIf(/http:\/\/localhost\/api\/subscribe\/?/, (req) => {
// return Promise.resolve(
// req.method === "POST"
// ? {
// body: JSON.stringify({ subscriptionId: 123 }),
// }
// : req.method === "PUT"
// ? { init: { status: 200 } }
// : { init: { status: 405 } }
// )
// })
fetchMock.mockResponse((req) => { fetchMock.mockResponse((req) => {
if ( return Promise.resolve(
req.url.replace(/https?:\/\/[a-zA-Z0-9\.]*/, "") ===
"/api/subscription/" &&
req.method === "POST" req.method === "POST"
) { ? {
return Promise.resolve({ body: JSON.stringify({ subscriptionId: 123 }),
body: JSON.stringify({ subscriptionId: 123 }), }
}) : req.method === "PUT"
} else if ( ? { init: { status: 200 } }
req.url : { init: { status: 405 } }
.replace(/https?:\/\/[a-zA-Z0-9\.]*/, "") )
.startsWith("/api/subscription/") &&
req.method === "PUT"
) {
return Promise.resolve({ init: { status: 200 } })
}
return Promise.resolve({ init: { status: 500 } })
}) })
}) })
afterEach(() => { afterEach(() => {
jest.clearAllMocks() jest.clearAllMocks()
global.window = originalWindow
}) })
test("displays push notifications", async () => { xtest("displays push notifications", () => {
initWorker(self) initWorker(self)
const pushHandler = getHandler("push") const pushHandler = getHandler("push")
const pushEvent = createPushEvent( const pushEvent = createEvent({ data: "HI" })
JSON.stringify({
title: "Test title",
body: "Test text",
icon: "Test icon",
badge: "Test badge",
destination: "tackupnow.com/hi",
})
)
pushHandler(pushEvent) pushHandler(pushEvent)
await waitUntilCalls(pushEvent)
expect(self.registration.showNotification).toHaveBeenCalledWith(
"Test title",
{
body: "Test text",
icon: "Test icon",
badge: "Test badge",
data: { destination: "tackupnow.com/hi" },
}
)
})
describe("on notification click", () => {
test("the notification is closed", async () => {
initWorker(self)
const notificationHandler = getHandler("notificationclick")
const event = createNotificationEvent("tackupnow.com")
notificationHandler(event)
await waitUntilCalls(event)
expect(event.notification.close).toHaveBeenCalled()
})
test("opens tack up now if no clients match the destination", async () => {
initWorker(self)
const notificationHandler = getHandler("notificationclick")
const event = createNotificationEvent("tackupnow.com")
notificationHandler(event)
await waitUntilCalls(event)
expect(self.clients.openWindow).toHaveBeenCalledWith("tackupnow.com")
})
test("focuses first matching client", async () => {
addClient("https://tackupnow.com/place", "window", true)
const matchingClient = addClient(
"https://tackupnow.com/otherplace",
"window",
true
)
addClient("https://tackupnow.com/yetanotherotherplace", "window", true)
initWorker(self)
const notificationHandler = getHandler("notificationclick")
const event = createNotificationEvent("https://tackupnow.com/otherplace")
notificationHandler(event)
await waitUntilCalls(event)
expect(matchingClient.focus).toHaveBeenCalled()
})
test("focuses uncontrolled matching clients", async () => {
addClient("https://derpatious.world", "window", false)
addClient("https://tackupnow.com/1", "window", false)
const matchingClient = addClient(
"https://tackupnow.com/2",
"window",
false
)
addClient("https://tackupnow.com/controlled", "window", true)
initWorker(self)
const notificationHandler = getHandler("notificationclick")
const event = createNotificationEvent("https://tackupnow.com/2")
notificationHandler(event)
await waitUntilCalls(event)
expect(matchingClient.focus).toHaveBeenCalled()
})
}) })
test("claims on activate", async () => { test("claims on activate", async () => {
@ -190,175 +87,64 @@ describe("service worker", () => {
expect(self.skipWaiting).toHaveBeenCalled() expect(self.skipWaiting).toHaveBeenCalled()
}) })
describe("on subscription message", () => { test("puts push subscription if the subscription id is set", async () => {
test("puts push subscription if the subscription id is set", async () => { initWorker(self)
initWorker(self) await setSavedSubscription(5000)
await setSavedSubscription(5000)
const messageHandler = getHandler("message") const changeHandler = getHandler("pushsubscriptionchange")
const messageEvent = createEvent({ const changeEvent = createEvent()
data: { changeHandler(changeEvent)
subscription: { subscription: "YO" }, await waitUntilCalls(changeEvent)
type: "subscribed",
},
})
messageHandler(messageEvent)
await waitUntilCalls(messageEvent)
expect(fetch).toHaveBeenCalledWith( expect(self.registration.pushManager.subscribe).toHaveBeenCalledWith({
`${process.env.BASE_URL}/api/subscription/5000`, userVisibleOnly: true,
{ applicationServerKey: expect.any(Uint8Array),
method: "PUT",
body: JSON.stringify({ subscription: "YO" }),
headers: {
"Content-Type": "application/json",
},
}
)
}) })
test("posts to subscriptions when there is no user set", async () => { expect(fetch).toHaveBeenCalledWith(
initWorker(self) `${process.env.BASE_URL}/api/subscription/5000`,
{
const messageHandler = getHandler("message") method: "PUT",
body: JSON.stringify({ subscription: "HI" }),
const messageEvent = createEvent({
data: {
subscription: { subscription: "YO" },
type: "subscribed",
},
})
messageHandler(messageEvent)
await waitUntilCalls(messageEvent)
expect(fetch).toHaveBeenCalledWith(
`${process.env.BASE_URL}/api/subscription/`,
{
method: "POST",
body: JSON.stringify({ subscription: "YO" }),
headers: {
"Content-Type": "application/json",
},
}
)
})
test("subscription id is saved on first subscription change", async () => {
initWorker(self)
const messageHandler = getHandler("message")
const messageEvent = createEvent({
data: {
subscription: { subscription: "YO" },
type: "subscribed",
},
})
messageHandler(messageEvent)
await waitUntilCalls(messageEvent)
expect(await getSavedSubscription()).toEqual({
id: 1,
subscriptionId: 123,
})
})
})
describe("on subscription change", () => {
test("puts push subscription if the subscription id is set", async () => {
initWorker(self)
await setSavedSubscription(5000)
const changeHandler = getHandler("pushsubscriptionchange")
const changeEvent = createEvent()
changeHandler(changeEvent)
await waitUntilCalls(changeEvent)
expect(self.registration.pushManager.subscribe).toHaveBeenCalledWith({
userVisibleOnly: true,
applicationServerKey: expect.any(Uint8Array),
})
expect(fetch).toHaveBeenCalledWith(
`${process.env.BASE_URL}/api/subscription/5000`,
{
method: "PUT",
body: JSON.stringify({ subscription: "HI" }),
headers: {
"Content-Type": "application/json",
},
}
)
})
test("posts to subscriptions when there is no user set", async () => {
initWorker(self)
const changeHandler = getHandler("pushsubscriptionchange")
const changeEvent = createEvent()
changeHandler(changeEvent)
await waitUntilCalls(changeEvent)
expect(self.registration.pushManager.subscribe).toHaveBeenCalledWith({
userVisibleOnly: true,
applicationServerKey: expect.any(Uint8Array),
})
expect(fetch).toHaveBeenCalledWith(
`${process.env.BASE_URL}/api/subscription/`,
{
method: "POST",
body: JSON.stringify({ subscription: "HI" }),
headers: {
"Content-Type": "application/json",
},
}
)
})
test("subscription id is saved on first subscription change", async () => {
initWorker(self)
const changeHandler = getHandler("pushsubscriptionchange")
const changeEvent = createEvent()
changeHandler(changeEvent)
await waitUntilCalls(changeEvent)
expect(await getSavedSubscription()).toEqual({
id: 1,
subscriptionId: 123,
})
})
})
function addClient(url: string, type: ClientTypes, controlled: boolean) {
const matchAllMock = self.clients.matchAll as jest.Mock
matchAllMock.mockImplementation(
(args: { type: string; includeUncontrolled?: boolean }) => {
const clientList = args.includeUncontrolled
? [...uncontrolledClients, ...controlledClients]
: controlledClients
return clientList.filter((client) => client.type === args.type)
} }
) )
})
const clientList = controlled ? controlledClients : uncontrolledClients test("posts to subscriptions when there is no user set", async () => {
initWorker(self)
const client: WindowClient = { const changeHandler = getHandler("pushsubscriptionchange")
url,
type,
focus: jest.fn(() => Promise.resolve(client)) as WindowClient["focus"],
} as WindowClient
clientList.push(client) const changeEvent = createEvent()
changeHandler(changeEvent)
await waitUntilCalls(changeEvent)
return client expect(self.registration.pushManager.subscribe).toHaveBeenCalledWith({
} userVisibleOnly: true,
applicationServerKey: expect.any(Uint8Array),
})
expect(fetch).toHaveBeenCalledWith(
`${process.env.BASE_URL}/api/subscription/`,
{
method: "POST",
body: JSON.stringify({ subscription: "HI" }),
}
)
})
test("subscription id is saved on first subscription change", async () => {
initWorker(self)
const changeHandler = getHandler("pushsubscriptionchange")
const changeEvent = createEvent()
changeHandler(changeEvent)
await waitUntilCalls(changeEvent)
expect(await getSavedSubscription()).toEqual({ id: 1, subscriptionId: 123 })
})
function getHandler(eventName: string) { function getHandler(eventName: string) {
expect(self.addEventListener).toHaveBeenCalledWith( expect(self.addEventListener).toHaveBeenCalledWith(
@ -407,19 +193,8 @@ function createPushEvent(data: string) {
return createEvent(pushFields) return createEvent(pushFields)
} }
function createNotificationEvent(destination: string): NotificationEvent & { function createMessageEvent(message: any) {
waitUntil: jest.Mock<void, Parameters<ExtendableEvent["waitUntil"]>> return createEvent({ data: message })
} {
return createEvent({
notification: {
close: jest.fn(),
data: {
destination,
},
},
}) as any as NotificationEvent & {
waitUntil: jest.Mock<void, Parameters<ExtendableEvent["waitUntil"]>>
}
} }
function createEvent(properties?: object) { function createEvent(properties?: object) {

View File

@ -16,49 +16,72 @@ export default function start(self: ServiceWorkerGlobalScope) {
event.waitUntil(self.clients.claim()) event.waitUntil(self.clients.claim())
}) })
self.addEventListener("push", function (event: PushEvent) { // self.addEventListener("push", function (event: PushEvent) {
const { title, body, badge, icon, destination } = event.data?.json() // console.log("[Service Worker] Push Received.")
// console.log(`[Service Worker] Push had this data: "${event.data?.text()}"`)
event.waitUntil( // const title = "Push Codelab"
self.registration.showNotification(title, { // const options = {
body, // body: "Yay it works.",
badge, // icon: "images/icon.png",
icon, // badge: "images/badge.png",
data: { destination }, // }
})
// event.waitUntil(self.registration.showNotification(title, options))
// })
// self.addEventListener("notificationclick", function (event) {
// console.log("[Service Worker] Notification click Received.")
// event.notification.close()
// event.waitUntil(
// self.clients.openWindow("https://developers.google.com/web/")
// )
// })
async function sendMessage(message: any) {
const clients = await self.clients.matchAll()
await Promise.all(
clients.map(async (client) => client.postMessage(message))
) )
}) }
self.addEventListener("notificationclick", function (event) {
event.notification.close()
const destination = event.notification.data.destination
event.waitUntil(
(async () => {
const clients = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
})
const existingClient = clients.find(
(client) => client.url === event.notification.data.destination
)
if (existingClient === undefined) {
await self.clients.openWindow(destination)
} else {
await existingClient.focus()
}
})()
)
})
self.addEventListener("message", function (event) { self.addEventListener("message", function (event) {
const waitEvent = event as ExtendableEvent const waitEvent = event as ExtendableEvent
waitEvent.waitUntil(
(async () => {
await sendMessage({
message: "Got message",
data: event.data,
})
if ("type" in event.data && event.data.type === "subscribed") { if (
waitEvent.waitUntil(submitSubscription(event.data.subscription)) "type" in event.data &&
} event.data.type === "subscribed" &&
"subscription" in event.data
) {
try {
await event.waitUntil(
submitSubscription(
self.registration,
event.data.subscription as PushSubscription
)
)
} catch (e) {
await sendMessage({
message: "Got error processing subscription",
error: (e as Error).toString(),
})
}
}
await sendMessage({
message: "Processed subscription",
})
})()
)
}) })
self.addEventListener("pushsubscriptionchange", function (event) { self.addEventListener("pushsubscriptionchange", function (event) {
@ -66,54 +89,128 @@ export default function start(self: ServiceWorkerGlobalScope) {
waitEvent.waitUntil( waitEvent.waitUntil(
(async () => { (async () => {
const applicationServerKey = urlB64ToUint8Array(pushPublicKey) const existingSubscription =
const newSubscription = await self.registration.pushManager.subscribe({ await self.registration.pushManager.getSubscription()
userVisibleOnly: true, const newSubscription = await submitSubscription(
applicationServerKey: applicationServerKey, self.registration,
}) existingSubscription
)
await submitSubscription(newSubscription.toJSON()) await sendMessage({ type: "sent subscription", newSubscription })
})() })()
) )
}) })
}
async function submitSubscription(subscription: PushSubscriptionJSON) { async function submitSubscription(
const db = database() registration: ServiceWorkerRegistration,
subscription: PushSubscription | null
) {
const db = database()
const existingSubscriptionId = await db.subscriptions.get(1) await sendMessage({
message: `Is subscription null? ${subscription === null}`,
})
await (existingSubscriptionId === undefined if (subscription === null) return
? postSubscription(subscription, db)
: putSubscription(subscription, existingSubscriptionId.subscriptionId))
}
async function postSubscription( const existingSubscriptionId = await db.subscriptions.get(1)
subscription: PushSubscriptionJSON,
db: ReturnType<typeof database>
) {
const response = await fetch(`${process.env.BASE_URL}/api/subscription/`, {
method: "POST",
body: JSON.stringify(subscription),
headers: {
"Content-Type": "application/json",
},
})
db.subscriptions.put({ await sendMessage({
id: 1, message: `Existing subscription ID ${existingSubscriptionId}`,
subscriptionId: (await response.json()).subscriptionId, })
})
}
function putSubscription(subscription: PushSubscriptionJSON, id: number) { await sendMessage({
return fetch(`${process.env.BASE_URL}/api/subscription/${id}`, { message: `pushPublicKey ${pushPublicKey}`,
method: "PUT", })
body: JSON.stringify(subscription),
headers: { let applicationServerKey
"Content-Type": "application/json", try {
}, applicationServerKey = urlB64ToUint8Array(pushPublicKey)
}) } catch (error) {
await sendMessage({
message: `B64 error ${(error as Error).toString()}`,
})
}
await sendMessage({
message: `Converted public key`,
})
const newSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey,
})
await sendMessage({
message: `subscribed via pushManager`,
})
const stupid =
existingSubscriptionId === undefined
? postSubscription(newSubscription.toJSON(), db)
: putSubscription(
newSubscription.toJSON(),
existingSubscriptionId.subscriptionId
)
await stupid
return newSubscription
}
async function postSubscription(
subscription: PushSubscriptionJSON,
db: ReturnType<typeof database>
) {
await sendMessage({
message: "Log something you piece of garbage",
})
try {
await sendMessage({
message: `URL ${process.env.BASE_URL}/api/subscription/`,
})
await sendMessage({
message: `Submitting subscription ${subscription}`,
})
await sendMessage({
message: `JSON formatted: ${JSON.stringify(subscription)}`,
})
const response = await fetch(
`${process.env.BASE_URL}/api/subscription/`,
{
method: "POST",
body: JSON.stringify(subscription),
headers: {
"Content-Type": "application/json",
},
}
)
await sendMessage({
message: `Response status ${response.status}`,
})
db.subscriptions.put({
id: 1,
subscriptionId: (await response.json()).subscriptionId,
})
} catch (error) {
await sendMessage({
message: `ERRRROR ${(error as Error).toString()}}`,
})
}
}
function putSubscription(subscription: PushSubscriptionJSON, id: number) {
return fetch(`${process.env.BASE_URL}/api/subscription/${id}`, {
method: "PUT",
body: JSON.stringify(subscription),
headers: {
"Content-Type": "application/json",
},
})
}
} }
function database() { function database() {

View File

@ -1,6 +1,6 @@
services: services:
database: database:
image: postgres:16 image: postgres
ports: ports:
- "5434:5432" - "5434:5432"
environment: environment:

View File

@ -3,9 +3,10 @@ services:
build: . build: .
ports: ports:
- "9000:3000" - "9000:3000"
env_file: ".env"
database: database:
image: postgres:16 image: postgres
ports:
- "5432:5432"
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_DB: postgres POSTGRES_DB: postgres

View File

@ -1,6 +1,5 @@
import { test, expect, Page } from "@playwright/test" import { test, expect, Page } from "@playwright/test"
import matchPath from "./urlMatcher" import matchPath from "./urlMatcher"
import { messageSW } from "@remix-pwa/sw"
const { describe, beforeEach, skip, use } = test const { describe, beforeEach, skip, use } = test
@ -90,23 +89,6 @@ describe("when user is on iOS", () => {
await expect(yourNotificationsHeading).toBeVisible() await expect(yourNotificationsHeading).toBeVisible()
}) })
test("the subscription is submitted", async ({ page }) => {
await page.goto("/")
await page.getByText(/Enable Notifications/).click()
const postedMessages = await page.evaluate(() => {
return (window as any).postedMessages
})
expect(postedMessages).toContainEqual({
type: "subscribed",
subscription: {
subscription: "HI",
},
})
})
}) })
}) })
@ -230,37 +212,20 @@ function stubNotifications(
function stubServiceWorker(page: Page) { function stubServiceWorker(page: Page) {
return page.addInitScript(() => { return page.addInitScript(() => {
function createSubscription(testToken = "HI") { const serviceWorker = {}
return {
toJSON: () => ({
subscription: testToken,
}),
}
}
let postedMessages: any[] = []
const serviceWorker = {
postMessage: (message: any) => postedMessages.push(message),
}
const registration = { const registration = {
pushManager: { pushManager: {
getSubscription() { getSubscription() {
return Promise.resolve(createSubscription()) return Promise.resolve({ hi: "subscription" })
}, },
subscribe(args: Parameters<PushManager["subscribe"]>[0]) { subscribe(args: Parameters<PushManager["subscribe"]>[0]) {
return Promise.resolve(createSubscription()) return Promise.resolve({ hi: "subscription" })
}, },
}, },
active: serviceWorker, active: serviceWorker,
} }
Object.defineProperty(window, "postedMessages", {
value: postedMessages,
writable: true,
})
Object.defineProperty(navigator, "serviceWorker", { Object.defineProperty(navigator, "serviceWorker", {
value: { value: {
ready: Promise.resolve(registration), ready: Promise.resolve(registration),

30
package-lock.json generated
View File

@ -31,7 +31,7 @@
"@babel/preset-env": "^7.24.5", "@babel/preset-env": "^7.24.5",
"@babel/preset-react": "^7.24.1", "@babel/preset-react": "^7.24.1",
"@babel/preset-typescript": "^7.24.1", "@babel/preset-typescript": "^7.24.1",
"@playwright/test": "^1.48.1", "@playwright/test": "^1.44.1",
"@remix-pwa/dev": "^3.1.0", "@remix-pwa/dev": "^3.1.0",
"@remix-run/dev": "^2.9.0", "@remix-run/dev": "^2.9.0",
"@remix-run/testing": "^2.9.1", "@remix-run/testing": "^2.9.1",
@ -3324,18 +3324,18 @@
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.48.1", "version": "1.44.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.48.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz",
"integrity": "sha512-s9RtWoxkOLmRJdw3oFvhFbs9OJS0BzrLUc8Hf6l2UdCNd1rqeEyD4BhCJkvzeEoD1FsK4mirsWwGerhVmYKtZg==", "integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"playwright": "1.48.1" "playwright": "1.44.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=16"
} }
}, },
"node_modules/@remix-pwa/dev": { "node_modules/@remix-pwa/dev": {
@ -13843,33 +13843,33 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.48.1", "version": "1.44.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz",
"integrity": "sha512-j8CiHW/V6HxmbntOfyB4+T/uk08tBy6ph0MpBXwuoofkSnLmlfdYNNkFTYD6ofzzlSqLA1fwH4vwvVFvJgLN0w==", "integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"playwright-core": "1.48.1" "playwright-core": "1.44.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=16"
}, },
"optionalDependencies": { "optionalDependencies": {
"fsevents": "2.3.2" "fsevents": "2.3.2"
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.48.1", "version": "1.44.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.1.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz",
"integrity": "sha512-Yw/t4VAFX/bBr1OzwCuOMZkY1Cnb4z/doAFSwf4huqAGWmf9eMNjmK7NiOljCdLmxeRYcGPPmcDgU0zOlzP0YA==", "integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==",
"dev": true, "dev": true,
"bin": { "bin": {
"playwright-core": "cli.js" "playwright-core": "cli.js"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=16"
} }
}, },
"node_modules/playwright/node_modules/fsevents": { "node_modules/playwright/node_modules/fsevents": {

View File

@ -9,9 +9,8 @@
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve ./build/server/index.js", "start": "remix-serve ./build/server/index.js",
"typecheck": "tsc", "typecheck": "tsc",
"watch": "export $(cat .env.test | xargs) && docker compose -f docker-compose-test.yaml up database -d --remove-orphans && jest --watch --config=jest.config.ts", "watch": "export $(cat .env.test | xargs) && docker compose -f docker-compose-test.yaml up database -d && jest --watch --config=jest.config.ts",
"test": "export $(cat .env.test | xargs) && docker compose -f docker-compose-test.yaml up database -d --remove-orphans && sleep 5 && jest --config=jest.config.ts", "test": "export $(cat .env.test | xargs) && docker compose -f docker-compose-test.yaml up database -d && jest --config=jest.config.ts"
"ci": "export $(cat .env.ci | xargs) && jest --config=jest.config.ts"
}, },
"dependencies": { "dependencies": {
"@remix-pwa/sw": "^3.0.9", "@remix-pwa/sw": "^3.0.9",
@ -39,7 +38,7 @@
"@babel/preset-env": "^7.24.5", "@babel/preset-env": "^7.24.5",
"@babel/preset-react": "^7.24.1", "@babel/preset-react": "^7.24.1",
"@babel/preset-typescript": "^7.24.1", "@babel/preset-typescript": "^7.24.1",
"@playwright/test": "^1.48.1", "@playwright/test": "^1.44.1",
"@remix-pwa/dev": "^3.1.0", "@remix-pwa/dev": "^3.1.0",
"@remix-run/dev": "^2.9.0", "@remix-run/dev": "^2.9.0",
"@remix-run/testing": "^2.9.1", "@remix-run/testing": "^2.9.1",

View File

@ -1 +1 @@
export default "BNwr5jYXxBwSRGooQWx6nzsrG5XejgAskVqowkc_O0rutL9PgRIkQAnDh4nz1yoPQEw40juMuqL2NEHSaD3XcxA" export default "BKc02U2-z7PkTbYgYLlxELWqzTVE631fs4IPuMLbxY_rxdo9VaduthqwkPOiblwjETl99uXes2Nc9EtPbS5x4uA"

22
send-it.js Normal file
View File

@ -0,0 +1,22 @@
import webPush from "web-push"
const subscription = {
endpoint:
"https://updates.push.services.mozilla.com/wpush/v2/gAAAAABm9FlbY5vMro60hF2iJF3UUBzweuFcg5NRSHXPSpHfUpjo5jKGVRnUxR4ekg0-FsvdQCP89cu__IFd06Tu2TJZ649YyqivRUBnAav0DgOLHGx5-t943QLS-wLvBqyJRCuuLlM1bLz6S9ph9AWJ8CG7rQuTabsHvw--s_w2KDQo3GcQXIM",
expirationTime: null,
keys: {
auth: "dDUqtFo26ekGEAmNzvmJAw",
p256dh:
"BHIT3J6xSRiHfz0m-QRHagDThiOZGVIANtPzOasrOBYG0s_yUnshTVharX5dZcq8GA5OkyMm3mqmA7_o_lFR4WE",
},
}
webPush.setVapidDetails(
"https://tackupnow.com",
"BKc02U2-z7PkTbYgYLlxELWqzTVE631fs4IPuMLbxY_rxdo9VaduthqwkPOiblwjETl99uXes2Nc9EtPbS5x4uA",
"Xmv5Pc4mqr138V3sCxXq7UmsbL5UgSOY43UuJ50nxPw"
)
webPush
.sendNotification(subscription, "Hi what's up?", {})
.then((x) => console.log("It sent", x))
.catch((error) => console.log("It did not send", error))

View File

@ -1,4 +1,7 @@
#!/bin/bash #!/bin/bash
set -a
source ./.env
set +a
npx tsx api/data/upgrade.ts npx tsx api/data/upgrade.ts
npx tsx server.ts npx tsx server.ts

View File

@ -12,7 +12,7 @@ export default defineConfig({
tsconfigPaths(), tsconfigPaths(),
remixPWA({ remixPWA({
buildVariables: { buildVariables: {
"process.env.BASE_URL": process.env.BASE_URL ?? "http://localhost:5173", "process.env.BASE_URL": process.env.BASE_URL!,
}, },
}), }),
], ],