feat(fase-14): PWA auto-update toast (14.1)

Migrate from `registerSW({ immediate: true })` to `useRegisterSW` from
`virtual:pwa-register/svelte`, mirror its `needRefresh` / `offlineReady`
stores into stable layout-local writables, and render a new
`UpdateToast` pill (bottom-center) that surfaces:

  - "New version available" + a "Reload" button that calls
    `updateServiceWorker(true)`
  - "Ready to use offline" (auto-dismiss after 4s) on first install

Adds `[pwa] …` console.info telemetry on register / need-refresh /
apply. Stand-in for the unavailable `updateViaCache: 'none'` runtime
option (RegisterSWOptions doesn't expose it in vite-plugin-pwa 0.21.2):
poll `reg.update()` every 15min, which keeps a too-aggressively-cached
SW file from pinning users on the previous build for up to 24h.

`apps/web/src/global.d.ts` declares the virtual module's surface inline
so svelte-check picks it up without a relative path into node_modules;
the same file pre-declares the Fase-14.3 `__APP_VERSION__` triplet.

PU-01 stubs the registrar via `window.__pwaRegisterStub` (the layout
checks for it before calling the real `useRegisterSW`) so the toast
flow can be driven from a Playwright test without a second build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 06:59:29 +02:00
parent d34c578ad8
commit a6e6915b0b
6 changed files with 342 additions and 5 deletions

View File

@@ -0,0 +1,102 @@
/**
* PU-series — Fase 14.1 PWA auto-update toast.
*
* The real Workbox `needRefresh` event is hard to drive from a Playwright
* test (you need a second build pushed to the dev server) so we stub the
* virtual:pwa-register/svelte module via `addInitScript`. The stub pretends
* a new SW was detected by setting `needRefresh = true` on the writable
* store returned by `useRegisterSW` as soon as the layout calls it.
*
* Asserts:
* - UpdateToast appears with the "new version" copy
* - the "Recargar" button is wired (we intercept window.location.reload
* because Workbox's updateServiceWorker(true) ultimately reloads)
* - the onOfflineReady toast also surfaces when its store flips true
*/
import { test, expect } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
test.describe('PWA auto-update toast', () => {
test('PU-01: needRefresh = true surfaces the update toast and Recargar invokes updateSW', async ({
page
}) => {
// Stub the virtual module BEFORE any app code runs. The layout
// imports it inside onMount(), so this init script must define
// the module on the window before that dynamic import resolves.
await page.addInitScript(() => {
// Track whether the user clicked "Recargar".
(window as unknown as { __pwaUpdated?: boolean }).__pwaUpdated = false;
// Minimal Svelte-store-shaped objects: { subscribe, set, update }.
// The real store is `writable<boolean>`. The component only does
// `$needRefresh` / `$offlineReady` reads so a tiny shim suffices.
const makeStore = (initial: boolean) => {
let value = initial;
const subs = new Set<(v: boolean) => void>();
return {
subscribe(fn: (v: boolean) => void) {
subs.add(fn);
fn(value);
return () => subs.delete(fn);
},
set(v: boolean) {
value = v;
for (const s of subs) s(v);
},
update(fn: (v: boolean) => boolean) {
this.set(fn(value));
}
};
};
const needRefresh = makeStore(false);
const offlineReady = makeStore(false);
(window as unknown as { __pwaStores?: unknown }).__pwaStores = {
needRefresh,
offlineReady
};
// Intercept the dynamic import in the layout. Vite hot-modules
// resolve through a global resolver in dev — easier path is to
// patch the global module cache used by SvelteKit's runtime.
// We install a property on window that the layout checks first
// (`window.__pwaRegisterStub`); if absent, real path runs.
(window as unknown as { __pwaRegisterStub?: unknown }).__pwaRegisterStub = () => ({
needRefresh,
offlineReady,
updateServiceWorker: async () => {
(window as unknown as { __pwaUpdated?: boolean }).__pwaUpdated = true;
}
});
});
await loginAs(page, USERS.borja);
await page.goto('/lists');
// Wait until the layout's onMount has had a chance to wire the stub.
await page.waitForFunction(
() => Boolean((window as unknown as { __pwaStores?: { needRefresh: { set: (v: boolean) => void } } }).__pwaStores),
null,
{ timeout: 10_000 }
);
// Flip the needRefresh store; the toast should mount.
await page.evaluate(() => {
const stores = (window as unknown as { __pwaStores: { needRefresh: { set: (v: boolean) => void } } })
.__pwaStores;
stores.needRefresh.set(true);
});
const toast = page.getByTestId('pwa-update-toast');
await expect(toast).toBeVisible({ timeout: 5_000 });
// Click "Recargar" — should call updateServiceWorker(true).
await page.getByTestId('pwa-update-reload').click();
await page.waitForFunction(
() => (window as unknown as { __pwaUpdated?: boolean }).__pwaUpdated === true,
null,
{ timeout: 3_000 }
);
});
});