Compare commits

..

16 Commits

Author SHA1 Message Date
Jeff fc6a5c4528 iOS install instructions (barely), stupid, stupid workaround for babel config inexplicably messing with vite bundling, try not to kill anyone out of frustration
Test / test (push) Successful in 30s Details
2024-09-18 17:28:51 -04:00
Jeff c2af5c40a9 Remove test examples
Test / test (push) Failing after 39s Details
2024-07-09 22:10:59 -04:00
Jeff 70291c27aa It is in fact npm install
Test / test (push) Failing after 37s Details
2024-07-09 21:59:41 -04:00
Jeff 11b7d420dc Might help to install the packages
Test / test (push) Failing after 9s Details
2024-07-09 21:55:48 -04:00
Jeff f36a56d6c4 Still trying to get uses to work
Test / test (push) Failing after 16s Details
2024-07-09 21:54:23 -04:00
Jeff 707456ce3f It might actually help to check out the repo
Test / test (push) Failing after 0s Details
2024-07-09 21:45:16 -04:00
Jeff dc8b01c5d8 Try gitea action
Test / Explore-Gitea-Actions (push) Failing after 45s Details
2024-07-09 21:25:45 -04:00
Jeff b8c4420009 Add WIP playwright stuff 2024-06-25 20:41:00 -04:00
ElectronicPanopticon 478e7ef9af welcome to stupidtown 2024-06-11 18:50:40 -07:00
ElectronicPanopticon 054637f2dd welcome to favitown 2024-06-11 18:48:16 -07:00
Jeff 94ab70ba1c Make tests and project actually work 2024-06-11 21:09:38 -04:00
Jeff 0c988b6046 Remove unused dependencies, also a commented not-test 2024-06-04 21:06:20 -04:00
Jeff fc3165e155 Remove commented configuration, add typeahead plugin for jest 2024-06-04 21:01:48 -04:00
Jeff 8400f130eb Add separate npm script for jest watch 2024-06-04 20:57:30 -04:00
Jeff 56aaae38c1 Tests work, for some reason 2024-06-04 20:51:52 -04:00
Jeff b46d1748ba Trying to kludge together a working test environment and actually make a first test 2024-05-28 20:46:39 -04:00
64 changed files with 1090 additions and 3445 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,4 +1 @@
DATABASE="postgres" POSTGRES_PASSWORD="The password for the postgres database"
POSTGRES_PASSWORD="password"
VAPID_PRIVATE_KEY=""
BASE_URL="http://localhost:5173"

View File

@ -1,6 +0,0 @@
DATABASE="postgres"
POSTGRES_PASSWORD="testpassword"
POSTGRES_HOST="localhost"
POSTGRES_PORT=5434
VAPID_PRIVATE_KEY="privatekey"
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
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,11 +1,9 @@
.env .env
/data/ data/
/data-test/
node_modules/ node_modules/
build/ build/
/public/entry.worker.js public/
/test-results/ /test-results/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
send-it.js

View File

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

52
.swcrc
View File

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

View File

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

View File

@ -1,14 +0,0 @@
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

View File

@ -1,28 +0,0 @@
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,
})
}

View File

@ -1,46 +0,0 @@
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

@ -1,13 +0,0 @@
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()
}

View File

@ -1,7 +0,0 @@
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

@ -1,103 +0,0 @@
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,
})
})
})

View File

@ -1,48 +0,0 @@
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>

View File

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

View File

@ -1,47 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,10 +0,0 @@
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()
}

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
app/images/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1 @@
{"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

@ -1,49 +0,0 @@
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

@ -1,32 +0,0 @@
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

@ -1,36 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -1,22 +0,0 @@
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>
)
}

45
app/root.test.tsx Normal file
View File

@ -0,0 +1,45 @@
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,36 +1,71 @@
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { import {
Links, Links,
Meta, Meta,
Outlet, Outlet,
Scripts, Scripts,
ScrollRestoration, ScrollRestoration,
useLoaderData,
} from "@remix-run/react" } from "@remix-run/react"
import React from "react" import React, { useEffect } from "react"
import { ManifestLink } from "@remix-pwa/sw" import UAParser from "ua-parser-js"
import { LinksFunction } from "@remix-run/node" import versionAtLeast from "semver/functions/gte"
import styles from "./styles/styles.css?url" import coerceSemver from "semver/functions/coerce"
// import { LandingMessage } from "./root.client"
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }]
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta
<Meta /> name="viewport"
<ManifestLink /> content="width=device-width, initial-scale=1"
<Links /> />
</head> <Meta />
<body> <Links />
{children} </head>
<ScrollRestoration /> <body>
<Scripts /> {children}
</body> <ScrollRestoration />
</html> <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 })
} }
export default function App() { export default function App() {
return <Outlet /> 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>
} }

