iOS install instructions
Test / test (push) Failing after 42s Details

This commit is contained in:
Jeff 2024-09-21 18:21:45 -04:00
parent 16015491e4
commit fd351360f1
14 changed files with 383 additions and 230 deletions

View File

@ -1,6 +1,6 @@
{
"trailingComma": "es5",
"tabWidth": 4,
"tabWidth": 2,
"semi": false,
"singleQuote": false,
"overrides": [

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

@ -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)
})
}

View File

@ -1,17 +1,17 @@
/// <reference lib="WebWorker" />
export {};
export {}
declare let self: ServiceWorkerGlobalScope;
declare let self: ServiceWorkerGlobalScope
self.addEventListener('install', event => {
console.log('Service worker installed');
self.addEventListener("install", (event) => {
console.log("Service worker installed")
event.waitUntil(self.skipWaiting());
});
event.waitUntil(self.skipWaiting())
})
self.addEventListener('activate', event => {
console.log('Service worker activated');
self.addEventListener("activate", (event) => {
console.log("Service worker activated")
event.waitUntil(self.clients.claim());
});
event.waitUntil(self.clients.claim())
})

View File

@ -1,45 +1,47 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import MatchMediaMock from 'jest-matchmedia-mock';
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'
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
}
])
})
beforeEach(() => {
RemixStub = createRemixStub([
{
path: "/",
meta: () => [],
links: () => [],
loader: () => ({ isSupported: true }),
Component: App,
},
])
})
let matchMedia: MatchMediaMock
let matchMedia: MatchMediaMock
beforeAll(() => {
matchMedia = new MatchMediaMock();
})
afterEach(() => {
matchMedia.clear();
})
beforeAll(() => {
matchMedia = new MatchMediaMock()
})
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/>)
afterEach(() => {
matchMedia.clear()
})
await waitFor(() => screen.findByText<HTMLElement>(/Install/), { timeout: 2000 })
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 />)
expect(true).toBe(true)
})
})
await waitFor(() => screen.findByText<HTMLElement>(/Install/), {
timeout: 2000,
})
expect(true).toBe(true)
})
})
})
})
})

View File

@ -1,33 +1,30 @@
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react"
import React from "react"
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>
)
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 default function App() {
return <Outlet/>
return <Outlet />
}

View File

@ -1,52 +1,82 @@
import { json, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import React, { Suspense, useEffect } from "react";
import UAParser from "ua-parser-js"
import versionAtLeast from "semver/functions/gte"
import {
json,
type LoaderFunctionArgs,
type MetaFunction,
} from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
import React, { useState } from "react"
import coerceSemver from "semver/functions/coerce"
import versionAtLeast from "semver/functions/gte"
import UAParser from "ua-parser-js"
import { usePush } from "remix-pwa-monorepo/packages/push/client/hook"
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 os = new UAParser(userAgent ?? "").getOS()
const isSupported =
os.name !== "iOS" ||
versionAtLeast(coerceSemver(os.version) ?? "0.0.0", "16.4.0")
os.name !== "iOS" ||
versionAtLeast(coerceSemver(os.version) ?? "0.0.0", "16.4.0")
return json({ isSupported })
}
export default function Index() {
const { isSupported } = useLoaderData<typeof loader>()
return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<Suspense>
<LandingMessage isSupported={isSupported}/>
</Suspense>
<LandingMessage isSupported={isSupported} />
</div>
);
)
}
function LandingMessage({ isSupported }: { isSupported: boolean }) {
const isClient = typeof window !== "undefined"
const notificationsEnabled =
isClient &&
"Notification" in window &&
window.Notification.permission === "granted"
if (typeof window === "undefined") return <div>Loading</div>
const isRunningPWA =
isClient &&
(("standalone" in navigator && (navigator.standalone as boolean)) ||
matchMedia("(dislay-mode: standalone)").matches)
const isRunningPWA = ("standalone" in navigator && navigator.standalone as boolean) || matchMedia("(dislay-mode: standalone)").matches
const notificationsEnabled = "Notification" in window && window.Notification.permission === "granted"
const message = isRunningPWA && !notificationsEnabled
? "Enable notifications"
: isSupported
? "Install Tack Up Now!"
: "Sorry, your device doesn't support Tack Up Now! :("
const [isInstalled, setIsInstalled] = useState(notificationsEnabled)
return <div><div>{message}</div></div>
return !isClient ? (
<div>Loading</div>
) : isInstalled ? (
<div>Your Notifications</div>
) : isRunningPWA && !notificationsEnabled ? (
<EnableButton onSubscribe={() => setIsInstalled(true)} />
) : isSupported ? (
"Install Tack Up Now!"
) : (
"Sorry, your device doesn't support Tack Up Now! :("
)
}
function EnableButton({ onSubscribe }: { onSubscribe: () => void }) {
const { subscribeToPush, requestPermission } = usePush()
return <button onClick={subscribe}>Enable notifications</button>
async function subscribe() {
console.log("Hey the thing was clicked wow")
subscribeToPush("Derpderp", (subscription) => {
fetch("/subscribe", {
method: "POST",
body: JSON.stringify(subscription),
})
onSubscribe()
})
}
}

