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:
102
apps/web/tests/e2e/pwa-update.test.ts
Normal file
102
apps/web/tests/e2e/pwa-update.test.ts
Normal 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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user