View File

@ -1,113 +1,18 @@
import { import type { MetaFunction } from "@remix-run/node";
json, import React from "react";
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 = () => { export const meta: MetaFunction = () => {
return [ return [
{ title: "Tack Up Now!" }, { title: "Tack Up Now!" },
{ name: "description", content: "Get equinelive notifications" }, { 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() { export default function Index() {
const { isSupported, isMobileSafari, isIOS } = useLoaderData<typeof loader>()
return ( return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}> <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<TackUpNow <h1>WHOAH! A WEBSITE</h1>
isSupported={isSupported} <div>Hey check it out!</div>
isMobileSafari={isMobileSafari}
isIOS={isIOS}
/>
</div> </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

@ -1,35 +0,0 @@
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",
},
}
)
}

View File

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

View File

@ -1,63 +0,0 @@
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,
})

View File

@ -1,181 +0,0 @@
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,
}
}

View File

@ -1,436 +0,0 @@
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))
}

View File

@ -1,127 +0,0 @@
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
}

View File

@ -1,12 +0,0 @@
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
}

View File

@ -1,11 +0,0 @@
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,12 +3,13 @@ services:
build: . build: .
ports: ports:
- "9000:3000" - "9000:3000"
env_file: ".env"
database: database:
image: postgres:16 image: postgres
ports:
- "5433:5432"
environment: environment:
POSTGRES_PASSWORD: $POSTGRES_PASSWORD
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_DB: postgres POSTGRES_DB: postgres
env_file: ".env"
volumes: volumes:
- ./data:/var/lib/postgresql/data - ./data:/var/lib/postgresql/data

View File

