Compare commits

..

51 Commits

Author SHA1 Message Date
Jeff Lieb f78d448a0b Fix env injection
Test / test (push) Successful in 1m4s Details
2025-03-22 16:04:07 -04:00
Jeff 683c685b7d Update public key
Test / test (push) Successful in 1m10s Details
2025-03-22 14:56:07 -04:00
Jeff d5f474768c Remove fallback notification
Test / test (push) Successful in 1m1s Details
2024-10-19 20:30:56 -04:00
Jeff 3a56b9f853 Open notification to existing tab/window 2024-10-19 20:30:37 -04:00
Jeff 37f32cce3a Replace vapid key that accidentally got committed, show notifications in app
Test / test (push) Successful in 1m2s Details
2024-10-19 19:20:51 -04:00
Jeff e9c7d7a8ef Must ignore data-test
Test / test (push) Successful in 1m2s Details
2024-10-19 18:11:15 -04:00
Jeff 75087f2121 Pin postgres to 16
Test / test (push) Successful in 1m14s Details
2024-10-19 17:53:30 -04:00
Jeff a719706dff Fix local test port
Test / test (push) Successful in 59s Details
2024-10-19 17:44:38 -04:00
Jeff be55d46391 Separate ci env from test env
Test / test (push) Successful in 1m3s Details
2024-10-19 17:42:24 -04:00
Jeff 277ee9f2b5 Still confused, try changing port, again
Test / test (push) Successful in 1m2s Details
2024-10-19 17:36:23 -04:00
Jeff d1f20c4a2b Update test database host name
Test / test (push) Successful in 1m2s Details
2024-10-19 17:34:42 -04:00
Jeff b992443a99 Make a separate npm script for ci
Test / test (push) Successful in 1m1s Details
2024-10-19 17:27:00 -04:00
Jeff 8feb9bd37a Setup action doesn't work, try defining as a service I guess
Test / test (push) Failing after 31s Details
2024-10-19 17:25:17 -04:00
Jeff d66c72ce96 Don't set postgres version
Test / test (push) Failing after 15s Details
2024-10-19 17:21:20 -04:00
Jeff e88b953c58 Give up trying to docker in docker, just install postgres
Test / test (push) Failing after 15s Details
2024-10-19 17:18:44 -04:00
Jeff fdbf8c5ca5 Why the heck is docker ps showing containers on the host???
Test / test (push) Successful in 1m37s Details
2024-10-19 14:22:29 -04:00
Jeff 2031e8a3e7 Verify the problem is that the service hasn't started before tests execute
Test / test (push) Successful in 1m31s Details
2024-10-19 14:03:10 -04:00
Jeff 06e82c4f3f Run docker ps before migrating
Test / test (push) Successful in 1m24s Details
2024-10-19 13:56:58 -04:00
Jeff 5c230f8e5c Try different port? Not sure why the database connection is being refused
Test / test (push) Successful in 1m23s Details
2024-10-19 13:53:31 -04:00
Jeff 8aad5028ab Remove orphans
Test / test (push) Successful in 1m37s Details
2024-10-19 13:49:20 -04:00
Jeff 6f6a1bc2dc Try migrating again
Test / test (push) Successful in 1m22s Details
2024-10-19 13:38:56 -04:00
Jeff 6334b63631 Try reset again
Test / test (push) Successful in 1m23s Details
2024-10-19 13:36:36 -04:00
Jeff 26344fbdb2 Update playwright test version
Test / test (push) Successful in 1m25s Details
2024-10-19 13:34:04 -04:00
Jeff d2860dc454 Check sanity more closely
Test / test (push) Failing after 1m49s Details
2024-10-19 13:29:07 -04:00
Jeff cfd6a40859 Try logging something during tests
Test / test (push) Has been cancelled Details
2024-10-19 13:25:07 -04:00
Jeff 206ce42655 Destroy old db instance during subscription test when resetting db instance
Test / test (push) Has been cancelled Details
2024-10-19 13:22:27 -04:00
Jeff 20e7099317 Try re-enabling subscription test
Test / test (push) Has been cancelled Details
2024-10-19 13:16:10 -04:00
Jeff cbdaea7f75 Try a less suck action
Test / test (push) Successful in 1m54s Details
2024-10-19 13:12:27 -04:00
Jeff 384a65f2dc Yo dawg, I heard you like docker, so I put a docker in your docker
Test / test (push) Failing after 27s Details
2024-10-19 13:08:28 -04:00
Jeff 28bfe4c755 Try using the image again
Test / test (push) Failing after 1m34s Details
2024-10-19 12:54:13 -04:00
Jeff dfb20a1cbe Try usig playwright docker image
Test / test (push) Has been cancelled Details
2024-10-19 12:44:19 -04:00
Jeff 16530e0505 Install playwright deps
Test / test (push) Failing after 3m39s Details
2024-10-19 12:29:44 -04:00
Jeff 71d5706320 Run all playwright tests
Test / test (push) Failing after 2m11s Details
2024-10-19 12:25:40 -04:00
Jeff d7ef727558 See if skipping subscription tests succeeds
Test / test (push) Failing after 1m14s Details
2024-10-19 12:23:09 -04:00
Jeff 45ec236839 Try increasing node head space
Test / test (push) Failing after 1m43s Details
2024-10-19 12:18:39 -04:00
Jeff a475f7f4bf Remove some console logs
Test / test (push) Failing after 1m49s Details
2024-10-19 12:11:54 -04:00
Jeff c0821776c1 Remove db port mapping
Test / test (push) Failing after 1m51s Details
2024-10-18 11:42:42 -04:00
Jeff 34372bdb89 Fix jest not exiting, post subscription to worker to send to the server
Test / test (push) Has been cancelled Details
2024-10-18 11:38:41 -04:00
Jeff 49392ebc4f Fix Dockerfile
Test / test (push) Failing after 49s Details
2024-10-12 12:16:43 -04:00
Jeff 4021f8c95b Point to correct database
Test / test (push) Failing after 54s Details
2024-10-12 11:56:00 -04:00
Jeff 4d5f5543f8 Try to fix db password not being injected during migration
Test / test (push) Failing after 58s Details
2024-10-02 01:46:40 -04:00
Jeff dfea928ad2 Make postgres port the default
Test / test (push) Failing after 49s Details
2024-10-02 01:37:48 -04:00
Jeff 8a3be36048 Fix migrations to actually run
Test / test (push) Failing after 58s Details
2024-10-02 01:32:34 -04:00
Jeff 0cd1e061b7 Beat head against desk harder
Test / test (push) Failing after 54s Details
2024-10-02 01:19:47 -04:00
Jeff 3a4d670d04 Make things work with or without bundler
Test / test (push) Failing after 55s Details
2024-10-02 01:17:02 -04:00
Jeff b1bbe9a314 Trying to fix import again
Test / test (push) Failing after 57s Details
2024-10-02 01:12:39 -04:00
Jeff e9e4a61236 Fix import
Test / test (push) Failing after 55s Details
2024-10-02 01:10:33 -04:00
Jeff 7143f180f6 Persist push subscription object, store subscription id on the front end for overwriting subscription objects when subscription changes
Test / test (push) Failing after 51s Details
2024-10-02 01:07:51 -04:00
Jeff c1cfdb78de Add installation instructions for not iOS
Test / test (push) Failing after 2m12s Details
2024-09-25 14:17:54 -04:00
Jeff e86534dc86 Add playwright to gitea action
Test / test (push) Failing after 1m12s Details
2024-09-25 13:24:07 -04:00
Jeff a7c955e908 Add installation instructions for iOS
Test / test (push) Successful in 33s Details
2024-09-25 13:04:06 -04:00
64 changed files with 3449 additions and 1094 deletions