View File

@ -1,76 +1,186 @@
import { test, expect } from "@playwright/test"
import { test, expect, Page } from "@playwright/test"
import { before } from "node:test"
const { describe, beforeEach, skip, use } = test
function webkitOnly() {
beforeEach(async ({ browserName }) => {
if (browserName !== "webkit") skip()
})
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.addInitScript(() => (Object.defineProperty(window.Notification, "permission", {value: "default", writable: true })))
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.Notification = window.Notification ?? {}
Object.defineProperty(window.Notification, "permission", {value: "granted", writable: true })
})
})
test("they aren't asked to enable notifications", async ({ page }) => {
await page.goto("/")
await page.evaluate(async () => await navigator.serviceWorker.ready)
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",
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",
})
await page.route("/subscribe", async (route) => {
await route.fulfill()
})
})
test("their push token is submitted after notifications are enabled", async ({
page,
}) => {
await page.goto("/")
const sorryText = await page.getByText(/Sorry/)
const requestPromise = page.waitForRequest("/subscribe")
await page.getByText(/Enable notifications/).click()
const request = await requestPromise
await expect(sorryText).toBeVisible()
await expect(request.postDataJSON()).toEqual({ hi: "subscription" })
})
test("users see tack up now after enabling notifications", async ({
page,
}) => {
await page.goto("/")
const requestPromise = page.waitForRequest("/subscribe")
await page.getByText(/Enable notifications/).click()
await requestPromise
const yourNotificationsHeading =
await page.getByText(/Your Notifications/)
await expect(yourNotificationsHeading).toBeVisible()
})
})
})
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 = await page.getByText(/Enable notifications/)
await expect(notificationText).not.toBeAttached()
})
test("users see tack up now", async ({ page }) => {
await page.goto("/")
const yourNotificationsHeading =
await 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 = await page.getByText(/Sorry/)
await expect(sorryText).toBeVisible()
})
})
})
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(() => {
const registration = {
pushManager: {
getSubscription() {
return Promise.resolve({ hi: "subscription" })
},
subscribe(args: Parameters<PushManager["subscribe"]>[0]) {
return Promise.resolve({ hi: "subscription" })
},
},
}
Object.defineProperty(navigator, "serviceWorker", {
value: {
register() {
return Promise.resolve(registration)
},
getRegistration() {
return Promise.resolve(registration)
},
addEventListener() {},
removeEventListener() {},
},
writable: false,
})
})
}

20
package-lock.json generated
View File

@ -16,6 +16,7 @@
"isbot": "^4.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remix-pwa-monorepo": "github:remix-pwa/monorepo#main",
"remix-utils": "^7.6.0",
"semver": "^7.6.3",
"ua-parser-js": "^1.0.39"
@ -14603,6 +14604,25 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remix-pwa-monorepo": {
"resolved": "git+ssh://git@github.com/remix-pwa/monorepo.git#dda9d68b1c69642679d6ff17658f21fe24c668d6",
"license": "MIT",
"workspaces": [
"packages/cli",
"packages/client",
"packages/dev",
"packages/eslint-config",
"packages/lint-staged-config",
"packages/push",
"packages/sw",
"packages/sync",
"packages/worker-runtime",
"playground"
],
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/remix-utils": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/remix-utils/-/remix-utils-7.6.0.tgz",

View File

@ -23,6 +23,7 @@
"isbot": "^4.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remix-pwa-monorepo": "github:remix-pwa/monorepo#main",
"remix-utils": "^7.6.0",
"semver": "^7.6.3",
"ua-parser-js": "^1.0.39"
@ -74,4 +75,4 @@
"engines": {
"node": ">=18.0.0"
}
}
}

View File

@ -11,10 +11,6 @@ app.get("/api", (req, res) => res.send("HI"))
// and your app is "just a request handler"
app.all("*", createRequestHandler({ build }))
import { register } from "register-service-worker";
register(`/service-worker.js`)
app.listen(3000, () => {
console.log("App listening on http://localhost:3000")
})

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],
});
})

View File

@ -2,14 +2,10 @@ 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'
import { remixPWA } from "@remix-pwa/dev"
installGlobals()
export default defineConfig({
plugins: [
remix(),
tsconfigPaths(),
remixPWA()
],
plugins: [remix(), tsconfigPaths(), remixPWA()],
})