monal-rebuild/test/shell.test.ts

107 lines
5.5 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
function worker() {
const root='https://chat.example.org/';
const rows=new Map<string,Response>();
const network=new Map<string,string>();
const events: Record<string,(event:any)=>void>={};
const pending:Promise<unknown>[]=[];
const holds=new Map<string,Promise<void>>();
const writes=new Map<string,Promise<void>>();
let offline=false;
const key=(value:any)=>new URL(typeof value==='string'?value:value.url,root).href;
const fetch=async(value:any)=>{
if(offline)throw Error('offline');
await holds.get(key(value));
const body=network.get(key(value));
return new Response(body??'missing',{status:body===undefined?404:200});
};
const cache={
match:async(value:any)=>rows.get(key(value))?.clone(),
put:async(value:any,res:Response)=>{await writes.get(key(value));rows.set(key(value),res.clone());},
addAll:async(values:string[])=>{for(const value of values){const res=await fetch(value);if(!res.ok)throw Error('cache failed');await cache.put(value,res);}},
};
runInNewContext(readFileSync(new URL('../public/sw.js',import.meta.url),'utf8'),{
self:{location:{origin:new URL(root).origin},addEventListener:(name:string,cb:any)=>{events[name]=cb;}},
caches:{open:async()=>cache,match:cache.match},fetch,URL,Response,console:{warn(){}},
});
const request=(path:string,mode='cors'):Promise<Response>=>{
let response:Promise<Response>|undefined;
events.fetch({request:{url:key(path),method:'GET',mode},respondWith(p:Promise<Response>){response=p;},waitUntil(p:Promise<unknown>){pending.push(p);}});
return response!;
};
for(const name of ['config.js','manifest.webmanifest','icon-192.png'])network.set(key(name),name);
const hold=(map:Map<string,Promise<void>>,path:string)=>{
let release!:()=>void;
map.set(key(path),new Promise<void>(resolve=>{release=resolve;}));
return release;
};
return {rows,network,key,request,offline:()=>{offline=true;},settle:()=>Promise.all(pending),holdFetch:(path:string)=>hold(holds,path),holdWrite:(path:string)=>hold(writes,path)};
}
test('a client-only shell update caches new bundles before replacing the offline page',async()=>{
const w=worker();
w.rows.set(w.key('./'),new Response('<script src="./assets/old.js"></script>'));
w.rows.set(w.key('assets/old.js'),new Response('old bundle'));
const html='<script src="./assets/new.js"></script><link href="./assets/new.css">';
w.network.set(w.key('./'),html);w.network.set(w.key('assets/new.js'),'new bundle');w.network.set(w.key('assets/new.css'),'new style');
assert.equal(await(await w.request('./','navigate')).text(),html);
await w.settle();
w.offline();
assert.equal(await(await w.request('./','navigate')).text(),html);
assert.equal(await(await w.request('assets/new.js')).text(),'new bundle');
assert.equal(await(await w.request('assets/new.css')).text(),'new style');
assert.equal(await(await w.request('assets/old.js')).text(),'old bundle');
});
test('a failed bundle download retains the previous complete offline shell',async()=>{
const w=worker();const old='<script src="./assets/old.js"></script>';
w.rows.set(w.key('./'),new Response(old));w.rows.set(w.key('assets/old.js'),new Response('old bundle'));
const next='<script src="./assets/unavailable.js"></script>';
w.network.set(w.key('./'),next);
assert.equal(await(await w.request('./','navigate')).text(),next);
await w.settle();
w.offline();
assert.equal(await(await w.request('./','navigate')).text(),old);
assert.equal(await(await w.request('assets/old.js')).text(),'old bundle');
});
test('navigation returns while shell downloads are pending and keeps a complete offline page',async()=>{
const w=worker();const old='<script src="./assets/old.js"></script>';
w.rows.set(w.key('./'),new Response(old));w.rows.set(w.key('assets/old.js'),new Response('old bundle'));
const next='<script src="./assets/new.js"></script>';
w.network.set(w.key('./'),next);w.network.set(w.key('assets/new.js'),'new bundle');
const release=w.holdFetch('assets/new.js');
try {
const body=w.request('./','navigate').then(res=>res.text());
assert.equal(await Promise.race([body,new Promise(resolve=>setTimeout(()=>resolve('blocked'),100))]),next);
assert.equal(await w.rows.get(w.key('./'))!.clone().text(),old);
} finally {release();await w.settle();}
w.offline();
assert.equal(await(await w.request('./','navigate')).text(),next);
assert.equal(await(await w.request('assets/new.js')).text(),'new bundle');
});
test('manifest and icon downloads do not delay navigation',async()=>{
const w=worker();w.network.set(w.key('./'),'page');
const release=w.holdFetch('manifest.webmanifest');
try {
assert.equal(await Promise.race([w.request('./','navigate').then(res=>res.text()),new Promise(resolve=>setTimeout(()=>resolve('blocked'),100))]),'page');
} finally {release();await w.settle();}
assert.equal(await w.rows.get(w.key('./'))!.clone().text(),'page');
});
test('bundle and config responses do not wait for storage writes',async()=>{
for(const path of ['assets/new.js','config.js']){
const w=worker();w.network.set(w.key(path),'content');
const release=w.holdWrite(path);
try {
assert.equal(await Promise.race([w.request(path).then(res=>res.text()),new Promise(resolve=>setTimeout(()=>resolve('blocked'),100))]),'content');
assert.equal(w.rows.has(w.key(path)),false);
} finally {release();await w.settle();}
assert.equal(await w.rows.get(w.key(path))!.clone().text(),'content');
}
});