View File

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

6
.env.ci Normal file
View File

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

View File

@ -1 +1,4 @@
POSTGRES_PASSWORD="The password for the postgres database"
DATABASE="postgres"
POSTGRES_PASSWORD="password"
VAPID_PRIVATE_KEY=""
BASE_URL="http://localhost:5173"

6
.env.test Normal file
View File

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

View File

@ -5,6 +5,27 @@ on: [push]
jobs:
test:
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:
- uses: actions/checkout@v4
- run: npm install && npm run test
- name: Run Tests
env: NODE_OPTIONS=--max-old-space-size=8192
run: |
npm install
npm run ci
npm ci
npx playwright test

6
.gitignore vendored
View File

@ -1,9 +1,11 @@
.env
data/
/data/
/data-test/
node_modules/
build/
public/
/public/entry.worker.js
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
send-it.js

View File

@ -1,8 +1,9 @@
{
"trailingComma": "es5",
"tabWidth": 4,
"tabWidth": 2,
"semi": false,
"singleQuote": false,
"maxLineLength": 120,
"overrides": [
{
"files": [
@ -12,7 +13,7 @@
"*.spec.js"
],
"options": {
"maxLineLength": 9999999
"maxLineLength": 9999
}
}
]

54
.swcrc
View File

@ -1,31 +1,31 @@
{
"jsc": {
"target": "es2022",
"parser": {
"syntax": "typescript",
"tsx": true,
"decorators": false,
"dynamicImport": false
},
"transform": {
"react": {
"pragma": "React.createElement",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": false,
"runtime": "automatic"
},
"hidden": {
"jest": true
}
}
"jsc": {
"target": "es2022",
"parser": {
"syntax": "typescript",
"tsx": true,
"decorators": false,
"dynamicImport": false
},
"module": {
"type": "commonjs",
"strict": false,
"strictMode": true,
"lazy": false,
"noInterop": false
"transform": {
"react": {
"pragma": "React.createElement",
"pragmaFrag": "React.Fragment",
"throwIfNamespace": true,
"development": false,
"useBuiltins": false,
"runtime": "automatic"
},
"hidden": {
"jest": true
}
}
},
"module": {
"type": "commonjs",
"strict": false,
"strictMode": true,
"lazy": false,
"noInterop": false
}
}

View File

@ -1,6 +1,6 @@
FROM node
FROM node:22-alpine
WORKDIR /tack-up-now
COPY . .
RUN npm install
RUN npx -y remix vite:build
CMD npx tsx server.ts
CMD /bin/sh ./start.sh

14
api/app.ts Normal file
View File

@ -0,0 +1,14 @@
import express from "express"
import { postSubscription } from "./subscriptionEndpoints/postSubscription"
import { putSubscription } from "./subscriptionEndpoints/putSubscription"
import migrateToLatest from "./data/migrate"
const app = express()
app.use(express.json())
app.get("/api", (req, res) => res.send("HI"))
app.post("/api/subscription", postSubscription)
app.put("/api/subscription/:id/", putSubscription)
export default app

28
api/data/database.ts Normal file
View File

@ -0,0 +1,28 @@
import { SubscriptionTable } from "./subscription"
import pg from "pg"
const { Pool } = pg
export interface Database {
subscription: SubscriptionTable
}
import { Kysely, PostgresDialect } from "kysely"
import settings from "./settings"
const dialect = new PostgresDialect({
pool: new Pool({
...settings,
max: 10,
}),
})
export let db = new Kysely<Database>({
dialect,
})
export async function resetDbInstance() {
await db.destroy()
db = new Kysely<Database>({
dialect,
})
}

46
api/data/migrate.ts Normal file
View File

@ -0,0 +1,46 @@
import { promises as fs } from "fs"
import { FileMigrationProvider, Migrator } from "kysely"
import * as path from "path"
import { db } from "./database"
import { fileURLToPath } from "url"
let dirname
try {
dirname = __dirname
} catch {
const filename = fileURLToPath(import.meta.url)
dirname = path.dirname(filename)
}
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: path.join(dirname, "migrations"),
}),
})
async function migrateToLatest() {
const { error, results } = await migrator.migrateToLatest()
results?.forEach((it) => {
if (it.status === "Success") {
console.log(`migration "${it.migrationName}" was executed successfully`)
} else if (it.status === "Error") {
console.error(`failed to execute migration "${it.migrationName}"`)
}
})
if (error) {
console.error("failed to migrate")
console.error(error)
}
}
export async function resetAll() {
while ((await migrator.migrateDown()).error !== undefined) {}
}
export default migrateToLatest

View File

@ -0,0 +1,13 @@
import { Kysely } from "kysely"
export async function up(db: Kysely<any>) {
await db.schema
.createTable("subscription")
.addColumn("id", "serial", (col) => col.primaryKey())
.addColumn("subscription", "json")
.execute()
}
export async function down(db: Kysely<any>) {
await db.schema.dropTable("subscription").execute()
}

7
api/data/settings.ts Normal file
View File

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

View File

@ -0,0 +1,103 @@
import { db, resetDbInstance } from "./database"
import migrateToLatest, { resetAll } from "./migrate"
import {
createSubscription,
getSubscription,
Subscription,
updateSubscription,
} from "./subscription"
const pushSubscription1 = {
endpoint: "https://updates.push.services.mozilla.com/wpush/v2/aaaaaaa",
expirationTime: null,
keys: {
auth: "adfsadfasdf",
p256dh: "aaaaaaaaaaaa",
},
}
const pushSubscription2 = {
endpoint: "https://updates.push.services.mozilla.com/wpush/v2/bbbbbbbb",
expirationTime: null,
keys: {
auth: "whoahauth",
p256dh: "bbbbbbbb",
},
}
jest.mock("./settings", () => ({
port: process.env.POSTGRES_PORT,
user: "postgres",
password: process.env.POSTGRES_PASSWORD,
database: "test",
host: process.env.POSTGRES_HOST,
}))
describe("subscriptions", () => {
beforeAll(async () => {
await resetDbInstance()
})
beforeEach(async () => {
await migrateToLatest()
})
afterEach(async () => {
await resetAll()
})
afterAll(async () => {
await db.destroy()
})
test("createSubscription", async () => {
const { id } = await createSubscription(pushSubscription1)
const subscription = (await db
.selectFrom("subscription")
.selectAll()
.where("id", "=", id)
.executeTakeFirst()) as Subscription
expect(subscription).toEqual({
id,
subscription: pushSubscription1,
})
})
test("updateSubscription", async () => {
const { id } = (await db
.insertInto("subscription")
.values({ subscription: JSON.stringify(pushSubscription1), id: 50 })
.returning("id")
.executeTakeFirst())!
await updateSubscription(pushSubscription2, id)
const updated = (await db
.selectFrom("subscription")
.selectAll()
.where("id", "=", id)
.executeTakeFirst()) as Subscription
expect(updated).toEqual({
id,
subscription: pushSubscription2,
})
})
test("getSubscription", async () => {
const { id } = (await db
.insertInto("subscription")
.values({ subscription: JSON.stringify(pushSubscription1), id: 5000 })
.returning("id")
.executeTakeFirst())!
const got = await getSubscription(id)
expect(got).toEqual({
subscription: pushSubscription1,
id: 5000,
})
})
})

