feat: Initial prototype
This commit is contained in:
commit
819f6a61cd
327 files changed
+252446
No files matched your search
@@ -0,0 +1,84 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile, readdir} from 'node:fs/promises';
|
||||
import {fonts} from '../font-catalog.js';
|
||||
import {render, registerFont, renderLoaded, resolveFont, FONT_CACHE} from '../renderer.js';
|
||||
|
||||
test('catalog includes every bundled FIGlet font', async () => {
|
||||
const files = await readdir(new URL('../fonts/figlet/', import.meta.url));
|
||||
assert.deepEqual(fonts, files.filter(file => file.endsWith('.flf')).map(file => file.slice(0, -4)).sort());
|
||||
});
|
||||
|
||||
for (const font of fonts) {
|
||||
test(`renders ${font} from its local font file`, async () => {
|
||||
registerFont(font, await readFile(new URL(`../fonts/figlet/${font}.flf`, import.meta.url), 'utf8'));
|
||||
const art = renderLoaded('Hello World! 123', font);
|
||||
assert.equal(typeof art, 'string');
|
||||
assert.match(art, /\S/);
|
||||
});
|
||||
}
|
||||
|
||||
test('Standard preserves lowercase lettering and the familiar FIGlet shape', () => {
|
||||
assert.equal(renderLoaded('Hi', 'Standard'), [
|
||||
' _ _ _ ', ' | | | (_)', ' | |_| | |', ' | _ | |', ' |_| |_|_|',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('old lowercase font links resolve to their library names', () => {
|
||||
for (const [name, expected] of [['standard', 'Standard'], ['slant', 'Slant'], ['block', 'Block'], ['banner', 'Banner'], ['compact', 'compact'], ['unknown', 'Standard']]) {
|
||||
assert.equal(resolveFont(name), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test('missing font requests can be retried', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const data = await readFile(new URL('../fonts/figlet/Standard.flf', import.meta.url), 'utf8');
|
||||
const figlet = (await import('../vendor/figlet.js')).default;
|
||||
delete figlet.figFonts.Standard;
|
||||
try {
|
||||
globalThis.fetch = async () => new Response('', {status: 404});
|
||||
await assert.rejects(render('Hi', 'Standard'), /Cannot load font/);
|
||||
globalThis.fetch = async () => new Response(data);
|
||||
assert.match(await render('Hi', 'Standard'), /\S/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('caches only selected fonts and reuses them when offline', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalCaches = globalThis.caches;
|
||||
const entries = new Map();
|
||||
const figlet = (await import('../vendor/figlet.js')).default;
|
||||
for (const font of ['Standard', 'Slant', 'Small']) delete figlet.figFonts[font];
|
||||
try {
|
||||
globalThis.caches = {
|
||||
async open(name) {
|
||||
assert.equal(name, FONT_CACHE);
|
||||
return {
|
||||
async match(url) { return entries.get(url)?.clone(); },
|
||||
async put(url, response) { entries.set(url, response.clone()); },
|
||||
};
|
||||
},
|
||||
};
|
||||
globalThis.fetch = async url => new Response(await readFile(new URL(url), 'utf8'));
|
||||
await render('Hello', 'Standard');
|
||||
await render('Hello', 'Slant');
|
||||
assert.equal(entries.size, 2);
|
||||
assert.ok([...entries.keys()].every(url => /\/(Standard|Slant)\.flf$/.test(url)));
|
||||
delete figlet.figFonts.Standard;
|
||||
globalThis.fetch = async () => { throw new Error('Offline'); };
|
||||
assert.match(await render('Offline', 'Standard'), /\S/);
|
||||
await assert.rejects(render('Offline', 'Small'), /Offline/);
|
||||
assert.equal(entries.size, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalCaches === undefined) delete globalThis.caches;
|
||||
else globalThis.caches = originalCaches;
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects unknown fonts and overlong input', async () => {
|
||||
await assert.rejects(render('Hello', 'unknown'), /Unknown/);
|
||||
await assert.rejects(render('x'.repeat(501)), /too long/);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {normalizeText, render, styles} from '../bitmap.js';
|
||||
|
||||
test('compact lettering renders a known phrase with correct spacing', () => {
|
||||
assert.equal(render('HI', 'compact'), [
|
||||
'# # #####', '# # #', '# # #', '##### #',
|
||||
'# # #', '# # #', '# # #####',
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
for (const style of styles) {
|
||||
test(`${style} renders digits, punctuation and multiline input`, () => {
|
||||
const first = render('Hello 123!', style);
|
||||
assert.equal(first.split('\n').length, 7);
|
||||
assert.match(first, /\S/);
|
||||
assert.equal(render('Hello 123!\r\nWorld?', style), `${first}\n\n${render('World?', style)}`);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [text, expected] of [
|
||||
['çığöşü İ', 'CIGOSU I'],
|
||||
['café', 'CAFE'],
|
||||
['abc 123!?', 'ABC 123!?'],
|
||||
]) {
|
||||
test(`normalizes ${text}`, () => assert.equal(normalizeText(text), expected));
|
||||
}
|
||||
|
||||
test('unsupported characters have a visible fallback', () => {
|
||||
assert.equal(render('🙂'), render('?'));
|
||||
});
|
||||
test('all printable ASCII characters have distinct supported glyphs', () => {
|
||||
for (let code = 32; code <= 126; code++) {
|
||||
const char = String.fromCharCode(code);
|
||||
if (char === '?') continue;
|
||||
assert.notEqual(render(char), render('?'), `missing ${char}`);
|
||||
}
|
||||
});
|
||||
test('banner uses the original normalized letter as ink', () => {
|
||||
assert.match(render('a', 'banner'), /^ AAA\n/);
|
||||
assert.doesNotMatch(render('a', 'banner'), /#/);
|
||||
});
|
||||
test('rejects invalid styles and overlong text', () => {
|
||||
assert.throws(() => render('hello', 'unknown'), /Unknown/);
|
||||
assert.throws(() => render('x'.repeat(501)), /too long/);
|
||||
assert.doesNotThrow(() => render('x'.repeat(500)));
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import {readFile, access} from 'node:fs/promises';
|
||||
|
||||
const source = await readFile(new URL('../sw.js', import.meta.url), 'utf8');
|
||||
|
||||
function worker({scope = 'https://ascii.example/pwa/', version = 'v1', stores = new Map(), missing = ''} = {}) {
|
||||
const handlers = {};
|
||||
let claimed = false;
|
||||
let activated = false;
|
||||
let fetched = 0;
|
||||
const context = vm.createContext({
|
||||
URL,
|
||||
self: {
|
||||
registration: {scope}, location: {origin: new URL(scope).origin},
|
||||
addEventListener(name, callback) { handlers[name] = callback; },
|
||||
clients: {async claim() { claimed = true; }},
|
||||
skipWaiting() { activated = true; },
|
||||
},
|
||||
caches: {
|
||||
async keys() { return [...stores.keys()]; },
|
||||
async delete(name) { return stores.delete(name); },
|
||||
async open(name) {
|
||||
if (!stores.has(name)) stores.set(name, new Map());
|
||||
const store = stores.get(name);
|
||||
return {
|
||||
async addAll(urls) {
|
||||
if (urls.some(url => url.endsWith(missing)) && missing) throw Error('Missing asset');
|
||||
for (const url of urls) {
|
||||
const relative = new URL(url).pathname.slice(new URL(scope).pathname.length);
|
||||
await access(new URL(`../${relative || 'index.html'}`, import.meta.url));
|
||||
}
|
||||
// Cache.addAll commits only after the whole batch succeeds.
|
||||
for (const url of urls) store.set(url, {url});
|
||||
},
|
||||
async match(url) { return store.get(url); },
|
||||
};
|
||||
},
|
||||
},
|
||||
async fetch(request) { fetched++; throw Error(`Offline: ${request.url}`); },
|
||||
});
|
||||
vm.runInContext(source.replace(/import .*;\n/, '').replace(/const VERSION = 'v\d+'/, `const VERSION = '${version}'`), context);
|
||||
return {
|
||||
stores,
|
||||
get claimed() { return claimed; },
|
||||
get activated() { return activated; },
|
||||
get fetched() { return fetched; },
|
||||
async lifecycle(name) {
|
||||
let result;
|
||||
handlers[name]({waitUntil(promise) { result = promise; }});
|
||||
await result;
|
||||
},
|
||||
message(data) { handlers.message({data}); },
|
||||
async request(url, {method = 'GET', mode = 'navigate'} = {}) {
|
||||
let result;
|
||||
handlers.fetch({request: {url, method, mode}, respondWith(promise) { result = promise; }});
|
||||
return result ? await result : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const scope of ['https://ascii.example/', 'https://ascii.example/pwa/']) {
|
||||
test(`offline shell preserves supported navigation at ${scope}`, async () => {
|
||||
const sw = worker({scope});
|
||||
await sw.lifecycle('install');
|
||||
await sw.lifecycle('activate');
|
||||
for (const cache of sw.stores.values()) {
|
||||
assert.equal([...cache.keys()].some(url => url.endsWith('.flf')), false);
|
||||
}
|
||||
assert.equal(sw.claimed, true);
|
||||
assert.equal(sw.activated, false, 'initial install does not force activation');
|
||||
assert.equal((await sw.request(`${scope}?text=Hello&font=compact`)).url, scope);
|
||||
assert.equal((await sw.request(`${scope}index.html?text=World`)).url, scope);
|
||||
for (const cache of sw.stores.values()) {
|
||||
for (const url of cache.keys()) {
|
||||
assert.ok(await sw.request(`${url}?v=1`, {mode: 'cors'}), `offline asset ${url}`);
|
||||
}
|
||||
}
|
||||
assert.equal(sw.fetched, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('bypasses API routes, unknown navigation, cross-origin resources and writes', async () => {
|
||||
const sw = worker();
|
||||
for (const [url, options] of [
|
||||
['https://ascii.example/api/render', {}],
|
||||
['https://ascii.example/pwa/not-a-route', {}],
|
||||
['https://other.example/pwa/app.js', {mode: 'cors'}],
|
||||
['https://ascii.example/pwa/app.js', {method: 'POST', mode: 'cors'}],
|
||||
['https://ascii.example/pwa/unrelated.js', {mode: 'cors'}],
|
||||
]) assert.equal(await sw.request(url, options), undefined);
|
||||
});
|
||||
|
||||
test('version upgrade prepares a complete cache, waits, then cleans only this app scope', async () => {
|
||||
const stores = new Map([['unrelated-cache', new Map()]]);
|
||||
const old = worker({stores});
|
||||
await old.lifecycle('install');
|
||||
// Create an unrelated installation cache without attempting to load its shell.
|
||||
const otherKey = `ascii-pwa-${encodeURIComponent('https://ascii.example/other/')}-v1`;
|
||||
stores.set(otherKey, new Map());
|
||||
const fontKey = `ascii-pwa-fonts-${encodeURIComponent('https://ascii.example/pwa/')}-figlet-1.8.2`;
|
||||
stores.set(fontKey, new Map([['Standard.flf', {used: true}]]));
|
||||
const next = worker({version: 'v2', stores});
|
||||
await next.lifecycle('install');
|
||||
assert.equal(next.activated, false);
|
||||
assert.ok([...stores.keys()].some(key => key.endsWith('-v1')));
|
||||
assert.equal((await old.request('https://ascii.example/pwa/')).url, 'https://ascii.example/pwa/');
|
||||
next.message('ACTIVATE_UPDATE');
|
||||
assert.equal(next.activated, true);
|
||||
await next.lifecycle('activate');
|
||||
assert.ok(stores.has('unrelated-cache'));
|
||||
assert.ok(stores.has(otherKey));
|
||||
assert.equal(stores.get(fontKey).get('Standard.flf').used, true);
|
||||
const key = `ascii-pwa-${encodeURIComponent('https://ascii.example/pwa/')}-v2`;
|
||||
assert.ok(stores.has(key));
|
||||
assert.equal(stores.has(key.replace(/v2$/, 'v1')), false);
|
||||
assert.equal((await next.request('https://ascii.example/pwa/')).url, 'https://ascii.example/pwa/');
|
||||
});
|
||||
|
||||
test('missing shell assets prevent installation without replacing the old shell', async () => {
|
||||
const stores = new Map();
|
||||
const old = worker({stores});
|
||||
await old.lifecycle('install');
|
||||
const next = worker({stores, version: 'v2', missing: 'renderer.js'});
|
||||
await assert.rejects(next.lifecycle('install'), /Missing asset/);
|
||||
assert.equal(next.claimed, false);
|
||||
assert.equal(next.activated, false);
|
||||
assert.equal((await old.request('https://ascii.example/pwa/')).url, 'https://ascii.example/pwa/');
|
||||
});
|
||||
|
||||
test('manifest paths and PNG dimensions support root and subdirectory installs', async () => {
|
||||
const manifest = JSON.parse(await readFile(new URL('../manifest.webmanifest', import.meta.url)));
|
||||
for (const base of ['https://ascii.example/', 'https://ascii.example/pwa/']) {
|
||||
for (const field of ['id', 'scope', 'start_url']) assert.equal(new URL(manifest[field], base).href, base);
|
||||
}
|
||||
assert.equal(manifest.display, 'standalone');
|
||||
for (const icon of manifest.icons) {
|
||||
const data = await readFile(new URL(`../${icon.src}`, import.meta.url));
|
||||
if (icon.type === 'image/png') {
|
||||
assert.equal(data.subarray(1, 4).toString(), 'PNG');
|
||||
assert.equal(`${data.readUInt32BE(16)}x${data.readUInt32BE(20)}`, icon.sizes);
|
||||
}
|
||||
}
|
||||
for (const purpose of ['any', 'maskable']) {
|
||||
for (const size of ['192x192', '512x512']) {
|
||||
assert.ok(manifest.icons.some(icon => icon.purpose === purpose && icon.sizes === size));
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user