summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEuAndreh <eu@euandre.org>2024-10-21 07:39:46 -0300
committerEuAndreh <eu@euandre.org>2024-10-21 17:17:17 -0300
commitd45cf0d708bc739b6478e741c964da2e64e3d874 (patch)
treea62b03cb31f8f3fe9dc1f4b94ab8a0f5669bb5b3
parentsrc/content/img/: Add favicon and logo SVGs (diff)
downloadchat.papo.im-d45cf0d708bc739b6478e741c964da2e64e3d874.tar.gz
chat.papo.im-d45cf0d708bc739b6478e741c964da2e64e3d874.tar.xz
Init work on offline support and tests
-rw-r--r--.gitignore3
-rw-r--r--Makefile97
-rw-r--r--deps.mk5
-rwxr-xr-xmkdeps.sh7
-rw-r--r--package.json1
-rw-r--r--src/content/index.html22
-rw-r--r--src/content/papo.js34
-rw-r--r--src/content/style.css0
-rw-r--r--src/content/sw.js131
-rwxr-xr-xsrc/exported.sh7
-rw-r--r--src/sw-main.js5
-rw-r--r--tests/browser-driver.js54
-rw-r--r--tests/driver.html56
-rw-r--r--tests/driver.js34
-rw-r--r--tests/node-driver.js52
-rw-r--r--tests/papo.js19
-rw-r--r--tests/sw.js26
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:
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 @@
+<!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,
+];