48
api/data/subscription.ts Normal file
View File

@ -0,0 +1,48 @@
import {
Generated,
Insertable,
JSONColumnType,
Selectable,
Updateable,
} from "kysely"
import { db } from "./database"
export async function createSubscription(
subscription: PushSubscriptionJSON
): Promise<Subscription> {
const inserted = await db
.insertInto("subscription")
.values({ subscription: JSON.stringify(subscription) })
.returningAll()
.executeTakeFirst()
return inserted!
}
export async function updateSubscription(
subscription: PushSubscriptionJSON,
id: number
): Promise<void> {
await db
.updateTable("subscription")
.set({ subscription: JSON.stringify(subscription) })
.where("id", "=", id)
.executeTakeFirst()
}
export async function getSubscription(id: number) {
return await db
.selectFrom("subscription")
.selectAll()
.where("id", "=", id)
.executeTakeFirst()
}
export interface SubscriptionTable {
id: Generated<number>
subscription: JSONColumnType<PushSubscriptionJSON>
}
export type Subscription = Selectable<SubscriptionTable>
export type NewSubscription = Insertable<SubscriptionTable>
export type SubscriptionUpdate = Updateable<SubscriptionTable>

3
api/data/upgrade.ts Normal file
View File

@ -0,0 +1,3 @@
import migrateToLatest from "./migrate"
migrateToLatest()

View File

@ -0,0 +1,47 @@
import request from "supertest"
import app from "./app"
import { createSubscription, updateSubscription } from "./data/subscription"
const pushSubscription = {
endpoint: "https://updates.push.services.mozilla.com/wpush/v2/aaaaaaa",
expirationTime: null,
keys: {
auth: "adfsadfasdf",
p256dh: "aaaaaaaaaaaa",
},
}
jest.mock("./data/subscription")
const mockedCreateSubscription = jest.mocked(createSubscription)
const mockedUpdateSubscription = jest.mocked(updateSubscription)
describe("/api/subscription", () => {
beforeEach(() => {
mockedCreateSubscription.mockImplementation((subscription) =>
Promise.resolve({ id: 40, subscription })
)
mockedUpdateSubscription.mockImplementation((subscription, id) =>
Promise.resolve({ id, subscription })
)
})
test("POST", async () => {
const response = await request(app)
.post("/api/subscription/")
.send(pushSubscription)
expect(JSON.parse(response.text)).toEqual({
subscriptionId: 40,
})
expect(mockedCreateSubscription).toHaveBeenCalledWith(pushSubscription)
})
test("PUT", async () => {
await request(app).put("/api/subscription/10/").send(pushSubscription)
expect(mockedUpdateSubscription).toHaveBeenCalledWith(pushSubscription, 10)
})
})

View File

@ -0,0 +1,11 @@
import type { Response, Request } from "express"
import { createSubscription } from "../data/subscription"
export async function postSubscription(req: Request, res: Response) {
const subscriptionBody = req.body
const { id: subscriptionId } = await createSubscription(subscriptionBody)
res.status(200).send({ subscriptionId })
}

View File

@ -0,0 +1,10 @@
import type { Response, Request } from "express"
import { updateSubscription } from "../data/subscription"
export async function putSubscription(req: Request, res: Response) {
const subscriptionBody = req.body
await updateSubscription(subscriptionBody, Number.parseInt(req.params.id))
res.status(200).send()
}

19
app/ClientOnly.tsx Normal file
View File

@ -0,0 +1,19 @@
import { ReactNode, useEffect, useState } from "react"
export default function ClientOnly({
fallback,
children,
}: {
fallback: ReactNode
children(): ReactNode
}) {
const isClient = typeof window !== "undefined"
const [rendered, setRendered] = useState(false)
useEffect(() => {
setRendered(true)
}, [])
return isClient && rendered ? children() : fallback
}

View File

@ -4,9 +4,10 @@
* For more information, see https://remix.run/file-conventions/entry.client
*/
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { RemixBrowser } from "@remix-run/react"
import React from "react"
import { startTransition, StrictMode } from "react"
import { hydrateRoot } from "react-dom/client"
startTransition(() => {
hydrateRoot(
@ -14,5 +15,5 @@ startTransition(() => {
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});
)
})

View File

@ -4,16 +4,16 @@
* For more information, see https://remix.run/file-conventions/entry.server
*/
import { PassThrough } from "node:stream";
import { PassThrough } from "node:stream"
import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";
import React from "react";
import type { AppLoadContext, EntryContext } from "@remix-run/node"
import { createReadableStreamFromReadable } from "@remix-run/node"
import { RemixServer } from "@remix-run/react"
import { isbot } from "isbot"
import { renderToPipeableStream } from "react-dom/server"
import React from "react"
const ABORT_DELAY = 5_000;
const ABORT_DELAY = 5_000
export default function handleRequest(
request: Request,
@ -37,7 +37,7 @@ export default function handleRequest(
responseStatusCode,
responseHeaders,
remixContext
);
)
}
function handleBotRequest(
@ -47,7 +47,7 @@ function handleBotRequest(
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
@ -56,38 +56,38 @@ function handleBotRequest(
/>,
{
onAllReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set("Content-Type", "text/html");
responseHeaders.set("Content-Type", "text/html")
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
)
pipe(body);
pipe(body)
},
onShellError(error: unknown) {
reject(error);
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500;
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
console.error(error)
}
},
}
);
)
setTimeout(abort, ABORT_DELAY);
});
setTimeout(abort, ABORT_DELAY)
})
}
function handleBrowserRequest(
@ -97,7 +97,7 @@ function handleBrowserRequest(
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
@ -106,36 +106,36 @@ function handleBrowserRequest(
/>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set("Content-Type", "text/html");
responseHeaders.set("Content-Type", "text/html")
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
)
pipe(body);
pipe(body)
},
onShellError(error: unknown) {
reject(error);
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500;
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
console.error(error)
}
},
}
);
)
setTimeout(abort, ABORT_DELAY);
});
setTimeout(abort, ABORT_DELAY)
})
}

9
app/entry.worker.ts Normal file
View File

@ -0,0 +1,9 @@
/// <reference lib="WebWorker" />
export {}
declare let self: ServiceWorkerGlobalScope
import initWorker from "./worker"
initWorker(self)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 947 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1 +0,0 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -0,0 +1,49 @@
import React from "react"
import { useEffect } from "react"
import { usePush } from "../usePush"
import pushPublicKey from "../../pushPublicKey"
import urlB64ToUint8Array from "../../b64ToUInt8"
export default function EnableNotifications({
onSubscribe,
}: {
onSubscribe: () => void
}) {
return (
<div>
<h1>Allow Notifications</h1>
<div>
Tack Up Now requires your permission to send notifications in order to
function properly
</div>
<EnableButton onSubscribe={onSubscribe} />
</div>
)
}
function EnableButton({ onSubscribe }: { onSubscribe: () => void }) {
const { subscribeToPush, requestPermission, canSendPush } = usePush()
function subscribe() {
requestPermission()
}
useEffect(() => {
if (!canSendPush) return
subscribeToPush(
urlB64ToUint8Array(pushPublicKey) as any,
async (subscription) => {
await navigator.serviceWorker.ready.then((registration) => {
registration.active?.postMessage({
type: "subscribed",
subscription: subscription.toJSON(),
})
})
onSubscribe()
}
)
}, [canSendPush])
return <button onClick={subscribe}>Enable Notifications</button>
}

