From d45cf0d708bc739b6478e741c964da2e64e3d874 Mon Sep 17 00:00:00 2001 From: EuAndreh Date: Mon, 21 Oct 2024 07:39:46 -0300 Subject: Init work on offline support and tests --- .gitignore | 3 ++ Makefile | 97 +++++++++++++++++++++++++++++++++++ deps.mk | 5 ++ mkdeps.sh | 7 +++ package.json | 1 + src/content/index.html | 22 ++++++++ src/content/papo.js | 34 +++++++++++++ src/content/style.css | 0 src/content/sw.js | 131 ++++++++++++++++++++++++++++++++++++++++++++++++ src/exported.sh | 7 +++ src/sw-main.js | 5 ++ tests/browser-driver.js | 54 ++++++++++++++++++++ tests/driver.html | 56 +++++++++++++++++++++ tests/driver.js | 34 +++++++++++++ tests/node-driver.js | 52 +++++++++++++++++++ tests/papo.js | 19 +++++++ tests/sw.js | 26 ++++++++++ 17 files changed, 553 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 deps.mk create mode 100755 mkdeps.sh create mode 100644 package.json create mode 100644 src/content/index.html create mode 100644 src/content/papo.js create mode 100644 src/content/style.css create mode 100644 src/content/sw.js create mode 100755 src/exported.sh create mode 100644 src/sw-main.js create mode 100644 tests/browser-driver.js create mode 100644 tests/driver.html create mode 100644 tests/driver.js create mode 100644 tests/node-driver.js create mode 100644 tests/papo.js create mode 100644 tests/sw.js 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: diff --git a/deps.mk b/deps.mk new file mode 100644 index 0000000..b5d3842 --- /dev/null +++ b/deps.mk @@ -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 @@ + + + + + + + + + Chat with Freedom | Papo FIXME + + + +

Loading...

+ + 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"); +}; + +// diff --git a/src/content/style.css b/src/content/style.css new file mode 100644 index 0000000..e69de29 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 => `${escape(s)}`; +const green = s => `${escape(s)}`; +const yellow = s => `${escape(s)}`; + +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 @@ + + + + + + + Papo | webapp tests + + + + +
+

+    
+ + 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, +]; -- cgit v1.2.3