diff options
author | EuAndreh <eu@euandre.org> | 2024-10-21 07:39:46 -0300 |
---|---|---|
committer | EuAndreh <eu@euandre.org> | 2024-10-21 17:17:17 -0300 |
commit | d45cf0d708bc739b6478e741c964da2e64e3d874 (patch) | |
tree | a62b03cb31f8f3fe9dc1f4b94ab8a0f5669bb5b3 | |
parent | src/content/img/: Add favicon and logo SVGs (diff) | |
download | chat.papo.im-d45cf0d708bc739b6478e741c964da2e64e3d874.tar.gz chat.papo.im-d45cf0d708bc739b6478e741c964da2e64e3d874.tar.xz |
Init work on offline support and tests
-rw-r--r-- | .gitignore | 3 | ||||
-rw-r--r-- | Makefile | 97 | ||||
-rw-r--r-- | deps.mk | 5 | ||||
-rwxr-xr-x | mkdeps.sh | 7 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | src/content/index.html | 22 | ||||
-rw-r--r-- | src/content/papo.js | 34 | ||||
-rw-r--r-- | src/content/style.css | 0 | ||||
-rw-r--r-- | src/content/sw.js | 131 | ||||
-rwxr-xr-x | src/exported.sh | 7 | ||||
-rw-r--r-- | src/sw-main.js | 5 | ||||
-rw-r--r-- | tests/browser-driver.js | 54 | ||||
-rw-r--r-- | tests/driver.html | 56 | ||||
-rw-r--r-- | tests/driver.js | 34 | ||||
-rw-r--r-- | tests/node-driver.js | 52 | ||||
-rw-r--r-- | tests/papo.js | 19 | ||||
-rw-r--r-- | tests/sw.js | 26 |
17 files changed, 553 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2b9f22 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/src/content/papo.exported.js +/src/content/sw.exported.js +/src/content/service-worker.js diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..669e9cc --- /dev/null +++ b/Makefile @@ -0,0 +1,97 @@ +.POSIX: +NAME = webapp +PREFIX = /usr +SRCDIR = $(PREFIX)/src/$(NAME) +SHAREDIR = $(PREFIX)/share +# DOCDIR? JavaScript goes to DOCDIR? Really? +DOCDIR = $(SHAREDIR)/doc/$(NAME) + + + +.SUFFIXES: +.SUFFIXES: .js + + +all: +include deps.mk + + + +static-contents = \ + src/content/papo.js \ + src/content/style.css \ + src/content/index.html \ + +contents = \ + src/content/service-worker.js \ + $(img.svg) \ + + +derived-assets = \ + src/content/papo.exported.js \ + src/content/sw.exported.js \ + src/content/service-worker.js \ + + + +all: $(derived-assets) + + +## The use of static `import` statements inside service workers +## isn't supported. So in order to have tests for the code in +## it, a "main()" function is included in the generated file +## so that is can be ran as a standalone file. +src/content/service-worker.js: src/content/sw.js src/sw-main.js Makefile + cat src/content/sw.js src/sw-main.js > $@ + +src/content/papo.exported.js src/content/sw.exported.js: \ + Makefile src/exported.sh +src/content/papo.exported.js: src/content/papo.js +src/content/sw.exported.js: src/content/sw.js + +src/content/papo.exported.js src/content/sw.exported.js: + sh src/exported.sh $(@D)/`basename $@ .exported.js`.js > $@ + + + +.SUFFIXES: .js-check +tests/papo.js-check: src/content/papo.exported.js +tests/sw.js-check: src/content/sw.exported.js +tests/papo.js-check tests/sw.js-check: + node tests/node-driver.js $*.js + + +check-unit: tests/papo.js-check tests/sw.js-check + +check-integration: + +check: check-unit check-integration + + +clean: + rm -rf $(derived-assets) $(side-assets) + +install: all + mkdir -p \ + '$(DESTDIR)$(SRCDIR)' \ + + for f in $(contents) $(static-contents); do \ + dir='$(DESTDIR)$(DOCDIR)'/"`dirname "$${f#src/content/}"`"; \ + mkdir -p "$$dir"; \ + cp -P "$$f" "$$dir"; \ + done + +uninstall: + rm -rf \ + '$(DESTDIR)$(SRCDIR)' \ + '$(DESTDIR)$(DOCDIR)' \ + + + +PORT = 3334 +## Run file server for local static files +run: + serve -n -p $(PORT) -d '$(DESTDIR)$(DOCDIR)' + + +ALWAYS: @@ -0,0 +1,5 @@ +img.svg = \ + src/content/img/favicon.svg \ + src/content/img/logo/dark.svg \ + src/content/img/logo/light.svg \ + diff --git a/mkdeps.sh b/mkdeps.sh new file mode 100755 index 0000000..fbc15b3 --- /dev/null +++ b/mkdeps.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +export LANG=POSIX.UTF-8 + + +find src/content/img/ -type f -name '*.svg' | varlist 'img.svg' diff --git a/package.json b/package.json new file mode 100644 index 0000000..5ffd980 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{ "type": "module" } diff --git a/src/content/index.html b/src/content/index.html new file mode 100644 index 0000000..ca012d4 --- /dev/null +++ b/src/content/index.html @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html lang="en" manifest="manifest.appcache"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <!-- + FIXME + - metadata + - canonical link + --> + <link rel="stylesheet" type="text/css" href="./style.css" /> + <link rel="shortcut icon" type="image/svg+xml" href="./img/favicon.svg" /> + <title>Chat with Freedom | Papo FIXME</title> + <script type="module"> + import { main } from "./papo.js"; + main(); + </script> + </head> + <body> + <p id="static-loading">Loading...</p> + </body> +</html> diff --git a/src/content/papo.js b/src/content/papo.js new file mode 100644 index 0000000..83dd1b1 --- /dev/null +++ b/src/content/papo.js @@ -0,0 +1,34 @@ +// import * as assert from "node:assert/strict"; + + +// assert.deepEqual({a: 1}, {a: 1}); +// console.log(123); +// console.log({ navigator }); + + +const f1 = x => x + 1; +const f2 = x => x - 1; + +const registerServiceWorker = async ({ serviceWorkerPath, err }) => { + try { + await navigator.serviceWorker?.register(path) + } catch (e) { + err(`Service worker registration failed: ${e}`); + } +}; + +export const main = async ({ + serviceWorkerPath = "./service-worker.js", + out = console.log, + err = console.warn, +} = {}) => { + const env = { + serviceWorkerPath, + out, + err, + }; + await registerServiceWorker(env); + out("main called"); +}; + +// <body onload="setOnline()" ononline="setOnline()" onoffline="setOnline()" > diff --git a/src/content/style.css b/src/content/style.css new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/content/style.css diff --git a/src/content/sw.js b/src/content/sw.js new file mode 100644 index 0000000..9e17707 --- /dev/null +++ b/src/content/sw.js @@ -0,0 +1,131 @@ +const CACHE_NAME = "static-shared-assets"; + +const FALLBACK_PATHS = { + img: "/fallback-image-FIXME.svg", + data: { + static: "/fallback-data-FIXME.json", + }, +}; + +const leafValues = tree => + Object.values(tree).map(x => + typeof x !== "object" + ? x + : leafValues(x) + ); + +const collectLeaves = tree => + leafValues(tree).flat(Infinity); + +const DEFAULT_INSTALL_PATHS = [ + "/", + "index.html", + "style.css", + "img/favicon.svg", + "papo.js", +].concat(collectLeaves(FALLBACK_PATHS)); + +const mkInstallHandler = ({ self, caches }) => event => { + self.skipWaiting(); + event.waitUntil( + caches.open(CACHE_NAME).then( + cache => cache.addAll(DEFAULT_INSTALL_PATHS), + ), + ); +}; + +const store = async (caches, request, response) => { + const cache = await caches.open(CACHE_NAME); + await cache.put(request, response); +}; + +const getPrefixIn = (paths, segments) => { + if (paths === undefined) { + return null; + } + + if (segments.length === 0) { + return null; + } + + if (typeof paths === "string") { + return paths; + } + + return getPrefixIn(paths[segments[0]], segments.slice(1)); +}; + +const maybeFallback = async (caches, request, paths = FALLBACK_PATHS) => { + const url = new URL(request.url) + const segments = url.pathname.split("/").filter(s => !!s) + const fallbackPath = getPrefixIn(paths, segments); + if (fallbackPath) { + return await caches.match(fallbackPath); + } + + return null; +} + +const fromCache = async (caches, fetch, { request, preloadResponse }) => { + const cachedResponse = await caches.match(request); + if (cachedResponse) { + fetch(request).then(async freshResponse => + store(caches, request, freshResponse)); + return cachedResponse; + } + + const preloadedResponse = await preloadResponse; + if (preloadedResponse) { + store(caches, request, preloadeResponse.clone()); + return preloadedResponse; + } + + try { + // FIXME: do integration test with real lack of internet. + const fetchedResponse = await fetch(request); + store(caches, request, fetchedResponse.clone()); + return fetchedResponse; + } catch (e) { + const fallbackResponse = await maybeFallback(caches, request); + if (fallbackResponse) { + return fallbackResponse; + } + + throw e; + } +}; + +const mkFetchHandler = ({ caches }) => (event, mkfetch = () => fetch) => { + if (event.request.method !== "GET") { + return; + } + + event.respondWith(fromCache(caches, mkfetch(), event)); +}; + +const mkActivateHandler = ({ self, clients }) => event => + event.waitUntil(Promise.all([ + clients.claim(), + self.registration.navigationPreload?.enable(), + ])); + +const registerListeners = env => { + env.self.addEventListener("install", mkInstallHandler(env)); + env.self.addEventListener("activate", mkActivateHandler(env)); + env.self.addEventListener("fetch", mkFetchHandler(env)); +}; + +const main = ({ + self, + caches, + clients, + out = console.log, + err = console.warn, +} = {}) => + registerListeners({ + self, + caches, + clients, + out, + err, + }); diff --git a/src/exported.sh b/src/exported.sh new file mode 100755 index 0000000..15e99f6 --- /dev/null +++ b/src/exported.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +cat "$1" +printf '%s\n\n\nexport {\n' +awk '/^const / { printf "\t%s,\n", $2 }' "$1" +printf '}\n' diff --git a/src/sw-main.js b/src/sw-main.js new file mode 100644 index 0000000..be45ba4 --- /dev/null +++ b/src/sw-main.js @@ -0,0 +1,5 @@ +main({ + self, + caches, + clients, +}); diff --git a/tests/browser-driver.js b/tests/browser-driver.js new file mode 100644 index 0000000..529d824 --- /dev/null +++ b/tests/browser-driver.js @@ -0,0 +1,54 @@ +import { runTests } from "./driver.js"; + + + +class AssertionError extends Error {} + + + +const panel = document.querySelector("#panel"); +const err = s => panel.innerHTML += s; + +const assert = { + equal: (given, expected) => { + if (given !== expected) { + throw new AssertionError( + `given ${given}; expected ${expected}` + ); + } + }, + deepEqual: (x, y) => { + }, +}; + +// FIXME +const escape = s => s; + +const red = s => `<span class="red" >${escape(s)}</span>`; +const green = s => `<span class="green" >${escape(s)}</span>`; +const yellow = s => `<span class="yellow">${escape(s)}</span>`; + +const conf = { + err, + assert, + colors: { + red, + green, + yellow, + }, +}; + +const TEST_PATHS = [ + "tests/papo.js", + "tests/sw.js", +]; +export const main = (paths = TEST_PATHS) => { + document.addEventListener("DOMContentLoaded", async _ => { + for (const path of paths) { + const module = await import("../" + path); + conf.err(path + "\n"); + await runTests(conf, module.allTests); + conf.err("\n"); + } + }); +}; diff --git a/tests/driver.html b/tests/driver.html new file mode 100644 index 0000000..e4508ec --- /dev/null +++ b/tests/driver.html @@ -0,0 +1,56 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <link rel="shortcut icon" type="image/svg+xml" href="../src/content/img/favicon.svg" /> + <title>Papo | webapp tests</title> + <style> + :root { + --color-fg: black; + --color-bg: white; + --color-red: red; + --color-green: green; + --color-yellow: goldenrod; + } + + @media(prefers-color-scheme: dark) { + :root { + --color-fg: white; + --color-bg: black; + --color-red: orangered; + --color-green: lightgreen; + --color-yellow: yellow; + } + } + + body { + color: var(--color-fg); + background-color: var(--color-bg); + margin: 0 auto; + max-width: 800px; + } + + .red { + color: var(--color-red); + } + + .green { + color: var(--color-green); + } + + .yellow { + color: var(--color-yellow); + } + </style> + <script type="module"> + import { main } from "./browser-driver.js"; + main(); + </script> + </head> + <body> + <main> + <pre id="panel"></pre> + </main> + </body> +</html> diff --git a/tests/driver.js b/tests/driver.js new file mode 100644 index 0000000..d273c5d --- /dev/null +++ b/tests/driver.js @@ -0,0 +1,34 @@ +const isAsync = f => + typeof f.then === 'function' && + f[Symbol.toStringTag] === 'Promise'; + +const t = ({ colors, err, assert }) => ({ + assert, + tap: x => { + err(`tap: ${x}\n`); + return x; + }, + start: msg => { + err(`${msg}:\n`); + }, + test: async (msg, fn) => { + err(`${colors.yellow("testing")}: ${msg}... `); + try { + if (isAsync(fn)) { + await fn(); + } else { + fn(); + } + } catch (e) { + err(`${colors.red("ERR")}.\n`); + throw e; + } + err(`${colors.green("OK")}.\n`); + }, +}); + +export const runTests = async (conf, tests) => { + for (const testFn of tests) { + await testFn(t(conf)); + } +}; diff --git a/tests/node-driver.js b/tests/node-driver.js new file mode 100644 index 0000000..3393dca --- /dev/null +++ b/tests/node-driver.js @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import process from "node:process"; + + + +const red = s => `\x1b[31m${s}\x1b[0m`; +const green = s => `\x1b[32m${s}\x1b[0m`; +const yellow = s => `\x1b[33m${s}\x1b[0m`; + +const isAsync = f => + typeof f.then === 'function' && + f[Symbol.toStringTag] === 'Promise'; + +const t = { + assert, + tap: x => { + process.stderr.write(`tap: ${x}\n`); + return x; + }, + start: msg => { + process.stderr.write(`${msg}:\n`); + }, + test: async (msg, fn) => { + process.stderr.write(`${yellow("testing")}: ${msg}... `); + try { + if (isAsync(fn)) { + await fn(); + } else { + fn(); + } + } catch (e) { + process.stderr.write(`${red("ERR")}.\n}`); + throw e; + } + process.stderr.write(`${green("OK")}.\n`); + }, +}; + +const runTests = async tests => { + for (const testFn of tests) { + await testFn(t); + } +}; + +const main = async (path = process.argv[2]) => { + const module = await import("../" + path); + await runTests(module.allTests); +}; + + + +await main(); diff --git a/tests/papo.js b/tests/papo.js new file mode 100644 index 0000000..188ca67 --- /dev/null +++ b/tests/papo.js @@ -0,0 +1,19 @@ +import { + f1, +} from "../src/content/papo.exported.js"; + + + +const test_f1 = async t => { + t.start("f1()"); + t.test("addition", () => { + t.assert.equal(f1(1), 2); + // t.assert.equal(f1(1), 3); + }); +}; + + + +export const allTests = [ + test_f1, +]; diff --git a/tests/sw.js b/tests/sw.js new file mode 100644 index 0000000..cff37bc --- /dev/null +++ b/tests/sw.js @@ -0,0 +1,26 @@ +import { + leafValues, +} from "../src/content/sw.exported.js"; + + + +const test_leafValues = async t => { + t.start("leafValues()"); + + t.test("noop on empty object", () => { + t.assert.deepEqual(leafValues({}), []); + }); + + t.test("flat array for flat tree", () => { + }); + + t.test("array of empty arrays for leafless tree", () => { + t.assert.deepEqual(leafValues({ a: {} }), [[]]); + }); +}; + + + +export const allTests = [ + test_leafValues, +]; |