View File

@ -0,0 +1,32 @@
import React from "react"
import shareIcon from "../images/safari-share-icon.png?url"
export default function InstallPWA() {
return (
<div>
<h1>Install Tack Up Now!</h1>
<div>
Install Tack Up Now on your device to get notified when it's time to
tack up
</div>
<div>
<ul>
<span>Tap </span>
<img style={{ width: "2rem" }} src={shareIcon} />
<span> and choose </span>
<span className="bold">Add to Home Screen</span>
</ul>
<ul>
<span>On the next screen, tap </span>
<span className="bold">Add</span>
</ul>
<ul>
<span>Then open </span>
<span className="bold">Tack Up Now</span>
<span> from your home screen</span>
</ul>
</div>
</div>
)
}

View File

@ -0,0 +1,36 @@
import React from "react"
import useInstallState from "../useInstallState"
import EnableNotifications from "./EnableNotifications"
import OpenSafari from "./OpenSafari"
import InstallPWA from "./InstallPWA"
import Unsupported from "./Unsupported"
import PermissionDenied from "./PermissionDenied"
interface InstallPromptsProps {
isMobileSafari: boolean
isSupported: boolean
isIOS: boolean
notificationsEnabled: boolean
onInstallComplete: () => void
}
export default function InstallPrompts({
isSupported,
isMobileSafari,
isIOS,
onInstallComplete,
}: InstallPromptsProps) {
const { step } = useInstallState({ isSupported, isMobileSafari, isIOS })
const steps = {
"open safari": <OpenSafari />,
install: <InstallPWA />,
"enable notifications": (
<EnableNotifications onSubscribe={onInstallComplete} />
),
unsupported: <Unsupported />,
"permission denied": <PermissionDenied />,
}
return steps[step as keyof typeof steps] ?? null
}

View File

@ -0,0 +1,11 @@
import React from "react"
export default function OpenSafari() {
return (
<div>
<div>This device requires Tack Up Now to be installed using Safari</div>
<br />
<div>Open tackupnow.com in Safari to continue!</div>
</div>
)
}

View File

@ -0,0 +1,18 @@
import React from "react"
export default function PermissionDenied() {
return (
<div>
<h1>Enable Notifications</h1>
<div>
<span className="bold">Tack Up Now</span> requires notifications
permissions to work
</div>
<br />
<div>
You have denied permission to send notifications, please grant
notifications permissions to use Tack Up Now
</div>
</div>
)
}

View File

@ -0,0 +1,22 @@
import React, { useState } from "react"
export default function Unsupported() {
const [showWhy, setShowWhy] = useState(false)
return (
<div>
<h1>{"Sorry :("}</h1>
<br />
<div>Your device doesn't support Tack Up Now!</div>
{showWhy ? (
<div>
iOS 16.3 and under does not support notification delivery through web
apps, so Tack Up Now can't send notifications to your device.
</div>
) : (
<button onClick={() => setShowWhy(true)}>Why not?</button>
)}
</div>
)
}

View File

@ -1,45 +0,0 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import MatchMediaMock from 'jest-matchmedia-mock';
import { createRemixStub, RemixStubProps } from "@remix-run/testing"
import App from './root'
import React from 'react'
let RemixStub: (props: RemixStubProps) => React.JSX.Element
describe("root", () => {
beforeEach(() => {
RemixStub = createRemixStub([
{
path: "/",
meta: () => ([]),
links: () => ([]),
loader: () => ({ isSupported: true}),
Component: App
}
])
})
let matchMedia: MatchMediaMock
beforeAll(() => {
matchMedia = new MatchMediaMock();
})
afterEach(() => {
matchMedia.clear();
})
describe("when user is on iOS", () => {
describe("version 16.4 or higher", () => {
describe("and tack up now is not already installed on their device", () => {
test("they are instructed to install tack up now", async () => {
render(<RemixStub/>)
await waitFor(() => screen.findByText<HTMLElement>(/Install/), { timeout: 2000 })
expect(true).toBe(true)
})
})
})
})
})

View File

@ -1,71 +1,36 @@
import { json, LoaderFunctionArgs } from "@remix-run/node"
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react"
import React, { useEffect } from "react"
import UAParser from "ua-parser-js"
import versionAtLeast from "semver/functions/gte"
import coerceSemver from "semver/functions/coerce"
// import { LandingMessage } from "./root.client"
import React from "react"
import { ManifestLink } from "@remix-pwa/sw"
import { LinksFunction } from "@remix-run/node"
import styles from "./styles/styles.css?url"
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }]
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userAgent = request.headers.get("user-agent")
const os = new UAParser(userAgent ?? "").getOS()
const isSupported =
os.name !== "iOS" ||
versionAtLeast(coerceSemver(os.version) ?? "0.0.0", "16.4.0")
return json({ isSupported })
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<ManifestLink />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
export default function App() {
const { isSupported } = useLoaderData<typeof loader>()
return <><Outlet/><LandingMessage isSupported={isSupported}/></>
}
function LandingMessage({ isSupported }: { isSupported: boolean }) {
useEffect(() => {
console.log("WTF")
}, [])
if (typeof window === "undefined") return <div>WHY</div>
const isRunningPWA = "standalone" in navigator && navigator.standalone || matchMedia("(dislay-mode: standalone)").matches
const message = isRunningPWA
? "Enable notifications"
: isSupported
? "Install Tack Up Now!"
: "Sorry, your device doesn't support Tack Up Now! :("
return <div>{message}</div>
return <Outlet />
}

View File

@ -1,18 +1,113 @@
import type { MetaFunction } from "@remix-run/node";
import React from "react";
import {
json,
type LoaderFunctionArgs,
type MetaFunction,
} from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
import React, { useEffect, useRef, useState } from "react"
import coerceSemver from "semver/functions/coerce"
import versionAtLeast from "semver/functions/gte"
import UAParser from "ua-parser-js"
import ClientOnly from "../ClientOnly"
import InstallPrompts from "../install/InstallPrompts"
import useInstallState from "../useInstallState"
export const meta: MetaFunction = () => {
return [
{ title: "Tack Up Now!" },
{ name: "description", content: "Get equinelive notifications" },
];
};
]
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const userAgent = request.headers.get("user-agent")
const parsedUserAgent = new UAParser(userAgent ?? "")
const os = parsedUserAgent.getOS()
const isMobileSafari = parsedUserAgent.getBrowser().name === "Mobile Safari"
const isSupported =
os.name !== "iOS" ||
versionAtLeast(coerceSemver(os.version) ?? "0.0.0", "16.4.0")
const isIOS = os.name === "iOS"
const cookies = parseCookies(request.headers.get("Cookie"))
const userIdCookie = cookies?.find(({ key }) => key === "userId")
const newUserId =
userIdCookie === undefined ? Math.floor(Math.random()) * 5 : null
return json(
{
isSupported,
isMobileSafari,
isIOS,
},
{
headers:
newUserId !== null && isSupported && isIOS && isMobileSafari
? {
"Set-Cookie": `userId=${newUserId}`,
}
: {},
}
)
}
export default function Index() {
const { isSupported, isMobileSafari, isIOS } = useLoaderData<typeof loader>()
return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<h1>WHOAH! A WEBSITE</h1>
<div>Hey check it out!</div>
<TackUpNow
isSupported={isSupported}
isMobileSafari={isMobileSafari}
isIOS={isIOS}
/>
</div>
);
)
}
function parseCookies(cookieString: string | null | undefined) {
if (cookieString === null || cookieString === undefined) return
const allCookieStrings = cookieString.split(";")
const cookies = allCookieStrings.map((string) => {
const [key, value] = string.trim().split("=")
return { key, value }
})
return cookies
}
function TackUpNow({
isSupported,
isMobileSafari,
isIOS,
}: {
isSupported: boolean
isMobileSafari: boolean
isIOS: boolean
}) {
const { installed } = useInstallState({ isSupported, isMobileSafari, isIOS })
const [isInstalled, setIsInstalled] = useState(installed)
return (
<ClientOnly fallback={<div>Loading</div>}>
{() =>
isInstalled ? (
<div>Your Notifications</div>
) : (
<InstallPrompts
isMobileSafari={isMobileSafari}
isSupported={isSupported}
isIOS={isIOS}
notificationsEnabled={false}
onInstallComplete={() => setIsInstalled(true)}
/>
)
}
</ClientOnly>
)
}