@ -1,279 +1,77 @@
import { test, expect, Page } from "@playwright/test" import { test, expect } from "@playwright/test"
import matchPath from "./urlMatcher"
import { messageSW } from "@remix-pwa/sw"
const { describe, beforeEach, skip, use } = test const { describe, beforeEach, skip, use } = test
function webkitOnly() { function webkitOnly() {
beforeEach(async ({ browserName }) => { beforeEach(async ({ browserName }) => {
if (browserName !== "webkit") skip() if (browserName !== "webkit") skip()
}) })
}
function nonWebkitOnly() {
beforeEach(async ({ browserName }) => {
if (browserName === "webkit") skip()
})
} }
describe("when user is on iOS", () => { describe("when user is on iOS", () => {
webkitOnly() webkitOnly()
describe("version 16.4 or higher", () => { describe("version 16.4 or higher", () => {
use({ use({
userAgent: 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",
"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("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()
}) })
describe("and then the user enables notifications", () => { test("and tack up now is not running as a PWA, they are instructed to install tack up now", async ({ page }) => {
beforeEach(async ({ page }) => {
await stubNotifications(page, {
permission: "default",
requestPermissionResult: "granted",
})
})
test("users see tack up now after enabling notifications", async ({
page,
}) => {
await page.goto("/") await page.goto("/")
await page.getByText(/Enable Notifications/).click() const installText = await page.getByText(/Install Tack Up Now!/)
const yourNotificationsHeading = await expect(installText).toBeAttached()
page.getByText(/Your Notifications/) })
await expect(yourNotificationsHeading).toBeVisible() describe("and tack up now is running as a PWA", () => {
})
test("the subscription is submitted", async ({ page }) => { 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.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("/") await page.goto("/")
await page.getByText(/Enable Notifications/).click() const sorryText = await page.getByText("Sorry, your device doesn't support Tack Up Now! :(")
const postedMessages = await page.evaluate(() => { await expect(sorryText).toBeVisible()
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,
})
})
}

View File

@ -1,6 +0,0 @@
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,17 +4,15 @@ const ignorePatterns = [
"\\/\\.vscode\\/", "\\/\\.vscode\\/",
"\\/\\.tmp\\/", "\\/\\.tmp\\/",
"\\/\\.cache\\/", "\\/\\.cache\\/",
"^\\/data/", "data",
"^\\/data-test/", "e2e"
"e2e", ];
]
module.exports = { module.exports = {
moduleNameMapper: { moduleNameMapper: {
"^@web3-storage/multipart-parser$": require.resolve( "^@web3-storage/multipart-parser$": require.resolve(
"@web3-storage/multipart-parser" "@web3-storage/multipart-parser"
), ),
"\\?url$": "<rootDir>/url-mock.js",
}, },
modulePathIgnorePatterns: ignorePatterns, modulePathIgnorePatterns: ignorePatterns,
transform: { transform: {
@ -30,4 +28,4 @@ module.exports = {
setupFiles: [], setupFiles: [],
testEnvironment: "jsdom", testEnvironment: "jsdom",
setupFilesAfterEnv: ["./setupTests.ts", "@testing-library/jest-dom"], 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,28 +9,22 @@
"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": "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": "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/worker-runtime": "^2.1.4",
"@remix-run/express": "^2.9.1", "@remix-run/express": "^2.9.1",
"@remix-run/node": "^2.9.0", "@remix-run/node": "^2.9.0",
"@remix-run/react": "^2.9.0", "@remix-run/react": "^2.9.0",
"@remix-run/serve": "^2.9.0", "@remix-run/serve": "^2.9.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dexie": "^4.0.8",
"express": "^4.19.2", "express": "^4.19.2",
"isbot": "^4.1.0", "isbot": "^4.1.0",
"kysely": "^0.27.4",
"pg": "^8.13.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"remix-utils": "^7.6.0",
"semver": "^7.6.3", "semver": "^7.6.3",
"ua-parser-js": "^1.0.39", "ua-parser-js": "^1.0.39"
"web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.24.5", "@babel/core": "^7.24.5",
@ -39,8 +33,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-run/dev": "^2.9.0", "@remix-run/dev": "^2.9.0",
"@remix-run/testing": "^2.9.1", "@remix-run/testing": "^2.9.1",
"@swc/core": "^1.5.7", "@swc/core": "^1.5.7",
@ -50,7 +43,6 @@
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^20.14.5", "@types/node": "^20.14.5",
"@types/pg": "^8.11.10",
"@types/react": "^18.2.20", "@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
@ -60,17 +52,14 @@
"@typescript-eslint/parser": "^6.7.4", "@typescript-eslint/parser": "^6.7.4",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"babel-plugin-transform-remove-console": "^6.9.4", "babel-plugin-transform-remove-console": "^6.9.4",
"core-js": "^3.38.1",
"eslint": "^8.38.0", "eslint": "^8.38.0",
"eslint-import-resolver-typescript": "^3.6.1", "eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.28.1", "eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"fake-indexeddb": "^6.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
"jest-matchmedia-mock": "^1.1.0", "jest-matchmedia-mock": "^1.1.0",
"jest-watch-typeahead": "^2.2.2", "jest-watch-typeahead": "^2.2.2",
"prettier": "^3.3.3", "prettier": "^3.3.3",
@ -83,4 +72,4 @@
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

View File

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

View File

@ -1,13 +1,17 @@
import { createRequestHandler } from "@remix-run/express" import { createRequestHandler } from "@remix-run/express"
import express from "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" import * as build from "./build/server/index.js"
import app from "./api/app" const app = express()
app.use(express.static("build/client")) 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.all("*", createRequestHandler({ build }))
app.listen(3000, () => { app.listen(3000, () => {
console.log("App listening on http://localhost:3000") console.log("App listening on http://localhost:3000")
}) })

View File

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

View File

@ -1,4 +0,0 @@
#!/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 }) => ({ let metaPlugin = ({ types: t }) => ({
visitor: { visitor: {
MetaProperty: (path) => { MetaProperty: (path) => {
path.replaceWith(t.identifier("undefined")) path.replaceWith(t.identifier("undefined"));
}, },
}, },
}) });
export default babelJest.createTransformer({ export default babelJest.createTransformer({
babelrc: false, babelrc: false,
...baseConfig, ...baseConfig,
plugins: [...baseConfig.plugins, metaPlugin], plugins: [...baseConfig.plugins, metaPlugin],
}) });

View File

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

View File

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