aboutsummaryrefslogtreecommitdiffstats
path: root/tools/sw-reload-test.js
blob: 6ad4fed0d766511d772c34840121ae9df6f251cc (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// A deploy should not refresh the page out from under you.
//
// The app is inlined in the page, so when a new worker takes over, a page
// still running the old build has to reload. But navigations are
// network-first: the refresh that triggered the update has usually already
// fetched the new page, and reloading then is a second, unasked-for refresh a
// beat after your own. Both halves are asserted here — the quiet case and the
// case where a reload is genuinely owed.
//
// Run: bun tools/sw-reload-test.js   (needs a built dist/)
import puppeteer from './puppeteer.js';

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let ok = true;
const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };

const html1 = await Bun.file('dist/index.html').text();
const sw1 = await Bun.file('dist/sw.js').text();
const token = (html1.match(/_v='([a-z0-9]+)'/) || [])[1];
if (!token) { console.log('no build token in dist/index.html — run bun run build'); process.exit(1); }

// A "new deploy" is the same app under a different build token.
const restamp = (tok) => ({ html: html1.replaceAll(token, tok), sw: sw1.replaceAll(token, tok) });
const builds = { v1: { html: html1, sw: sw1 }, v2: restamp('deadbeef00001'), v3: restamp('deadbeef00002') };
let current = builds.v1;

const server = Bun.serve({
  port: 5223,
  fetch(req) {
    const p = new URL(req.url).pathname;
    if (p === '/sw.js') return new Response(current.sw, { headers: { 'content-type': 'text/javascript', 'cache-control': 'no-store' } });
    if (p === '/' || p === '/index.html') return new Response(current.html, { headers: { 'content-type': 'text/html', 'cache-control': 'no-store' } });
    // The worker precaches the shell during install and addAll rejects on any
    // miss — a 404 here means it never activates and the test proves nothing.
    // The split build names its entry after the token (app-<token>.js), so
    // a restamped build asks for app-deadbeef….js: the same file.
    const f = Bun.file('dist' + p.replace(/deadbeef0000\d/, token));
    return new Response(f, { headers: { 'cache-control': 'no-store' } });
  },
});

const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
let loads = 0;
page.on('load', () => { loads++; });
page.on('console', (m) => { if (m.type() === 'error') console.log('   [page]', m.text().slice(0, 160)); });
page.on('pageerror', (e) => console.log('   [pageerror]', e.message.slice(0, 160)));

const controlled = async (ms = 15000) => {
  for (let i = 0; i < ms / 250; i++) {
    if (await page.evaluate(() => !!navigator.serviceWorker.controller)) return true;
    await sleep(250);
  }
  return false;
};

try {
  console.log('[first visit]');
  await page.goto('http://localhost:5223/', { waitUntil: 'load' });
  const reg = await page.evaluate(async () => {
    try {
      const r = await navigator.serviceWorker.register('sw.js', { updateViaCache: 'none' });
      return { ok: true, scope: r.scope, active: !!r.active, installing: !!r.installing, waiting: !!r.waiting };
    } catch (e) { return { ok: false, err: String(e).slice(0, 200) }; }
  });
  console.log('   register:', JSON.stringify(reg));
  check('worker takes control', await controlled());
  await sleep(3000);
  check('a first visit does not reload itself', loads === 1, `${loads} load(s)`);

  console.log('\n[refreshing after a deploy]');
  current = builds.v2;
  const before = loads;
  await page.reload({ waitUntil: 'load' });
  await sleep(6000); // well past install + activate + claim
  check('your refresh is the only refresh', loads === before + 1, `${loads - before} load(s) — one is yours`);
  check('and the new build is what is running', await page.evaluate(() => document.documentElement.outerHTML.includes("_v='deadbeef00001'")));

  console.log('\n[a deploy while the page sits open]');
  current = builds.v3;
  const before2 = loads;
  // no navigation: just let the worker notice, the way the periodic update
  // check would
  await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r && r.update()));
  for (let i = 0; i < 40 && loads === before2; i++) await sleep(250);
  check('a stale page still gets reloaded', loads === before2 + 1, `${loads - before2} reload(s)`);
  await sleep(3000);
  check('and only once', loads === before2 + 1, `${loads - before2} reload(s)`);
  check('now running the newest build', await page.evaluate(() => document.documentElement.outerHTML.includes("_v='deadbeef00002'")));
} catch (e) {
  check('run completed', false, e.message);
} finally {
  await browser.close();
  server.stop(true);
}
console.log(ok ? '\n✅ sw reload behaviour is right' : '\n❌ failures above');
process.exit(ok ? 0 : 1);