View File

@ -0,0 +1,35 @@
import type { WebAppManifest } from "@remix-pwa/dev"
import { json } from "@remix-run/node"
export const loader = () => {
return json(
{
name: "Tack Up Now!",
short_name: "Tack Up Now!",
icons: [
{
src: "./images/192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "./images/512.png",
sizes: "512x512",
type: "image/png",
},
],
theme_color: "#ffffff",
background_color: "#ffffff",
display: "standalone",
description: "Notifications for equinelive.com",
start_url: "/",
id: "tackupnow.com",
} as WebAppManifest,
{
headers: {
"Cache-Control": "public, max-age=600",
"Content-Type": "application/manifest+json",
},
}
)
}

10
app/styles/styles.css Normal file
View File

@ -0,0 +1,10 @@
.bold {
font-weight: bold;
}
ul {
display: flex;
flex-direction: row;
align-items: center;
white-space: pre-wrap;
}

63
app/useInstallState.ts Normal file
View File

@ -0,0 +1,63 @@
import { usePush } from "./usePush"
type IOSInstallStep =
| "loading"
| "install"
| "open safari"
| "enable notifications"
| "unsupported"
| "permission denied"
export default function useInstallState({
isSupported,
isMobileSafari,
isIOS,
}: {
isSupported: boolean
isMobileSafari: boolean
isIOS: boolean
}) {
const isClient = typeof window !== "undefined"
if (!isClient)
return {
step: isSupported ? "loading" : ("unsupported" as IOSInstallStep),
installed: false,
}
const { canSendPush } = usePush()
const notificationsEnabled =
("Notification" in window &&
window.Notification.permission === "granted") ||
canSendPush
const permissionDenied =
"Notification" in window && window.Notification.permission === "denied"
const isRunningPWA =
("standalone" in navigator && (navigator.standalone as boolean)) ||
matchMedia("(dislay-mode: standalone)").matches
const iOSStates = [
state(!isMobileSafari, "open safari"),
state(!isRunningPWA, "install"),
]
const states = [
state(!isSupported, "unsupported"),
...(isIOS ? iOSStates : []),
state(permissionDenied, "permission denied"),
state(!notificationsEnabled, "enable notifications"),
]
return {
step: states.find(({ active }) => active)?.result ?? null,
installed: notificationsEnabled,
}
}
const state = (active: boolean, result: IOSInstallStep) => ({
active,
result,
})

181
app/usePush.ts Normal file
View File

@ -0,0 +1,181 @@
import { useEffect, useState } from "react"
export type PushObject = {
/**
* Boolean state indicating whether the user is subscribed to push notifications or not.
*/
isSubscribed: boolean
/**
* The push subscription object
*/
pushSubscription: PushSubscription | null
/**
* Request permission for push notifications
* @returns The permission status of the push notifications
*/
requestPermission: () => NotificationPermission
/**
* Utility to subscribe to push notifications
* @param publicKey the public vapid key
* @param callback a callback function to be called when the subscription is successful
* @param errorCallback a callback function to be called if the subscription fails
*/
subscribeToPush: (
publicKey: string,
callback?: (subscription: PushSubscription) => void,
errorCallback?: (error: any) => void
) => void
/**
* Utility to unsubscribe from push notifications
* @param callback a callback function to be called when the unsubscription is successful
* @param errorCallback a callback function to be called if the unsubscription fails
*/
unsubscribeFromPush: (
callback?: () => void,
errorCallback?: (error: any) => void
) => void
/**
* Boolean state indicating whether the user has allowed sending of push notifications or not.
*/
canSendPush: boolean
}
/**
* Push API hook - contains all your necessities for handling push notifications in the client
*/
export const usePush = (): PushObject => {
const [swRegistration, setSWRegistration] =
useState<ServiceWorkerRegistration | null>(null)
const [isSubscribed, setIsSubscribed] = useState<boolean>(false)
const [pushSubscription, setPushSubscription] =
useState<PushSubscription | null>(null)
const [canSendPush, setCanSendPush] = useState<boolean>(false)
const requestPermission = () => {
if (canSendPush) return "granted"
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
setCanSendPush(true)
return permission
} else {
setCanSendPush(false)
return permission
}
})
return "default"
}
const subscribeToPush = (
publicKey: string,
callback?: (subscription: PushSubscription) => void,
errorCallback?: (error: any) => void
) => {
if (swRegistration === null || swRegistration.pushManager === undefined)
return
swRegistration.pushManager
.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey,
})
.then(
(subscription) => {
setIsSubscribed(true)
setPushSubscription(subscription)
callback && callback(subscription)
},
(error) => {
errorCallback && errorCallback(error)
}
)
}
const unsubscribeFromPush = (
callback?: () => void,
errorCallback?: (error: any) => void
) => {
if (swRegistration === null || swRegistration.pushManager === undefined)
return
swRegistration.pushManager
.getSubscription()
.then((subscription) => {
if (subscription) {
subscription.unsubscribe().then(
() => {
setIsSubscribed(false)
setPushSubscription(null)
callback && callback()
},
(error) => {
errorCallback && errorCallback(error)
}
)
}
})
.catch((error) => {
errorCallback && errorCallback(error)
})
}
useEffect(() => {
if (typeof window === "undefined") return
const getRegistration = async () => {
if ("serviceWorker" in navigator) {
try {
const _registration = await navigator.serviceWorker.getRegistration()
setSWRegistration(_registration ?? null)
} catch (err) {
console.error("Error getting service worker registration:", err)
}
}
}
const handleControllerChange = () => {
getRegistration()
}
if ("serviceWorker" in navigator) {
navigator.serviceWorker.addEventListener(
"controllerchange",
handleControllerChange
)
}
getRegistration()
return () => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.removeEventListener(
"controllerchange",
handleControllerChange
)
}
}
}, [])
useEffect(() => {
if (swRegistration && swRegistration.pushManager !== undefined) {
swRegistration.pushManager.getSubscription().then((subscription) => {
setIsSubscribed(!!subscription)
setPushSubscription(subscription)
})
Notification.permission === "granted"
? setCanSendPush(true)
: setCanSendPush(false)
}
}, [swRegistration])
return {
isSubscribed,
pushSubscription,
requestPermission,
subscribeToPush,
unsubscribeFromPush,
canSendPush,
}
}

436
app/worker.test.ts Normal file
View File

@ -0,0 +1,436 @@
import "fake-indexeddb/auto"
import "core-js/stable/structured-clone"
import initWorker from "./worker"
import Dexie, { EntityTable } from "dexie"
function createSubscription(testToken = "HI") {
return {
toJSON: () => ({
subscription: testToken,
}),
}
}
const createSelf = () =>
({
addEventListener: jest.fn(),
skipWaiting: jest.fn(() => Promise.resolve()),
registration: {
pushManager: {
subscribe: jest.fn(() => Promise.resolve(createSubscription())),
getSubscription: jest.fn(() => Promise.resolve(createSubscription())),
},
showNotification: jest.fn(),
},
clients: {
claim: jest.fn(() => Promise.resolve()),
openWindow: jest.fn(() => Promise.resolve()),
matchAll: jest.fn(() => Promise.resolve([])),
},
}) as unknown as ServiceWorkerGlobalScope
let originalWindow: Window & typeof globalThis
describe("service worker", () => {
let self: ServiceWorkerGlobalScope
let controlledClients: Client[]
let uncontrolledClients: Client[]
beforeEach(() => {
originalWindow = global.window
global.window = undefined as any
indexedDB = new IDBFactory()
self = createSelf()
controlledClients = []
uncontrolledClients = []
fetchMock.mockResponse((req) => {
if (
req.url.replace(/https?:\/\/[a-zA-Z0-9\.]*/, "") ===
"/api/subscription/" &&
req.method === "POST"
) {
return Promise.resolve({
body: JSON.stringify({ subscriptionId: 123 }),
})
} else if (
req.url
.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(() => {
jest.clearAllMocks()
global.window = originalWindow
})
test("displays push notifications", async () => {
initWorker(self)
const pushHandler = getHandler("push")
const pushEvent = createPushEvent(
JSON.stringify({
title: "Test title",
body: "Test text",
icon: "Test icon",
badge: "Test badge",
destination: "tackupnow.com/hi",
})
)
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 () => {
initWorker(self)
const activateHandler = getHandler("activate")
const event = createEvent()
activateHandler(event)
await waitUntilCalls(event)
expect(self.clients.claim).toHaveBeenCalled()
})
test("skips waiting on install", async () => {
initWorker(self)
const installHandler = getHandler("install")
const event = createEvent()
installHandler(event)
await waitUntilCalls(event)
expect(self.skipWaiting).toHaveBeenCalled()
})
describe("on subscription message", () => {
test("puts push subscription if the subscription id is set", async () => {
initWorker(self)
await setSavedSubscription(5000)
const messageHandler = getHandler("message")
const messageEvent = createEvent({
data: {
subscription: { subscription: "YO" },
type: "subscribed",
},
})
messageHandler(messageEvent)
await waitUntilCalls(messageEvent)
expect(fetch).toHaveBeenCalledWith(
`${process.env.BASE_URL}/api/subscription/5000`,
{
method: "PUT",
body: JSON.stringify({ subscription: "YO" }),
headers: {
"Content-Type": "application/json",
},
}
)
})
test("posts to subscriptions when there is no user set", async () => {
initWorker(self)
const messageHandler = getHandler("message")
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
const client: WindowClient = {
url,
type,
focus: jest.fn(() => Promise.resolve(client)) as WindowClient["focus"],
} as WindowClient
clientList.push(client)
return client
}
function getHandler(eventName: string) {
expect(self.addEventListener).toHaveBeenCalledWith(
eventName,
expect.anything()
)
const [, handler] = (self.addEventListener as jest.Mock).mock.calls.find(
([event]: [string]) => event === eventName
)
return handler
}
})
async function getSavedSubscription() {
const database = new Dexie("tack-up-now", { indexedDB }) as Dexie & {
subscriptions: EntityTable<{ id: number; subscriptionId: number }, "id">
}
database.version(1).stores({ subscriptions: "id++, subscriptionId&" })
return (await database.subscriptions.toArray())[0]
}
async function setSavedSubscription(subscriptionId: number) {
const database = new Dexie("tack-up-now", { indexedDB }) as Dexie & {
subscriptions: EntityTable<{ id: number; subscriptionId: number }, "id">
}
database.version(1).stores({ subscriptions: "id++, subscriptionId&" })
await database.subscriptions.put({ id: 1, subscriptionId })
}
function createPushEvent(data: string) {
const pushFields = {
data: {
blob: () => new Blob([data], { type: "text/plain" }),
json: () => JSON.parse(data),
text: () => data,
arrayBuffer: () => new TextEncoder().encode(data),
},
}
return createEvent(pushFields)
}
function createNotificationEvent(destination: string): NotificationEvent & {
waitUntil: jest.Mock<void, Parameters<ExtendableEvent["waitUntil"]>>
} {
return createEvent({
notification: {
close: jest.fn(),
data: {
destination,
},
},
}) as any as NotificationEvent & {
waitUntil: jest.Mock<void, Parameters<ExtendableEvent["waitUntil"]>>
}
}
function createEvent(properties?: object) {
return {
waitUntil: jest.fn(),
...properties,
}
}
function waitUntilCalls(event: {
waitUntil: jest.Mock<void, Parameters<ExtendableEvent["waitUntil"]>>
}) {
return Promise.all(event.waitUntil.mock.calls.map(([promise]) => promise))
}

127
app/worker.ts Normal file
View File

@ -0,0 +1,127 @@
import Dexie, { EntityTable } from "dexie"
import urlB64ToUint8Array from "../b64ToUInt8"
import pushPublicKey from "../pushPublicKey"
interface SubscriptionRecord {
id: number
subscriptionId: number
}
export default function start(self: ServiceWorkerGlobalScope) {
self.addEventListener("install", (event) => {
event.waitUntil(self.skipWaiting())
})
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim())
})
self.addEventListener("push", function (event: PushEvent) {
const { title, body, badge, icon, destination } = event.data?.json()
event.waitUntil(
self.registration.showNotification(title, {
body,
badge,
icon,
data: { destination },
})
)
})
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) {
const waitEvent = event as ExtendableEvent
if ("type" in event.data && event.data.type === "subscribed") {
waitEvent.waitUntil(submitSubscription(event.data.subscription))
}
})
self.addEventListener("pushsubscriptionchange", function (event) {
const waitEvent = event as ExtendableEvent
waitEvent.waitUntil(
(async () => {
const applicationServerKey = urlB64ToUint8Array(pushPublicKey)
const newSubscription = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey,
})
await submitSubscription(newSubscription.toJSON())
})()
)
})
}
async function submitSubscription(subscription: PushSubscriptionJSON) {
const db = database()
const existingSubscriptionId = await db.subscriptions.get(1)
await (existingSubscriptionId === undefined
? postSubscription(subscription, db)
: putSubscription(subscription, existingSubscriptionId.subscriptionId))
}
async function postSubscription(
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({
id: 1,
subscriptionId: (await response.json()).subscriptionId,
})
}
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() {
const db = new Dexie("tack-up-now", { indexedDB }) as Dexie & {
subscriptions: EntityTable<SubscriptionRecord, "id">
}
db.version(1).stores({ subscriptions: "id++, subscriptionId&" })
return db
}

12
b64ToUInt8.ts Normal file
View File

@ -0,0 +1,12 @@
export default function urlB64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/")
const rawData = atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}

11
docker-compose-test.yaml Normal file
View File

@ -0,0 +1,11 @@
services:
database:
image: postgres:16
ports:
- "5434:5432"
environment:
POSTGRES_PASSWORD: testpassword
POSTGRES_USER: postgres
POSTGRES_DB: test
volumes:
- ./data-test:/var/lib/postgresql/data

View File

@ -3,13 +3,12 @@ services:
build: .
ports:
- "9000:3000"
env_file: ".env"
database:
image: postgres
ports:
- "5433:5432"
image: postgres:16
environment:
POSTGRES_PASSWORD: $POSTGRES_PASSWORD
POSTGRES_USER: postgres
POSTGRES_DB: postgres
env_file: ".env"
volumes:
- ./data:/var/lib/postgresql/data
- ./data:/var/lib/postgresql/data

View File

@ -1,77 +1,279 @@
import { test, expect } from "@playwright/test"
import { test, expect, Page } from "@playwright/test"
import matchPath from "./urlMatcher"
import { messageSW } from "@remix-pwa/sw"
const { describe, beforeEach, skip, use } = test
function webkitOnly() {
beforeEach(async ({ browserName }) => {
if (browserName !== "webkit") skip()
})
beforeEach(async ({ browserName }) => {
if (browserName !== "webkit") skip()
})
}
function nonWebkitOnly() {
beforeEach(async ({ browserName }) => {
if (browserName === "webkit") skip()
})
}
describe("when user is on iOS", () => {
webkitOnly()
webkitOnly()
describe("version 16.4 or higher", () => {
use({
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1",
})
test("and tack up now is not running as a PWA, they are instructed to install tack up now", async ({ page }) => {
await page.goto("/")
const installText = await page.getByText(/Install Tack Up Now!/)
await expect(installText).toBeAttached()
})
describe("and tack up now is running as a PWA", () => {
beforeEach(async ({ page }) => {
await page.addInitScript(() => (window.navigator as any)["standalone"] = true)
})
test("and notifications aren't enabled, they are asked to enable notifications", async ({ page, browser }) => {
await page.goto("/")
const notificationText = await page.getByText(/Enable notifications/)
await expect(notificationText).toBeAttached()
})
// describe("and notifications are enabled", () => {
// beforeEach(async ({ page }) => {
// await page.addInitScript(() => (window.persmis))
// })
// test("they aren't asked to enable notifications", async ({ browser }) => {
// const context = await browser.newContext({
// permissions: ['notification'],
// });
// const page = await context.newPage()
// await page.goto("/")
// const notificationText = await page.getByText(/Enable notifications/)
// await expect(notificationText).not.toBeAttached()
// })
// })
})
describe("version 16.4 or higher", () => {
use({
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1",
})
describe("version 16.3 and under", () => {
use({
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1",
describe("and the browser is not safari", () => {
use({
userAgent:
"Mozilla/5.0 (iPhone; U; CPU iPhone OS 16_4 like Mac OS X; en-gb) AppleWebKit/534.46.0 (KHTML, like Gecko) CriOS/19.0.1084.60 Mobile/9B206 Safari/7534.48.3",
})
test("the user is told they need to install through Safari, because reasons, I guess", async ({
page,
}) => {
await page.goto("/")
const safariText = await page.getByText(/Open tackupnow.com in Safari/)
await expect(safariText).toBeAttached()
})
})
test("and tack up now is not running as a PWA, they are instructed to install tack up now", async ({
page,
}) => {
await page.goto("/")
const installText = await page.getByText(/Install Tack Up Now!/)
await expect(installText).toBeAttached()
})
describe("and tack up now is running as a PWA", () => {
beforeEach(async ({ page }) => {
await page.addInitScript(
() => ((window.navigator as any)["standalone"] = true)
)
await stubServiceWorker(page)
})
describe("and notifications aren't enabled", () => {
test("they are asked to enable notifications", async ({ page }) => {
await stubNotifications(page, { permission: "default" })
await page.goto("/")
const enableButton = await page.getByText(/Enable Notifications/)
await expect(enableButton).toBeAttached()
})
test("version 16.3 and under they are informed that their iOS version isn't supported", async ({ page }) => {
describe("and then the user enables notifications", () => {
beforeEach(async ({ page }) => {
await stubNotifications(page, {
permission: "default",
requestPermissionResult: "granted",
})
})
test("users see tack up now after enabling notifications", async ({
page,
}) => {
await page.goto("/")
const sorryText = await page.getByText("Sorry, your device doesn't support Tack Up Now! :(")
await page.getByText(/Enable Notifications/).click()
await expect(sorryText).toBeVisible()
const yourNotificationsHeading =
page.getByText(/Your Notifications/)
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",
},
})
})
})
})
test("and notifications have been denied", async ({ page }) => {
await stubNotifications(page, { permission: "denied" })
await page.goto("/")
const deniedText = page.getByText(/requires notifications permissions/)
await expect(deniedText).toBeAttached()
})
describe("and notifications are enabled", () => {
beforeEach(async ({ page }) => {
await stubNotifications(page, { permission: "granted" })
})
test("they aren't asked to enable notifications", async ({ page }) => {
await page.goto("/")
await page.evaluate(async () => await navigator.serviceWorker.ready)
const notificationText = page.getByText(/Enable Notifications/)
await expect(notificationText).not.toBeAttached()
})
test("users see tack up now", async ({ page }) => {
await page.goto("/")
const yourNotificationsHeading = page.getByText(/Your Notifications/)
await expect(yourNotificationsHeading).toBeVisible()
})
})
})
})
describe("version 16.3 and under", () => {
use({
userAgent:
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Mobile/15E148 Safari/604.1",
})
test("version 16.3 and under they are informed that their iOS version isn't supported", async ({
page,
}) => {
await page.goto("/")
const sorryText = page.getByText(/Sorry/)
await expect(sorryText).toBeVisible()
})
})
})
describe("other browsers", () => {
nonWebkitOnly()
beforeEach(async ({ page }) => {
await page.route(matchPath(page, "/api/subscription"), async (route) => {
await route.fulfill()
})
await stubServiceWorker(page)
})
test("prompt the user to allow notifications", async ({ page }) => {
await stubNotifications(page, { permission: "default" })
await page.goto("/")
const notificationText = page.getByText(/Enable Notifications/)
await expect(notificationText).toBeAttached()
})
test("show tack up now when permission is granted", async ({ page }) => {
await stubNotifications(page, { permission: "granted" })
await page.goto("/")
const yourNotificationsHeading = page.getByText(/Your Notifications/)
await expect(yourNotificationsHeading).toBeVisible()
})
test("prompt to allow notifications if permission was denied", async ({
page,
}) => {
await stubNotifications(page, { permission: "denied" })
await page.goto("/")
const deniedText = page.getByText(/requires notifications permissions/)
await expect(deniedText).toBeAttached()
})
})
function stubNotifications(
page: Page,
args: {
permission: NotificationPermission
requestPermissionResult?: NotificationPermission
}
) {
return page.addInitScript(
(args: {
permission: NotificationPermission
requestPermissionResult?: NotificationPermission
}) => {
window.Notification = window.Notification ?? {}
Object.defineProperty(window.Notification, "permission", {
value: args.permission,
writable: true,
})
Object.defineProperty(window.Notification, "requestPermission", {
value: () =>
Promise.resolve(args.requestPermissionResult ?? args.permission),
})
},
args
)
}
function stubServiceWorker(page: Page) {
return page.addInitScript(() => {
function createSubscription(testToken = "HI") {
return {
toJSON: () => ({
subscription: testToken,
}),
}
}
let postedMessages: any[] = []
const serviceWorker = {
postMessage: (message: any) => postedMessages.push(message),
}
const registration = {
pushManager: {
getSubscription() {
return Promise.resolve(createSubscription())
},
subscribe(args: Parameters<PushManager["subscribe"]>[0]) {
return Promise.resolve(createSubscription())
},
},
active: serviceWorker,
}
Object.defineProperty(window, "postedMessages", {
value: postedMessages,
writable: true,
})
Object.defineProperty(navigator, "serviceWorker", {
value: {
ready: Promise.resolve(registration),
register() {
return Promise.resolve(registration)
},
getRegistration() {
return Promise.resolve(registration)
},
addEventListener() {},
removeEventListener() {},
},
writable: false,
})
})
}

6
e2e/urlMatcher.ts Normal file
View File

@ -0,0 +1,6 @@
import { Page, Request } from "@playwright/test"
export default function matchPath(page: Page, path: string) {
return (url: URL | Request) =>
new URL(page.url()).hostname === url.hostname && url.pathname === path
}

View File

@ -4,15 +4,17 @@ const ignorePatterns = [
"\\/\\.vscode\\/",
"\\/\\.tmp\\/",
"\\/\\.cache\\/",
"data",
"e2e"
];
"^\\/data/",
"^\\/data-test/",
"e2e",
]
module.exports = {
moduleNameMapper: {
"^@web3-storage/multipart-parser$": require.resolve(
"@web3-storage/multipart-parser"
),
"\\?url$": "<rootDir>/url-mock.js",
},
modulePathIgnorePatterns: ignorePatterns,
transform: {
@ -28,4 +30,4 @@ module.exports = {
setupFiles: [],
testEnvironment: "jsdom",
setupFilesAfterEnv: ["./setupTests.ts", "@testing-library/jest-dom"],
};
}

2271
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,22 +9,28 @@
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve ./build/server/index.js",
"typecheck": "tsc",
"watch": "jest --watch --config=jest.config.ts",
"test": "jest --config=jest.config.ts"
"watch": "export $(cat .env.test | xargs) && docker compose -f docker-compose-test.yaml up database -d --remove-orphans && 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",
"ci": "export $(cat .env.ci | xargs) && jest --config=jest.config.ts"
},
"dependencies": {
"@remix-pwa/sw": "^3.0.9",
"@remix-pwa/worker-runtime": "^2.1.4",
"@remix-run/express": "^2.9.1",
"@remix-run/node": "^2.9.0",
"@remix-run/react": "^2.9.0",
"@remix-run/serve": "^2.9.0",
"cross-env": "^7.0.3",
"dexie": "^4.0.8",
"express": "^4.19.2",
"isbot": "^4.1.0",
"kysely": "^0.27.4",
"pg": "^8.13.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remix-utils": "^7.6.0",
"semver": "^7.6.3",
"ua-parser-js": "^1.0.39"
"ua-parser-js": "^1.0.39",
"web-push": "^3.6.7"
},
"devDependencies": {
"@babel/core": "^7.24.5",
@ -33,7 +39,8 @@
"@babel/preset-env": "^7.24.5",
"@babel/preset-react": "^7.24.1",
"@babel/preset-typescript": "^7.24.1",
"@playwright/test": "^1.44.1",
"@playwright/test": "^1.48.1",
"@remix-pwa/dev": "^3.1.0",
"@remix-run/dev": "^2.9.0",
"@remix-run/testing": "^2.9.1",
"@swc/core": "^1.5.7",
@ -43,6 +50,7 @@
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.5",
"@types/pg": "^8.11.10",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/semver": "^7.5.8",
@ -52,14 +60,17 @@
"@typescript-eslint/parser": "^6.7.4",
"babel-jest": "^29.7.0",
"babel-plugin-transform-remove-console": "^6.9.4",
"core-js": "^3.38.1",
"eslint": "^8.38.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"fake-indexeddb": "^6.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"jest-matchmedia-mock": "^1.1.0",
"jest-watch-typeahead": "^2.2.2",
"prettier": "^3.3.3",
@ -72,4 +83,4 @@
"engines": {
"node": ">=18.0.0"
}
}
}

BIN
public/images/192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
public/images/512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

1
pushPublicKey.ts Normal file
View File

@ -0,0 +1 @@
export default "BNwr5jYXxBwSRGooQWx6nzsrG5XejgAskVqowkc_O0rutL9PgRIkQAnDh4nz1yoPQEw40juMuqL2NEHSaD3XcxA"

View File

@ -1,17 +1,13 @@
import { createRequestHandler } from "@remix-run/express"
import express from "express"
// notice that the result of `remix vite:build` is "just a module"
import * as build from "./build/server/index.js"
const app = express()
import app from "./api/app"
app.use(express.static("build/client"))
app.get("/api", (req, res) => res.send("HI"))
// and your app is "just a request handler"
app.all("*", createRequestHandler({ build }))
app.listen(3000, () => {
console.log("App listening on http://localhost:3000")
})

View File

@ -2,15 +2,20 @@
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import React from 'react'
import '@testing-library/jest-dom'
import React from "react"
import "@testing-library/jest-dom"
import { enableFetchMocks } from "jest-fetch-mock"
const JSDOMFormData = global.FormData;
const JSDOMFormData = global.FormData
global.React = React
global.TextDecoder = require("util").TextDecoder;
global.TextEncoder = require("util").TextEncoder;
global.ReadableStream = require("stream/web").ReadableStream;
global.WritableStream = require("stream/web").WritableStream;
global.TextDecoder = require("util").TextDecoder
global.TextEncoder = require("util").TextEncoder
global.ReadableStream = require("stream/web").ReadableStream
global.WritableStream = require("stream/web").WritableStream
require("@remix-run/node").installGlobals({ nativeFetch: true });
global.FormData = JSDOMFormData;
require("@remix-run/node").installGlobals({ nativeFetch: true })
global.FormData = JSDOMFormData
process.env.BASE_URL = "http://localhost"
enableFetchMocks()

4
start.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash
npx tsx api/data/upgrade.ts
npx tsx server.ts

View File

@ -12,13 +12,13 @@ import baseConfig from "./thebabel.config.cjs"
let metaPlugin = ({ types: t }) => ({
visitor: {
MetaProperty: (path) => {
path.replaceWith(t.identifier("undefined"));
path.replaceWith(t.identifier("undefined"))
},
},
});
})
export default babelJest.createTransformer({
babelrc: false,
...baseConfig,
plugins: [...baseConfig.plugins, metaPlugin],
});
})

1
url-mock.js Normal file
View File

@ -0,0 +1 @@
export default "/path/to/something"

View File

@ -1,10 +1,19 @@
import { vitePlugin as remix } from "@remix-run/dev";
import { installGlobals } from "@remix-run/node";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { vitePlugin as remix } from "@remix-run/dev"
import { installGlobals } from "@remix-run/node"
import { defineConfig } from "vite"
import tsconfigPaths from "vite-tsconfig-paths"
import { remixPWA } from "@remix-pwa/dev"
installGlobals();
installGlobals()
export default defineConfig({
plugins: [remix(), tsconfigPaths()],
});
plugins: [
remix({ ignoredRouteFiles: ["**/*.test.*"] }),
tsconfigPaths(),
remixPWA({
buildVariables: {
"process.env.BASE_URL": process.env.BASE_URL ?? "http://localhost:5173",
},
}),
],
})