From 1966171586e0b736b298b35ad2b209538160639f Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sun, 5 Jul 2026 20:43:19 +0530 Subject: [PATCH 01/68] feat(frontend): add offline support via service worker Precache the app shell and serve cached data when the network is unavailable: - gameplan-sw.js: service worker that precaches built assets listed in a build-time manifest and serves them offline. - vite.config.ts: offlineAssetManifest() plugin emits gameplan-offline-assets.json (CSS/JS/font URLs) at build time. - offline.ts: registers the service worker and exposes isBrowserOffline() / isNetworkError() helpers. - data layer: add staleOnError + cacheKey to useList/useDoc so cached responses are served on network failure. - router.ts: hydrate community/space data from cache and skip route validation (NotFound) when offline or on network errors. --- frontend/src/components/CommentsArea.vue | 5 + frontend/src/components/CommentsList.vue | 3 + frontend/src/components/LastPostReminder.vue | 1 + frontend/src/components/TaskList.vue | 1 + frontend/src/data/apps.ts | 1 + frontend/src/data/communities.ts | 1 + frontend/src/data/communitySpaces.ts | 1 + frontend/src/data/customEmojis.ts | 1 + frontend/src/data/discussions.ts | 2 + frontend/src/data/drafts.ts | 1 + frontend/src/data/notifications.ts | 1 + frontend/src/data/spaces.ts | 2 + frontend/src/data/tasks.ts | 1 + frontend/src/data/unreadCount.ts | 13 +- frontend/src/data/useDraftSync.ts | 2 + frontend/src/data/users.ts | 1 + frontend/src/main.js | 2 + frontend/src/offline.ts | 85 +++++++ frontend/src/pages/Notifications.vue | 1 + frontend/src/pages/Page.vue | 1 + frontend/src/pages/PageGrid.vue | 1 + frontend/src/pages/PersonProfile.vue | 1 + frontend/src/router.ts | 64 ++++- frontend/vite.config.ts | 28 +++ gameplan/www/gameplan-sw.js | 235 +++++++++++++++++++ 25 files changed, 450 insertions(+), 5 deletions(-) create mode 100644 frontend/src/offline.ts create mode 100644 gameplan/www/gameplan-sw.js diff --git a/frontend/src/components/CommentsArea.vue b/frontend/src/components/CommentsArea.vue index bf3a68a1c..76a8bbd8c 100644 --- a/frontend/src/components/CommentsArea.vue +++ b/frontend/src/components/CommentsArea.vue @@ -389,6 +389,7 @@ const composerStorageKey = computed(() => { const comments = useList({ doctype: 'GP Comment', cacheKey: ['Comments', props.doctype, props.name], + staleOnError: true, fields: [ 'name', 'content', @@ -424,6 +425,8 @@ const comments = useList({ const activities = useList({ doctype: 'GP Activity', + cacheKey: ['Activities', props.doctype, props.name], + staleOnError: true, fields: ['name', 'user', 'action', 'data', 'creation'], filters: { reference_doctype: props.doctype, @@ -455,6 +458,8 @@ watch( const polls = useList({ doctype: 'GP Poll', + cacheKey: ['Polls', props.name], + staleOnError: true, fields: [ 'name', 'title', diff --git a/frontend/src/components/CommentsList.vue b/frontend/src/components/CommentsList.vue index 833ebf977..ffe92cd5e 100644 --- a/frontend/src/components/CommentsList.vue +++ b/frontend/src/components/CommentsList.vue @@ -172,6 +172,7 @@ const comments = useList< >({ doctype: 'GP Comment', cacheKey: ['Comments', props.doctype, props.name], + staleOnError: true, fields: [ 'name', 'content', @@ -212,6 +213,8 @@ interface Activity extends Pick({ doctype: 'GP Activity', + cacheKey: ['Activities', props.doctype, props.name], + staleOnError: true, fields: ['name', 'user', 'action', 'data', 'creation'], filters: { reference_doctype: props.doctype, diff --git a/frontend/src/components/LastPostReminder.vue b/frontend/src/components/LastPostReminder.vue index 8cd8607f8..ff2549494 100644 --- a/frontend/src/components/LastPostReminder.vue +++ b/frontend/src/components/LastPostReminder.vue @@ -27,6 +27,7 @@ import { useLocalStorage } from '@vueuse/core' let lastPostAt = useCall({ url: `/api/v2/method/GP User Profile/get_last_post`, cacheKey: 'last_post_at', + staleOnError: true, }) const daysSinceLastPost = computed(() => { diff --git a/frontend/src/components/TaskList.vue b/frontend/src/components/TaskList.vue index 8dc20d35e..41f1aa10f 100644 --- a/frontend/src/components/TaskList.vue +++ b/frontend/src/components/TaskList.vue @@ -179,6 +179,7 @@ const tasks = useList({ orderBy: props.listOptions.orderBy, limit: props.listOptions.pageLength, cacheKey: ['Tasks', props.listOptions], + staleOnError: true, }) const tasksByStatus = computed(() => { diff --git a/frontend/src/data/apps.ts b/frontend/src/data/apps.ts index b4798ccc2..86e00a1cd 100644 --- a/frontend/src/data/apps.ts +++ b/frontend/src/data/apps.ts @@ -10,6 +10,7 @@ interface AppInfo { export const installedApps = useCall({ url: '/api/v2/method/frappe.apps.get_apps', cacheKey: 'apps', + staleOnError: true, immediate: true, transform(data) { let _apps = [ diff --git a/frontend/src/data/communities.ts b/frontend/src/data/communities.ts index 8dbcd9ad9..cc3793124 100644 --- a/frontend/src/data/communities.ts +++ b/frontend/src/data/communities.ts @@ -32,6 +32,7 @@ export let communities = useList({ orderBy: 'title asc', initialData: [], cacheKey: ['Communities', 'with-image'], + staleOnError: true, limit: 999, transform(data) { for (let community of data) { diff --git a/frontend/src/data/communitySpaces.ts b/frontend/src/data/communitySpaces.ts index 5ec37d485..eb79a5958 100644 --- a/frontend/src/data/communitySpaces.ts +++ b/frontend/src/data/communitySpaces.ts @@ -14,6 +14,7 @@ const INACTIVE_SPACE_MONTHS = 2 const spaceActivity = useCall>({ url: '/api/v2/method/GP Project/get_activity', cacheKey: 'spaceActivity', + staleOnError: true, initialData: {}, immediate: true, }) diff --git a/frontend/src/data/customEmojis.ts b/frontend/src/data/customEmojis.ts index a62a24458..0add10195 100644 --- a/frontend/src/data/customEmojis.ts +++ b/frontend/src/data/customEmojis.ts @@ -13,6 +13,7 @@ export const customEmojis = useList({ orderBy: 'creation desc', initialData: [], cacheKey: 'CustomEmojis', + staleOnError: true, limit: 999, immediate: true, }) diff --git a/frontend/src/data/discussions.ts b/frontend/src/data/discussions.ts index 1b2bfa43d..c6f4346f1 100644 --- a/frontend/src/data/discussions.ts +++ b/frontend/src/data/discussions.ts @@ -43,6 +43,7 @@ export function useDiscussions(options: UseDiscussionOptions) { url: '/api/v2/method/gameplan.gameplan.doctype.gp_discussion.api.get_discussions', doctype: 'GP Discussion', cacheKey: options.cacheKey ? ['Discussions', options.cacheKey] : undefined, + staleOnError: true, filters: options.filters, limit: options.limit || 50, orderBy: options.orderBy, @@ -97,6 +98,7 @@ export function useDiscussion(discussionId: MaybeRefOrGetter) { discussionsCache[name] = useDoc({ doctype: 'GP Discussion', name: discussionId, + staleOnError: true, methods: { trackVisit: 'track_visit', markAsUnread: 'mark_as_unread', diff --git a/frontend/src/data/drafts.ts b/frontend/src/data/drafts.ts index 51a46f77b..d49c12808 100644 --- a/frontend/src/data/drafts.ts +++ b/frontend/src/data/drafts.ts @@ -34,6 +34,7 @@ export const drafts = useCall({ // get_my_drafts is owner-scoped on the server; scope the client cache to the session user // too, so a same-tab account switch can't briefly show the previous user's draft rows. cacheKey: ['drafts', session.user], + staleOnError: true, immediate: true, }) diff --git a/frontend/src/data/notifications.ts b/frontend/src/data/notifications.ts index 756660fae..176206d0a 100644 --- a/frontend/src/data/notifications.ts +++ b/frontend/src/data/notifications.ts @@ -4,6 +4,7 @@ import { onSocketEvent } from '@/socket' export let unreadNotifications = useCall({ cacheKey: 'Unread Notifications Count', + staleOnError: true, url: '/api/v2/method/gameplan.api.unread_notifications', initialData: 0, }) diff --git a/frontend/src/data/spaces.ts b/frontend/src/data/spaces.ts index 75c1831c8..87d35c1db 100644 --- a/frontend/src/data/spaces.ts +++ b/frontend/src/data/spaces.ts @@ -43,6 +43,7 @@ export let spaces = useList({ orderBy: 'title asc', limit: 99999, cacheKey: 'spaces', + staleOnError: true, transform(data) { for (let space of data) { space.name = space.name.toString() @@ -118,6 +119,7 @@ export function getSpace(name: string) { export const joinedSpaces = useCall({ url: '/api/v2/method/GP Project/get_joined_spaces', cacheKey: 'joinedSpaces', + staleOnError: true, initialData: [], }) diff --git a/frontend/src/data/tasks.ts b/frontend/src/data/tasks.ts index bc804b9f3..96edcbd61 100644 --- a/frontend/src/data/tasks.ts +++ b/frontend/src/data/tasks.ts @@ -16,6 +16,7 @@ export function useTask(taskId: MaybeRefOrGetter) { tasksCache[name] = useDoc({ doctype: 'GP Task', name: taskId, + staleOnError: true, methods: { trackVisit: 'track_visit', }, diff --git a/frontend/src/data/unreadCount.ts b/frontend/src/data/unreadCount.ts index 8953ee59c..65265d1ff 100644 --- a/frontend/src/data/unreadCount.ts +++ b/frontend/src/data/unreadCount.ts @@ -77,7 +77,10 @@ function loadProjectUnreadCounts(projects?: string[]) { unreadCounts[spaceId] = Number(count) || 0 } return counts - }), + }) + // Offline / network failure: keep serving the last known counts rather than + // rejecting and blanking the UI. + .catch(() => unreadCounts), ) } @@ -100,10 +103,14 @@ export function fetchParticipatingUnreadCount(team: string) { return queued(participatingCountApi, () => participatingCountApi.runMethod .submit({ method: 'get_participating_unread_count', params: { team } }) - .then((count: number) => { + .then((count: number | null) => { + // Offline / network failure surfaces as a null response here — keep the last known + // count instead of zeroing it out. + if (count == null) return participatingUnreadCounts[team] ?? 0 participatingUnreadCounts[team] = Number(count) || 0 return participatingUnreadCounts[team] - }), + }) + .catch(() => participatingUnreadCounts[team] ?? 0), ) } diff --git a/frontend/src/data/useDraftSync.ts b/frontend/src/data/useDraftSync.ts index e49805eed..432500c48 100644 --- a/frontend/src/data/useDraftSync.ts +++ b/frontend/src/data/useDraftSync.ts @@ -14,6 +14,7 @@ */ import { ref, computed, watch, toValue, nextTick, onScopeDispose, type MaybeRefOrGetter } from 'vue' import { call, debounce, toast, dayjsLocal } from 'frappe-ui' +import { isNetworkError } from '@/offline' import { session } from './session' import { isEditorContentEmpty } from '@/utils' import { @@ -240,6 +241,7 @@ export function useDraftSync(options: UseDraftSyncOptions) { }) } } catch (error) { + if (isNetworkError(error)) return null console.error('Draft lookup failed', error) } return null diff --git a/frontend/src/data/users.ts b/frontend/src/data/users.ts index 63e147397..b93d4e3ae 100644 --- a/frontend/src/data/users.ts +++ b/frontend/src/data/users.ts @@ -75,6 +75,7 @@ function mergeUserInfo(user: UserInfo) { export let users = useCall({ url: '/api/v2/method/gameplan.api.get_user_info', cacheKey: 'Users', + staleOnError: true, initialData: [], transform(data) { for (let user of data) { diff --git a/frontend/src/main.js b/frontend/src/main.js index 0d296212a..0be900dce 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -22,6 +22,7 @@ import { useUser, users } from './data/users' import { isSessionUser, session } from './data/session' import { initSocket } from './socket' import resetDataMixin from './utils/resetDataMixin' +import { setupOfflineSupport } from './offline' let globalComponents = { Button, @@ -81,6 +82,7 @@ function setupApp() { socket = initSocket() app.config.globalProperties.$socket = socket app.mount('#app') + setupOfflineSupport() } // Sentry error logging. Loaded lazily (dynamic import) so the ~250 KB SDK stays diff --git a/frontend/src/offline.ts b/frontend/src/offline.ts new file mode 100644 index 000000000..43cf77b49 --- /dev/null +++ b/frontend/src/offline.ts @@ -0,0 +1,85 @@ +const SERVICE_WORKER_URL = '/gameplan-sw.js' +const SERVICE_WORKER_SCOPE = '/g' +const CACHE_URLS_MESSAGE = 'CACHE_URLS' + +export function setupOfflineSupport() { + if ( + import.meta.env.DEV || + typeof navigator === 'undefined' || + !window.isSecureContext || + !('serviceWorker' in navigator) + ) { + return + } + + const register = () => { + navigator.serviceWorker + .register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE }) + .then(async (registration) => { + await unregisterLegacyServiceWorkers(registration) + await warmLoadedAssets(registration) + }) + .catch((error) => { + console.error('Failed to register Gameplan service worker', error) + }) + } + + if (document.readyState === 'complete') { + register() + } else { + window.addEventListener('load', register, { once: true }) + } +} + +export function isBrowserOffline() { + return typeof navigator !== 'undefined' && navigator.onLine === false +} + +export function isNetworkError(error: unknown) { + return error instanceof TypeError && error.message === 'Failed to fetch' +} + +async function unregisterLegacyServiceWorkers(currentRegistration: ServiceWorkerRegistration) { + const registrations = await navigator.serviceWorker.getRegistrations() + const legacyScope = new URL('/g/', window.location.origin).href + + await Promise.all( + registrations + .filter((registration) => { + return registration.scope === legacyScope && registration.scope !== currentRegistration.scope + }) + .map((registration) => registration.unregister()), + ) +} + +async function warmLoadedAssets(registration: ServiceWorkerRegistration) { + await navigator.serviceWorker.ready + postLoadedAssetsToWorker(registration) + window.setTimeout(() => postLoadedAssetsToWorker(registration), 3000) +} + +function postLoadedAssetsToWorker(registration: ServiceWorkerRegistration) { + const urls = getLoadedAssetUrls() + if (!urls.length) return + + registration.active?.postMessage({ + type: CACHE_URLS_MESSAGE, + urls, + }) +} + +function getLoadedAssetUrls() { + return performance + .getEntriesByType('resource') + .map((entry) => entry.name) + .filter(isSameOriginAssetUrl) +} + +function isSameOriginAssetUrl(url: string) { + try { + const assetUrl = new URL(url) + return assetUrl.origin === window.location.origin && assetUrl.pathname.startsWith('/assets/') + } catch { + return false + } +} diff --git a/frontend/src/pages/Notifications.vue b/frontend/src/pages/Notifications.vue index 5605179d3..47b6b00b0 100644 --- a/frontend/src/pages/Notifications.vue +++ b/frontend/src/pages/Notifications.vue @@ -396,6 +396,7 @@ function useNotificationList(read: 0 | 1, cacheKey: string) { // which makes a "load more" button unsafe on a list that mark-as-read reloads. limit: 100, cacheKey, + staleOnError: true, }) } diff --git a/frontend/src/pages/Page.vue b/frontend/src/pages/Page.vue index d7f02ed06..c2517bc7e 100644 --- a/frontend/src/pages/Page.vue +++ b/frontend/src/pages/Page.vue @@ -140,6 +140,7 @@ const contentField = useTemplateRef('contentField') const page = useDoc({ doctype: 'GP Page', name: () => props.pageId, + staleOnError: true, }) // Read from the document, not from the fetch response. The body renders as soon diff --git a/frontend/src/pages/PageGrid.vue b/frontend/src/pages/PageGrid.vue index 19923337f..3b7c114d0 100644 --- a/frontend/src/pages/PageGrid.vue +++ b/frontend/src/pages/PageGrid.vue @@ -96,6 +96,7 @@ const pages = useList({ filters: props.listOptions.filters, orderBy: props.listOptions.orderBy, cacheKey: ['Pages', props.listOptions], + staleOnError: true, }) function getSpace(page: Page) { diff --git a/frontend/src/pages/PersonProfile.vue b/frontend/src/pages/PersonProfile.vue index 2c18e8db0..960894b08 100644 --- a/frontend/src/pages/PersonProfile.vue +++ b/frontend/src/pages/PersonProfile.vue @@ -86,6 +86,7 @@ const profileResource = useDoc({ setImage: 'set_image', setCoverImagePosition: 'set_cover_image_position', }, + staleOnError: true, }) const profile = computed(() => profileResource.doc) diff --git a/frontend/src/router.ts b/frontend/src/router.ts index b9f7a2115..0b4b038f8 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -6,6 +6,7 @@ import { type RouteRecordRaw, } from 'vue-router' import { until } from '@vueuse/core' +import { watch } from 'vue' import { call } from 'frappe-ui' import { session } from './data/session' import { users, usersReady } from './data/users' @@ -15,11 +16,14 @@ import type { Space } from './data/spaces' import { communityState } from './data/communityState' import { settingsBackgroundPath } from './components/Settings' import { getScrollContainer, scrollTo } from 'frappe-ui' +import { isBrowserOffline, isNetworkError } from './offline' declare const __FRONTEND_ROUTE__: string type ResourceLike = { isFinished?: boolean + data?: unknown + error?: unknown } type RouteParamValue = string | string[] @@ -36,7 +40,8 @@ type ProjectContentDoc = { } const discussionFeeds = ['recent', 'unread', 'participating'] -const projectContentDocRequests = new Map>() +const projectContentDocRequests = new Map>() +const OFFLINE_CACHE_HYDRATION_TIMEOUT = 3000 // Redirect-style guards still need a component record so Vue Router matches them consistently. const RouteGuard = { render: () => null } @@ -801,6 +806,10 @@ router.beforeEach(async (to, from) => { let space = to.params.spaceId ? getSpace(routeParam(to.params.spaceId)) : null if (to.params.spaceId && !space) { + if (isRouteValidationUnavailable()) { + communityState.scope(communityId) + return + } return { name: 'NotFound' } } @@ -814,6 +823,10 @@ router.beforeEach(async (to, from) => { // Public communities are visible even when the user has not joined them, so route validity // cannot be tied to the active sidebar community list. if (!community) { + if (isRouteValidationUnavailable()) { + communityState.scope(communityId) + return + } return { name: 'NotFound' } } @@ -828,6 +841,9 @@ export default router async function ensureCommunityDataLoaded() { await Promise.all([waitForResource(communities), waitForResource(spaces)]) + if (isBrowserOffline()) { + await Promise.all([waitForOfflineCachedData(communities), waitForOfflineCachedData(spaces)]) + } } async function waitForResource(resource: ResourceLike) { @@ -838,6 +854,43 @@ async function waitForResource(resource: ResourceLike) { await until(() => resource?.isFinished).toBe(true) } +function waitForOfflineCachedData(resource: ResourceLike) { + if (hasHydratedData(resource)) { + return Promise.resolve() + } + + return new Promise((resolve) => { + const timeout = window.setTimeout(done, OFFLINE_CACHE_HYDRATION_TIMEOUT) + const stop = watch( + () => resource.data, + () => { + if (hasHydratedData(resource)) { + done() + } + }, + ) + + function done() { + window.clearTimeout(timeout) + stop() + resolve() + } + }) +} + +function hasHydratedData(resource: ResourceLike) { + const data = resource.data + return Array.isArray(data) ? data.length > 0 : data != null +} + +function isRouteValidationUnavailable() { + return isBrowserOffline() || hasNetworkError(communities) || hasNetworkError(spaces) +} + +function hasNetworkError(resource: ResourceLike) { + return Boolean(resource?.error && isNetworkError(resource.error)) +} + export function getHomeRoute(): RouteLocationRaw { if (isMobileViewport() && communityState.id) { return { name: 'Home' } @@ -904,8 +957,14 @@ async function getCanonicalContentRoute( // space/slug rewrites to canonical. const isInAppNavigation = from.matched.length > 0 if (isInAppNavigation && hasCanonicalLocalParams(to, descriptor)) return + if (isRouteValidationUnavailable() && hasCanonicalLocalParams(to, descriptor)) return + if (isRouteValidationUnavailable()) return const doc = await getProjectContentDoc(descriptor.doctype, documentName) + if (doc === undefined) { + if (hasCanonicalLocalParams(to, descriptor)) return + return + } if (!doc?.project) return { name: 'NotFound' } const space = await findSpace(String(doc.project)) @@ -993,7 +1052,8 @@ async function getProjectContentDoc(doctype: ContentRouteDescriptor['doctype'], async function fetchProjectContentDoc(doctype: ContentRouteDescriptor['doctype'], name: string) { try { return await call('frappe.client.get', { doctype, name }) - } catch { + } catch (error) { + if (isNetworkError(error)) return undefined return null } } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b3ea4b6f5..a9c7b150a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -60,6 +60,7 @@ export default defineConfig({ }), vue(), vueJsx(), + offlineAssetManifest(), visualizer({ emitFile: true }) as PluginOption, // `extension` must list .vue explicitly: the plugin's default covers .js/.ts // only, which would silently report on the ~600 lines of utils and composables @@ -144,3 +145,30 @@ export default defineConfig({ include: ['feather-icons'], }, }) + +function offlineAssetManifest(): PluginOption { + return { + name: 'gameplan-offline-asset-manifest', + apply: 'build', + generateBundle(_, bundle) { + const urls = Object.values(bundle) + .map((entry) => entry.fileName) + .filter(isOfflineAsset) + .sort() + .map((fileName) => `/assets/gameplan/frontend/${fileName}`) + + this.emitFile({ + type: 'asset', + fileName: 'gameplan-offline-assets.json', + source: JSON.stringify(urls), + }) + }, + } +} + +function isOfflineAsset(fileName: string) { + if (!fileName.startsWith('assets/')) return false + if (fileName.endsWith('.map')) return false + + return ['.css', '.js', '.woff', '.woff2'].some((extension) => fileName.endsWith(extension)) +} diff --git a/gameplan/www/gameplan-sw.js b/gameplan/www/gameplan-sw.js new file mode 100644 index 000000000..7c2de13ce --- /dev/null +++ b/gameplan/www/gameplan-sw.js @@ -0,0 +1,235 @@ +const CACHE_PREFIX = "gameplan-readonly-offline"; +const CACHE_VERSION = "v5"; +const SHELL_CACHE = `${CACHE_PREFIX}:${CACHE_VERSION}:shell`; +const ASSET_CACHE = `${CACHE_PREFIX}:${CACHE_VERSION}:assets`; +const APP_SHELL_URL = "/g"; +const OFFLINE_ASSET_MANIFEST_URL = + "/assets/gameplan/frontend/gameplan-offline-assets.json"; +const PRECACHE_URLS = [ + APP_SHELL_URL, + OFFLINE_ASSET_MANIFEST_URL, + "/assets/gameplan/manifest/site.webmanifest", + "/assets/gameplan/manifest/manifest-icon-192.maskable.png", + "/assets/gameplan/manifest/manifest-icon-512.maskable.png", +]; +const CACHEABLE_DESTINATIONS = new Set(["font", "image", "script", "style"]); + +self.addEventListener("install", (event) => { + event.waitUntil(warmShellCache()); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(deleteOldCaches()); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + if (isGameplanNavigation(request, url)) { + event.respondWith(networkFirstNavigation(request)); + return; + } + + if (isApiRequest(url)) return; + + if (url.pathname.startsWith("/assets/")) { + event.respondWith(cacheFirst(request)); + return; + } + + if (CACHEABLE_DESTINATIONS.has(request.destination)) { + event.respondWith(staleWhileRevalidate(request)); + } +}); + +self.addEventListener("message", (event) => { + if (event.data?.type !== "CACHE_URLS" || !Array.isArray(event.data.urls)) + return; + event.waitUntil(cacheUrls(event.data.urls)); +}); + +async function warmShellCache() { + const cache = await caches.open(SHELL_CACHE); + await Promise.all( + PRECACHE_URLS.map(async (url) => { + try { + const response = await fetch( + new Request(url, { credentials: "include", cache: "reload" }), + ); + if (isCacheableResponse(response)) { + await cache.put(url, response.clone()); + if (isHtmlResponse(response)) { + await cacheShellAssets(response); + } + if (url === OFFLINE_ASSET_MANIFEST_URL) { + await cacheOfflineAssetManifest(response.clone()); + } + } + } catch { + // The runtime fetch handler will populate the cache once the app is online. + } + }), + ); +} + +async function deleteOldCaches() { + const currentCaches = new Set([SHELL_CACHE, ASSET_CACHE]); + const names = await caches.keys(); + await Promise.all( + names + .filter( + (name) => name.startsWith(CACHE_PREFIX) && !currentCaches.has(name), + ) + .map((name) => caches.delete(name)), + ); +} + +function isGameplanNavigation(request, url) { + return ( + request.mode === "navigate" && + (url.pathname === "/g" || url.pathname.startsWith("/g/")) + ); +} + +function isApiRequest(url) { + return ( + url.pathname.startsWith("/api/") || url.pathname.startsWith("/socket.io/") + ); +} + +async function networkFirstNavigation(request) { + const cache = await caches.open(SHELL_CACHE); + try { + const response = await fetch(request); + if (isHtmlResponse(response)) { + await cache.put(APP_SHELL_URL, response.clone()); + await cacheShellAssets(response.clone()); + } + return response; + } catch { + const cachedRequest = await cache.match(request); + const cachedShell = await cache.match(APP_SHELL_URL); + return cachedRequest || cachedShell || Response.error(); + } +} + +async function cacheShellAssets(response) { + const urls = getShellAssetUrls(await response.text()); + await cacheUrls(urls); + await cacheOfflineAssetManifest(); +} + +function getShellAssetUrls(html) { + const urls = new Set(); + const assetPattern = /\b(?:src|href)=["']([^"']+)["']/g; + + for (const match of html.matchAll(assetPattern)) { + const url = new URL(match[1], self.location.origin); + if ( + url.origin === self.location.origin && + url.pathname.startsWith("/assets/") + ) { + urls.add(url.href); + } + } + + return [...urls]; +} + +async function cacheUrls(urls) { + const cache = await caches.open(ASSET_CACHE); + const sameOriginAssetUrls = urls.filter(isSameOriginAssetUrl); + + await Promise.all( + sameOriginAssetUrls.map(async (url) => { + try { + const request = new Request(url, { credentials: "include" }); + const cached = await cache.match(request); + if (cached) return; + + const response = await fetch(request); + if (isCacheableResponse(response)) { + await cache.put(request, response); + } + } catch { + // The next online visit to the route will retry this asset. + } + }), + ); +} + +async function cacheOfflineAssetManifest(response) { + try { + const manifestResponse = + response || + (await fetch(OFFLINE_ASSET_MANIFEST_URL, { cache: "reload" })); + if (!isCacheableResponse(manifestResponse)) return; + + const urls = await manifestResponse.clone().json(); + if (Array.isArray(urls)) { + await cacheUrls(urls); + } + } catch { + // Older builds do not have the manifest; route assets will still be cached as they load. + } +} + +function isSameOriginAssetUrl(url) { + try { + const assetUrl = new URL(url, self.location.origin); + return ( + assetUrl.origin === self.location.origin && + assetUrl.pathname.startsWith("/assets/") + ); + } catch { + return false; + } +} + +async function cacheFirst(request) { + const cache = await caches.open(ASSET_CACHE); + const cached = await cache.match(request); + if (cached) return cached; + + try { + const response = await fetch(request); + if (isCacheableResponse(response)) { + await cache.put(request, response.clone()); + } + return response; + } catch { + return Response.error(); + } +} + +async function staleWhileRevalidate(request) { + const cache = await caches.open(ASSET_CACHE); + const cached = await cache.match(request); + const fetched = fetch(request) + .then((response) => { + if (isCacheableResponse(response)) { + cache.put(request, response.clone()); + } + return response; + }) + .catch(() => null); + + return cached || (await fetched) || Response.error(); +} + +function isCacheableResponse(response) { + return response && response.ok && response.type === "basic"; +} + +function isHtmlResponse(response) { + return ( + isCacheableResponse(response) && + response.headers.get("content-type")?.includes("text/html") + ); +} From 8d1eb4f359b03bdb83f117f53c1534509d3626b6 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sun, 9 Aug 2026 03:02:46 +0530 Subject: [PATCH 02/68] feat(frontend): offline indicator and auto-refetch on reconnect Show an unobtrusive pill while the browser is offline, and refetch feeds, unread counts, and open discussion timelines once connectivity returns, so content posted by others while offline shows up without a manual reload. --- frontend/src/App.vue | 2 + frontend/src/components/CommentsArea.vue | 14 ++++++ frontend/src/components/CommentsList.vue | 9 ++++ frontend/src/components/OfflineIndicator.vue | 29 +++++++++++++ frontend/src/data/discussions.ts | 6 +++ frontend/src/data/online.ts | 45 ++++++++++++++++++++ frontend/src/data/unreadCount.ts | 27 +++++++----- 7 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/OfflineIndicator.vue create mode 100644 frontend/src/data/online.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0d9a36065..c9b973028 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -13,6 +13,7 @@ flip isFinished back to false and unmount the open settings dialog. --> + @@ -27,6 +28,7 @@ import { useTheme } from '@/utils/useTheme' import { useCursorStyle } from '@/utils/useCursorStyle' import NewTaskDialog from './components/NewTaskDialog/NewTaskDialog.vue' import SettingsDialog from './components/Settings/SettingsDialog.vue' +import OfflineIndicator from './components/OfflineIndicator.vue' import { settingsBackgroundPath } from './components/Settings' import { getHomeRoute } from '@/router' diff --git a/frontend/src/components/CommentsArea.vue b/frontend/src/components/CommentsArea.vue index 76a8bbd8c..e27723304 100644 --- a/frontend/src/components/CommentsArea.vue +++ b/frontend/src/components/CommentsArea.vue @@ -275,6 +275,7 @@ import { tags } from '@/data/tags' import { isNewCommentOpen } from '@/data/newComment' import { useRichQuotes } from '@/components/RichQuoteExtension/useRichQuotes' import { useDraftSync } from '@/data/useDraftSync' +import { onReconnect } from '@/data/online' import { useSessionUser } from '@/data/users' import type { Space } from '@/data/spaces' import { useIsMobile } from 'frappe-ui' @@ -494,6 +495,19 @@ watchEffect(() => { } }) +// US5 (seamless recovery): while offline, comments/activity/polls posted by +// other users never arrive — the socket that normally pushes them is down too. +// Reload this discussion's timeline once the browser comes back online. +// Unregistered on unmount: this callback closes over lists owned by this +// component instance, and there's no reason to keep refetching an open +// discussion the user has already navigated away from. +const unregisterReconnect = onReconnect(() => { + comments.reload() + activities.reload() + polls.reload() +}) +onUnmounted(unregisterReconnect) + // Computed const timelineItems = computed(() => { let items: Array = [] diff --git a/frontend/src/components/CommentsList.vue b/frontend/src/components/CommentsList.vue index ffe92cd5e..69d1b47fc 100644 --- a/frontend/src/components/CommentsList.vue +++ b/frontend/src/components/CommentsList.vue @@ -118,6 +118,7 @@ import { subscribeToDoc, useSocket, type NewActivityEvent } from '@/socket' import { GPActivity, GPComment } from '@/types/doctypes' import type { Space } from '@/data/spaces' import { useDraftSync } from '@/data/useDraftSync' +import { onReconnect } from '@/data/online' interface Props { doctype: string @@ -231,6 +232,14 @@ const activities = useList({ }, }) +// US5 (seamless recovery): mirrors the same reconnect reload in CommentsArea.vue +// (discussion comments) for this task's comment/activity timeline. +const unregisterReconnect = onReconnect(() => { + comments.reload() + activities.reload() +}) +onUnmounted(unregisterReconnect) + // Computed type GroupedActivity = { doctype: 'GP Activity' diff --git a/frontend/src/components/OfflineIndicator.vue b/frontend/src/components/OfflineIndicator.vue new file mode 100644 index 000000000..b409c8d28 --- /dev/null +++ b/frontend/src/components/OfflineIndicator.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/data/discussions.ts b/frontend/src/data/discussions.ts index c6f4346f1..6fad89eec 100644 --- a/frontend/src/data/discussions.ts +++ b/frontend/src/data/discussions.ts @@ -3,6 +3,7 @@ import { useDoc, useList } from 'frappe-ui' import { UseListOptions } from 'frappe-ui' import { useDocumentVisibility } from '@vueuse/core' import { GPDiscussion } from '@/types/doctypes' +import { onReconnect } from '@/data/online' // Reload the feed when the tab is re-activated after sitting in the background // for at least this long, so new posts show up without a manual refresh. @@ -22,6 +23,11 @@ export function reloadDiscussionLists() { reloadSignal.value++ } +// US5 (seamless recovery): a discussion created or updated by someone else +// while we were offline is invisible until something refetches. Mounted feeds +// pick this signal up via the reloadSignal watcher below. +onReconnect(reloadDiscussionLists) + export interface Discussion extends GPDiscussion { project_title: string last_post_at: string diff --git a/frontend/src/data/online.ts b/frontend/src/data/online.ts new file mode 100644 index 000000000..b759a9b6b --- /dev/null +++ b/frontend/src/data/online.ts @@ -0,0 +1,45 @@ +import { useDebounceFn, useOnline } from '@vueuse/core' +import { watch } from 'vue' + +// Single shared `navigator.onLine` + online/offline event listener for the whole +// app (US3's indicator and US5's reconnect refetch both read this). +export const isOnline = useOnline() + +// Flaky connectivity (a train tunnel, a flapping wifi radio) can fire several +// offline→online transitions within a second or two. Debouncing the notification +// (not the ref itself, so the indicator stays instantly responsive) means a +// stampede of refetches only fires once connectivity actually settles. +const RECONNECT_DEBOUNCE_MS = 1500 + +type ReconnectCallback = () => void +const callbacks = new Set() + +/** + * Register a callback to run when the browser transitions from offline to + * online (debounced — see RECONNECT_DEBOUNCE_MS). Returns an unregister + * function; call it from `onUnmounted` for callbacks owned by a component so a + * torn-down view doesn't keep refetching after it's gone. + */ +export function onReconnect(callback: ReconnectCallback): () => void { + callbacks.add(callback) + return () => callbacks.delete(callback) +} + +const notifyReconnect = useDebounceFn(() => { + // Re-check at fire time: connectivity may have dropped again during the + // debounce window, in which case there's nothing to reconnect yet. + if (!isOnline.value) return + for (const callback of callbacks) { + try { + callback() + } catch (error) { + console.error('onReconnect callback failed', error) + } + } +}, RECONNECT_DEBOUNCE_MS) + +// watch() (not `immediate`) only fires on actual changes, so this is inert on +// initial load and only triggers on a genuine offline→online flip. +watch(isOnline, (online) => { + if (online) notifyReconnect() +}) diff --git a/frontend/src/data/unreadCount.ts b/frontend/src/data/unreadCount.ts index 65265d1ff..809711bc1 100644 --- a/frontend/src/data/unreadCount.ts +++ b/frontend/src/data/unreadCount.ts @@ -3,6 +3,7 @@ import { GPProject } from '@/types/doctypes' import { reactive } from 'vue' import { useDebounceFn } from '@vueuse/core' import { onSocketEvent } from '@/socket' +import { onReconnect } from '@/data/online' interface ProjectUnreadCount { [spaceId: string]: number @@ -167,19 +168,23 @@ export function refreshUnreadCountForProjects(projects: string[]) { return loadProjectUnreadCounts(projects) } +function refreshAllUnreadCounts() { + // Nothing awaits these; swallow failures so a dropped request doesn't surface as an + // unhandled rejection. The next signal (or a page load) refetches anyway. + Promise.allSettled([ + loadProjectUnreadCounts(), + ...Object.keys(participatingUnreadCounts).map((team) => fetchParticipatingUnreadCount(team)), + ]) +} + // The backend signals this after any create/mark-read change to GP Unread Record, so other tabs // (and spaces you're not currently viewing) pick up the change without a manual reload. // Debounced because one action fans out several signals — posting creates records for every // recipient and marks the thread read for the author — and each one costs a full map fetch plus // a request per cached community. -onSocketEvent( - 'gameplan:unread_counts_changed', - useDebounceFn(() => { - // Nothing awaits these; swallow failures so a dropped request doesn't surface as an - // unhandled rejection. The next signal (or a page load) refetches anyway. - Promise.allSettled([ - loadProjectUnreadCounts(), - ...Object.keys(participatingUnreadCounts).map((team) => fetchParticipatingUnreadCount(team)), - ]) - }, 500), -) +onSocketEvent('gameplan:unread_counts_changed', useDebounceFn(refreshAllUnreadCounts, 500)) + +// US5 (seamless recovery): while offline the socket is disconnected too, so any +// unread-count-changing activity from other users never reached us. Reload once +// reconnected rather than waiting on the next unrelated socket signal. +onReconnect(refreshAllUnreadCounts) From 327d7ae30015f7c79d043e300411bd375d3bae19 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sun, 9 Aug 2026 03:02:52 +0530 Subject: [PATCH 03/68] fix(frontend): wait for cache hydration before onboarding redirect when network is unreliable navigator.onLine can briefly lag the real network state right after a reload, so the home-route decision was racing communities/spaces IndexedDB hydration and occasionally sending an offline reload to onboarding instead of the cached feed. Treat a resource that already failed with a network error the same as isBrowserOffline() when deciding whether to wait for the cache. --- frontend/src/router.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 0b4b038f8..3621913d4 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -841,7 +841,13 @@ export default router async function ensureCommunityDataLoaded() { await Promise.all([waitForResource(communities), waitForResource(spaces)]) - if (isBrowserOffline()) { + // Right after a reload, navigator.onLine can briefly lag the browser's actual + // network state, so isBrowserOffline() alone can miss a reload that's genuinely + // offline. A resource that already finished with a network error is unambiguous + // proof the fresh fetch can't be trusted, so treat either signal the same way: + // give the IndexedDB cache a bounded chance to hydrate before the home route is + // decided from (possibly still-empty) `communities`/`spaces` data. + if (isNetworkUnreliable()) { await Promise.all([waitForOfflineCachedData(communities), waitForOfflineCachedData(spaces)]) } } @@ -884,6 +890,10 @@ function hasHydratedData(resource: ResourceLike) { } function isRouteValidationUnavailable() { + return isNetworkUnreliable() +} + +function isNetworkUnreliable() { return isBrowserOffline() || hasNetworkError(communities) || hasNetworkError(spaces) } From 56489a59092555a7b14653d35a3f0a5325752d34 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sun, 9 Aug 2026 03:03:23 +0530 Subject: [PATCH 04/68] feat(frontend): offline fallbacks for uncached discussions and space lists; scope offline caches per user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a friendly "can't load this while offline" state (with retry) for a never-visited discussion or space discussion list, instead of a blank or silently-empty screen. Also scope every offline-cached list/doc/call key to the session user (cacheKey: [..., session.user], matching the existing drafts.ts pattern), so a second account signing into the same browser can't read the previous account's cached data before its own permission-checked fetch resolves — review finding from PR #516. users.ts reads the session user straight from the cookie rather than importing session.ts: session.ts itself imports users.ts before assigning its `session` export, so importing it back from users.ts at module scope threw on boot. --- frontend/src/components/CommentsArea.vue | 9 +++-- frontend/src/components/CommentsList.vue | 8 +++- frontend/src/components/DiscussionView.vue | 15 ++++++++ frontend/src/components/LastPostReminder.vue | 5 ++- .../src/components/OfflineContentFallback.vue | 24 ++++++++++++ frontend/src/components/TaskList.vue | 6 ++- frontend/src/data/communities.ts | 5 ++- frontend/src/data/communitySpaces.ts | 5 ++- frontend/src/data/discussions.ts | 6 ++- frontend/src/data/notifications.ts | 5 ++- frontend/src/data/spaces.ts | 7 +++- frontend/src/data/users.ts | 15 +++++++- .../pages/Configure/useCommunitySpaceData.ts | 8 +++- frontend/src/pages/Notifications.vue | 4 +- frontend/src/pages/PageGrid.vue | 17 +++++---- frontend/src/pages/SpaceDiscussions.vue | 37 +++++++++++++++++++ 16 files changed, 152 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/OfflineContentFallback.vue diff --git a/frontend/src/components/CommentsArea.vue b/frontend/src/components/CommentsArea.vue index e27723304..e00e6ad7b 100644 --- a/frontend/src/components/CommentsArea.vue +++ b/frontend/src/components/CommentsArea.vue @@ -389,7 +389,10 @@ const composerStorageKey = computed(() => { const comments = useList({ doctype: 'GP Comment', - cacheKey: ['Comments', props.doctype, props.name], + // Scoped to the session user: a discussion's comments can live in a private space, + // so a second account on the same browser must not see them cached offline before + // its own permission-checked fetch resolves (review finding from PR #516). + cacheKey: ['Comments', props.doctype, props.name, sessionUser.name], staleOnError: true, fields: [ 'name', @@ -426,7 +429,7 @@ const comments = useList({ const activities = useList({ doctype: 'GP Activity', - cacheKey: ['Activities', props.doctype, props.name], + cacheKey: ['Activities', props.doctype, props.name, sessionUser.name], staleOnError: true, fields: ['name', 'user', 'action', 'data', 'creation'], filters: { @@ -459,7 +462,7 @@ watch( const polls = useList({ doctype: 'GP Poll', - cacheKey: ['Polls', props.name], + cacheKey: ['Polls', props.name, sessionUser.name], staleOnError: true, fields: [ 'name', diff --git a/frontend/src/components/CommentsList.vue b/frontend/src/components/CommentsList.vue index 69d1b47fc..4b7acd447 100644 --- a/frontend/src/components/CommentsList.vue +++ b/frontend/src/components/CommentsList.vue @@ -119,6 +119,7 @@ import { GPActivity, GPComment } from '@/types/doctypes' import type { Space } from '@/data/spaces' import { useDraftSync } from '@/data/useDraftSync' import { onReconnect } from '@/data/online' +import { session } from '@/data/session' interface Props { doctype: string @@ -172,7 +173,10 @@ const comments = useList< > >({ doctype: 'GP Comment', - cacheKey: ['Comments', props.doctype, props.name], + // Scoped to the session user: a discussion's comments can live in a private space, + // so a second account on the same browser must not see them cached offline before + // its own permission-checked fetch resolves (review finding from PR #516). + cacheKey: ['Comments', props.doctype, props.name, session.user], staleOnError: true, fields: [ 'name', @@ -214,7 +218,7 @@ interface Activity extends Pick({ doctype: 'GP Activity', - cacheKey: ['Activities', props.doctype, props.name], + cacheKey: ['Activities', props.doctype, props.name, session.user], staleOnError: true, fields: ['name', 'user', 'action', 'data', 'creation'], filters: { diff --git a/frontend/src/components/DiscussionView.vue b/frontend/src/components/DiscussionView.vue index e3eee11a3..d3d280d8a 100644 --- a/frontend/src/components/DiscussionView.vue +++ b/frontend/src/components/DiscussionView.vue @@ -275,6 +275,16 @@ again.

+ + + (null) const discussionListRef = useTemplateRef('discussionListRef') const router = useRouter() +// DiscussionList owns the useList resource; reach into it through its exposed ref rather +// than duplicating the fetch here, so this page can tell "loaded, genuinely empty" (handled +// inside DiscussionList already) apart from "fetch failed, nothing cached" (not handled +// there - see the v-show/OfflineContentFallback pairing below). +const discussionsResource = computed(() => discussionListRef.value?.discussions) +const listFailure = computed(() => { + const discussions = discussionsResource.value + if (!discussions) return null + const failed = + discussions.isFinished && !discussions.loading && discussions.error && discussions.data == null + if (!failed) return null + + const offline = isBrowserOffline() || isNetworkError(discussions.error) + return offline + ? { + title: "Can't load this while offline", + message: "This space's discussions haven't been saved for offline use yet.", + } + : { + title: 'Could not load discussions', + message: 'Something went wrong while loading this list. Retry to try again.', + } +}) const { space: currentSpace, isArchived, From 94d311b0578ad8ad6b98cfcb48adbbb9772cf34e Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sun, 9 Aug 2026 15:51:20 +0530 Subject: [PATCH 05/68] fix(profiles): accept query params in GP User Profile get_list frappe-ui's useList sends fields/filters/start/limit as GET query-string values, but get_list type-hinted fields/filters as dict and start/limit implicitly as int, so Frappe's own request coercion rejected the strings before the function body ran. Parse fields/filters with frappe.parse_json and coerce start/limit with cint, matching how the builtin /api/v2/document/ list route and gp_discussion.api.get_discussions already handle this. --- .../gp_user_profile/gp_user_profile.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/gameplan/gameplan/doctype/gp_user_profile/gp_user_profile.py b/gameplan/gameplan/doctype/gp_user_profile/gp_user_profile.py index 2b7d337e2..9d412ba2a 100644 --- a/gameplan/gameplan/doctype/gp_user_profile/gp_user_profile.py +++ b/gameplan/gameplan/doctype/gp_user_profile/gp_user_profile.py @@ -8,6 +8,7 @@ from frappe.model.document import Document from frappe.model.naming import append_number_if_name_exists from frappe.query_builder.functions import Count +from frappe.utils import cint from frappe.website.utils import cleanup_page_name from gameplan.api import get_user_info, require_admin @@ -208,7 +209,7 @@ def on_user_update(doc, method=None): @frappe.whitelist() def get_list( fields=None, - filters: dict | None = None, + filters=None, order_by=None, start=0, limit=20, @@ -216,15 +217,24 @@ def get_list( parent=None, debug=False, ): + # `fields`/`filters` are typed `dict | list` shapes on the query builder, and + # `start`/`limit` are ints, but a GET caller (frappe-ui's `useList`, which this + # endpoint is built for) can only send everything JSON-encoded/stringified in the + # query string. `fields`/`filters` left type-hinted `dict | None` had Frappe's own + # request-typing coercion reject the string before this function body ever ran, so + # they're parsed by hand instead - the same way the builtin `/api/v2/document/` + # list route and `gp_discussion.api.get_discussions` do it for the same reason. + # `start`/`limit` get the same treatment via `cint` - the query builder requires an + # actual int and rejects `"3"` outright. doctype = "GP User Profile" check_permissions(doctype, parent) query = frappe.qb.get_query( table=doctype, - fields=fields, - filters=filters, + fields=frappe.parse_json(fields) if fields else None, + filters=frappe.parse_json(filters) if filters else None, order_by=order_by, - offset=start, - limit=limit, + offset=cint(start), + limit=cint(limit), group_by=group_by, ) data = query.run(as_dict=True, debug=debug) From db554e12086ece6aae4017bd96e82d62e250eb57 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sun, 9 Aug 2026 15:51:30 +0530 Subject: [PATCH 06/68] feat(frontend): offline caching and fallbacks for people list and person profiles Moves the People list from the legacy Options-API resource (no offline persistence, not scoped per user) to a data/people.ts useList singleton with cacheKey ['People', session.user]. Reworks ProfileBento's card fetch onto useCall with cacheKey ['ProfileBento', personId, session.user] so it can resolve on failure instead of hanging forever, and fixes a broken relative API URL that made bento cards fail for everyone (online included) - useCall takes its url verbatim, unlike call()'s automatic /api/method/ prefix. People.vue, PersonProfile.vue, PersonProfileProfile.vue, PersonProfilePosts/Replies.vue all get an explicit offline/network failure state (OfflineContentFallback, with retry) distinct from a genuinely empty list or a real 404 - a failed fetch used to render as "0 members", an infinite skeleton, or a misleading NotFound. --- .../ProfileBento/profileBentoSource.ts | 96 ++++++- frontend/src/data/people.ts | 48 ++++ frontend/src/pages/People.vue | 252 +++++++++--------- frontend/src/pages/PersonProfile.vue | 125 +++++---- frontend/src/pages/PersonProfilePosts.vue | 57 +++- frontend/src/pages/PersonProfileProfile.vue | 16 +- frontend/src/pages/PersonProfileReplies.vue | 57 +++- 7 files changed, 451 insertions(+), 200 deletions(-) create mode 100644 frontend/src/data/people.ts diff --git a/frontend/src/components/ProfileBento/profileBentoSource.ts b/frontend/src/components/ProfileBento/profileBentoSource.ts index 222f56dee..eece2fc48 100644 --- a/frontend/src/components/ProfileBento/profileBentoSource.ts +++ b/frontend/src/components/ProfileBento/profileBentoSource.ts @@ -1,6 +1,8 @@ -import { call } from 'frappe-ui' +import { call, useCall } from 'frappe-ui' +import { computed, MaybeRefOrGetter, toValue, watch } from 'vue' import type { ProfileBentoCard } from './types' import type { ProfileBentoCardSource } from './useProfileBentoCustomization' +import { session } from '@/data/session' interface ProfileBentoResponse { profile: string @@ -49,14 +51,96 @@ export async function resetProfileBentoCards() { return getLoadResultFromResponse(response) } -export async function getProfileBentoCards(profile: string) { - let response = await call(getProfileBentoCardsMethod, { profile }) - return getLoadResultFromResponse(response) -} - function getLoadResultFromResponse(response: ProfileBentoResponse): ProfileBentoLoadResult { return { cards: response.cards || [], isDefault: response.is_default, } } + +// One `get_bento_cards` fetch per profile, keyed by profile name + session user (an +// offline cache scoped any other way could leak one account's cached cards to a second +// account sharing the browser - review finding from PR #516). Shared by `useProfileBento` +// (the live page) and `prefetchProfileBento` (the background cache warmer) so both read +// and write the same IndexedDB entry instead of racing two independent requests for the +// same profile. +const bentoCalls: Record> = {} + +function createProfileBentoCall(profile: string) { + return useCall({ + // `useCall` takes `url` verbatim - unlike `call()` (used by createServerProfileBentoSource + // below), it does not prefix a dotted method path with /api/method/ itself. A bare method + // name here would resolve relative to whatever page the app is currently on and hit the + // SPA's own catch-all route (200, HTML) instead of the API - see the same trap called out + // in Notifications.vue's markAllAsRead. + url: `/api/v2/method/${getProfileBentoCardsMethod}`, + params: { profile }, + cacheKey: ['ProfileBento', profile, session.user], + staleOnError: true, + immediate: false, + }) +} + +function getProfileBentoCall(profile: string) { + if (!bentoCalls[profile]) { + bentoCalls[profile] = createProfileBentoCall(profile) + } + return bentoCalls[profile] +} + +/** + * Reactive bento-card read for one profile (`PersonProfile.vue`'s Profile tab). Cards come + * straight off the shared per-profile call above instead of being copied into local refs, + * so revisiting a profile already viewed this session reuses its resource rather than + * re-racing a fresh request against whatever that earlier visit's request was doing - the + * out-of-order-response guard the old `loadProfileBentoCards` needed is gone because each + * profile now owns an isolated resource instead of sharing one mutable ref. + */ +export function useProfileBento(profile: MaybeRefOrGetter) { + const bentoCall = computed(() => { + let name = toValue(profile) + return name ? getProfileBentoCall(name) : null + }) + + const cards = computed(() => bentoCall.value?.data?.cards || []) + const isDefault = computed(() => bentoCall.value?.data?.is_default ?? true) + // Resolves on failure too (not just success) - the forever-skeleton bug this replaces + // came from the old plain `call()` throwing uncaught and never flipping its "loaded" ref. + // `data != null` lets a cache hit show immediately without waiting for the network leg + // that staleOnError may still be racing in the background. + const loaded = computed(() => { + let current = bentoCall.value + if (!current) return false + return current.data != null || Boolean(current.isFinished) + }) + const failed = computed(() => { + let current = bentoCall.value + return Boolean(current && current.error && current.data == null) + }) + const error = computed(() => bentoCall.value?.error ?? null) + + watch( + () => toValue(profile), + (name) => { + if (!name) return + let current = getProfileBentoCall(name) + if (!current.isFinished && !current.loading) current.reload() + }, + { immediate: true }, + ) + + function reload() { + return bentoCall.value?.reload() + } + + return { cards, isDefault, loaded, failed, error, reload } +} + +/** + * Warms a profile's bento-card cache ahead of a visit, for the background prefetcher. + * Same cache key path as `useProfileBento`, so a later visit to this profile reads + * whatever this fetched. + */ +export function prefetchProfileBento(personId: string) { + return getProfileBentoCall(personId).reload() +} diff --git a/frontend/src/data/people.ts b/frontend/src/data/people.ts new file mode 100644 index 000000000..d8cb1ba54 --- /dev/null +++ b/frontend/src/data/people.ts @@ -0,0 +1,48 @@ +import { ref } from 'vue' +import { useList } from 'frappe-ui' +import type { OrderBy } from 'frappe-ui' +import type { GPUserProfile } from '@/types/doctypes' +import { session } from './session' + +export interface Person extends Pick< + GPUserProfile, + 'name' | 'user' | 'bio' | 'modified' | 'cover_image' | 'cover_image_position' +> { + discussions_count: number + comments_count: number + reactions_given: number + reactions_received: number +} + +/** + * `full_name` and `modified` are real columns on `GP User Profile`, so the backend can + * sort by them directly. The other Select options in People.vue (posts/replies/reactions) + * have no backing column — they're derived counts computed per-row in `get_list` — so + * People.vue re-sorts for those client-side after fetching at `peopleOrderBy`'s value. + */ +export const peopleOrderBy = ref('modified desc') + +/** + * The People list: server-sorted, filtered to enabled accounts, cached for offline use. + * A module-level singleton (like `data/spaces.ts`'s `spaces`) rather than a per-call + * composable, so the People page and a future background prefetcher share one fetch and + * one cache entry instead of racing two independent requests. To warm this cache ahead of + * a visit, a prefetcher can `import { people } from '@/data/people'` and call + * `people.reload()`. + */ +export const people = useList({ + // GP User Profile's default list view; the aggregate post/reply/reaction counts are + // computed server-side per row, so this can't be the generic `/api/v2/document/...` + // REST list. + url: '/api/v2/method/gameplan.gameplan.doctype.gp_user_profile.gp_user_profile.get_list', + doctype: 'GP User Profile', + fields: ['name', 'user', 'bio', 'modified', 'cover_image', 'cover_image_position'], + filters: { enabled: 1 }, + orderBy: peopleOrderBy, + limit: 999, + // Scoped to the session user so a second account on the same browser can't read the + // first account's cached People list while offline (review finding from PR #516). + cacheKey: ['People', session.user], + staleOnError: true, + immediate: true, +}) diff --git a/frontend/src/pages/People.vue b/frontend/src/pages/People.vue index 411bed0e4..e04a04529 100644 --- a/frontend/src/pages/People.vue +++ b/frontend/src/pages/People.vue @@ -23,7 +23,7 @@
-

{{ people.length }} members

+

{{ peopleList.length }} members

- @@ -75,7 +65,7 @@
-
+
- {{ $user(user.user).full_name }} + {{ useUser(user.user).full_name }}
- Guest + Guest
- {{ $user(user.user).full_name }} + {{ useUser(user.user).full_name }}
- Guest + Guest
-
- + + + +
+
+ + +
- diff --git a/frontend/src/pages/PersonProfile.vue b/frontend/src/pages/PersonProfile.vue index 960894b08..addf61027 100644 --- a/frontend/src/pages/PersonProfile.vue +++ b/frontend/src/pages/PersonProfile.vue @@ -41,21 +41,31 @@
+ + diff --git a/frontend/src/pages/PersonProfileProfile.vue b/frontend/src/pages/PersonProfileProfile.vue index 6b12fb284..b712b60b7 100644 --- a/frontend/src/pages/PersonProfileProfile.vue +++ b/frontend/src/pages/PersonProfileProfile.vue @@ -7,7 +7,17 @@ - + + @@ -68,6 +78,7 @@ import { computed, ref } from 'vue' import { useRouter } from 'vue-router' import { Button, Skeleton } from 'frappe-ui' import EmptyStateBox from '@/components/EmptyStateBox.vue' +import OfflineContentFallback from '@/components/OfflineContentFallback.vue' import ProfileAboutDialog from '@/components/ProfileBento/ProfileAboutDialog.vue' import ProfileBentoGrid from '@/components/ProfileBento/ProfileBentoGrid.vue' import type { ProfileBentoCard, ProfileFieldEditor } from '@/components/ProfileBento/types' @@ -91,6 +102,8 @@ const props = withDefaults( bentoCardsLoaded?: boolean /** False once this profile has a saved layout rather than the computed default. */ bentoIsDefault?: boolean + /** Set when the bento fetch failed and nothing was cached to fall back to (US6). */ + bentoFailure?: { title: string; message: string } | null isOwnProfile?: boolean /** Set only when the viewer owns this profile; enables the card edit buttons. */ fieldEditor?: ProfileFieldEditor @@ -100,6 +113,7 @@ const props = withDefaults( defineEmits<{ restoreDefaultLayout: [] + retryBento: [] }>() const router = useRouter() diff --git a/frontend/src/pages/PersonProfileReplies.vue b/frontend/src/pages/PersonProfileReplies.vue index c83064e75..e6bb932c4 100644 --- a/frontend/src/pages/PersonProfileReplies.vue +++ b/frontend/src/pages/PersonProfileReplies.vue @@ -1,13 +1,58 @@ - From 57642e9a535050642d437f879520fad3fa055a6f Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sun, 9 Aug 2026 15:51:37 +0530 Subject: [PATCH 07/68] feat(frontend): background prefetch of members, profiles and avatars for offline Idle-delayed after login (and again on reconnect), warms the People list, every enabled member's GP User Profile doc, their bento cards, and their avatar bytes (loaded through an element so the service worker's image cache picks them up) through a small worker pool - so the People page and any member's profile render offline even for a member never directly visited this session. Posts/replies are intentionally left out; those pages fall back to the honest offline-fallback state instead. --- frontend/src/data/offlinePrefetch.ts | 206 +++++++++++++++++++++++++++ frontend/src/main.js | 3 + 2 files changed, 209 insertions(+) create mode 100644 frontend/src/data/offlinePrefetch.ts diff --git a/frontend/src/data/offlinePrefetch.ts b/frontend/src/data/offlinePrefetch.ts new file mode 100644 index 000000000..6356357e4 --- /dev/null +++ b/frontend/src/data/offlinePrefetch.ts @@ -0,0 +1,206 @@ +import { watch } from 'vue' +import { useDoc } from 'frappe-ui' +import type { GPUserProfile } from '@/types/doctypes' +import { people } from './people' +import { useUser, usersReady } from './users' +import { session } from './session' +import { isOnline, onReconnect } from './online' +import { prefetchProfileBento } from '@/components/ProfileBento/profileBentoSource' + +/** + * Warms every enabled member's offline data in the background so the People page and + * any member's profile render offline even when neither was ever visited this session: + * the People list itself, each member's `GP User Profile` doc (into the same `docStore` + * entry `useDoc` reads on `PersonProfile.vue`), each member's bento cards, and each + * member's avatar bytes (into the service worker's image cache). + * + * `useDoc` is the only supported way to warm `docStore` from outside frappe-ui - it isn't + * exported from the package, only reached through `useDoc`'s own `afterFetch` hook (see + * node_modules/frappe-ui/src/data-fetching/useDoc/useDoc.ts and ./docStore.ts). Calling it + * here with the exact `doctype`/`name` PersonProfile.vue uses means a later visit finds + * `docStore` already holding the doc (in memory, not just IDB) and skips straight to + * rendering it while its own network refresh races in the background - the same + * `staleOnError` fallback path an already-visited profile relies on offline. + */ + +// A shared pool size (not one member at a time, not unbounded) so a workspace with a few +// hundred members doesn't flood the network, while still finishing in reasonable time. +const POOL_SIZE = 3 +// Long enough that this never competes with the initial page load's own requests. +const INITIAL_DELAY_MS = 4000 +const IDLE_TIMEOUT_MS = 10000 + +let running = false + +/** + * Idle-delayed kickoff so this never competes with the initial page load. Triggered by + * the `session.isLoggedIn` watch below (covers both "already logged in at boot" and + * "logged in mid-session without a page reload") rather than being called explicitly from + * main.js, the same way data/discussions.ts and data/unreadCount.ts wire up their own + * `onReconnect` callback at module scope - importing this module for its side effects is + * enough (see main.js). + */ +export function scheduleOfflinePrefetch() { + runWhenIdle(() => { + if (session.isLoggedIn && isOnline.value) runOfflinePrefetch() + }) +} + +watch( + () => session.isLoggedIn, + (loggedIn) => loggedIn && scheduleOfflinePrefetch(), + { + immediate: true, + }, +) + +// A session that started offline never got its scheduled run in, and one that dropped +// mid-run may have only cached the first few members - both are exactly what a +// reconnect should retry. `onReconnect` is already debounced against flapping +// connectivity (data/online.ts), so this can't stampede on a flaky connection. The +// overlapping-run guard in `runOfflinePrefetch` covers this firing close to the initial +// idle-delayed run too. +onReconnect(() => { + if (session.isLoggedIn) runOfflinePrefetch() +}) + +function runWhenIdle(fn: () => void) { + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(() => fn(), { timeout: IDLE_TIMEOUT_MS }) + } else { + setTimeout(fn, INITIAL_DELAY_MS) + } +} + +/** + * Runs the full warm-up pass: reload the People list, then fan out per-member work + * (profile doc, bento cards, avatar) through a small worker pool. Exported mainly so + * tests/tools can trigger and await one pass directly instead of waiting on the idle + * timer. + */ +export async function runOfflinePrefetch() { + if (running || !isOnline.value) return + running = true + let counts = { members: 0, profiles: 0, bento: 0, avatars: 0 } + try { + console.debug('[offline-prefetch] start') + await people.reload() + + // Avatar URLs come from the users store, not the People list - wait for its first + // fetch to settle so an early run (or a slow `get_user_info`) doesn't skip every + // avatar because `useUser` only has placeholders to hand back yet. + await waitForUsersReady() + + let members = people.data || [] + counts.members = members.length + + let tasks = members.flatMap((member) => [ + () => + prefetchProfileDoc(member.name).then((ok) => { + if (ok) counts.profiles++ + }), + () => + prefetchProfileBento(member.name).then((ok) => { + if (ok) counts.bento++ + }), + () => + prefetchAvatar(useUser(member.user).user_image).then((ok) => { + if (ok) counts.avatars++ + }), + ]) + + await runPool(tasks, POOL_SIZE) + + console.debug( + `[offline-prefetch] done members=${counts.members} profiles=${counts.profiles} bento=${counts.bento} avatars=${counts.avatars}`, + ) + } finally { + running = false + } +} + +function waitForUsersReady(): Promise { + if (usersReady.value) return Promise.resolve() + return new Promise((resolve) => { + let stop = watch(usersReady, (ready) => { + if (!ready) return + stop() + resolve() + }) + }) +} + +/** + * Pulls tasks off a shared queue with `poolSize` workers running concurrently, instead of + * awaiting fixed-size batches - a worker that finishes early (a fast avatar load) picks up + * the next task immediately rather than waiting on the slowest task in its batch. Checked + * before every task, not just once: connectivity can drop midway through a run of a few + * hundred members, and there is no point queuing more requests that will only fail once it + * does. + */ +async function runPool(tasks: Array<() => Promise>, poolSize: number) { + let index = 0 + async function worker() { + while (index < tasks.length) { + if (!isOnline.value) return + let task = tasks[index++] + try { + await task() + } catch (error) { + console.error('[offline-prefetch] task failed', error) + } + } + } + await Promise.all(Array.from({ length: Math.min(poolSize, tasks.length) }, worker)) +} + +/** + * Fetches one member's profile doc and lets `useDoc`'s own `afterFetch` hook write it into + * `docStore` (both the in-memory ref and IDB) - see the module doc comment above. `execute` + * (aliased `fetch` on the returned object) never rejects; a network/HTTP failure resolves + * to `null` instead, so this only reports success/failure for the debug counters, nothing + * to catch. + */ +function prefetchProfileDoc(name: string): Promise { + let profileDoc = useDoc({ + doctype: 'GP User Profile', + name, + immediate: false, + staleOnError: true, + }) + return profileDoc.fetch().then(Boolean) +} + +/** + * Loads an avatar through an `` element rather than `fetch()` - the service worker + * (gameplan/www/gameplan-sw.js) only caches requests whose `destination` is `image` + * (`staleWhileRevalidate`), and a plain `fetch()` has `destination: ""`, which the worker + * ignores. This has to actually load through the browser's image-fetch machinery, not + * just hit the same URL. + */ +function prefetchAvatar(url: string | undefined): Promise { + if (!isSafeImageUrl(url)) return Promise.resolve(false) + return new Promise((resolve) => { + let img = new Image() + img.onload = () => resolve(true) + img.onerror = () => resolve(false) + img.src = url + }) +} + +// Same-origin only: the service worker ignores cross-origin requests outright +// (`url.origin !== self.location.origin` in gameplan-sw.js), and there is no reason to +// send a user's avatar URL to a third-party origin from a background prefetcher anyway. +function isSafeImageUrl(url: string | undefined): url is string { + if (!url) return false + if (url.startsWith('/') && !url.startsWith('//')) return true + try { + let parsed = new URL(url, window.location.origin) + return ( + (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + parsed.origin === window.location.origin + ) + } catch { + return false + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js index 0be900dce..47b7fd249 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -23,6 +23,9 @@ import { isSessionUser, session } from './data/session' import { initSocket } from './socket' import resetDataMixin from './utils/resetDataMixin' import { setupOfflineSupport } from './offline' +// Side-effect import: registers this module's own session/reconnect watchers (see +// data/offlinePrefetch.ts), same pattern as data/discussions.ts and data/unreadCount.ts. +import './data/offlinePrefetch' let globalComponents = { Button, From 51d400fc8e96005ab1d7bd8085c49c28d09a9aaa Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sun, 9 Aug 2026 18:51:07 +0530 Subject: [PATCH 08/68] feat(frontend): user-scoped offline cache clearing and service worker update flow Shared-computer safety (PR #516's review finding): wipe every offline cache (SW shell/runtime caches + idb-keyval) on logout, and on detecting a different user's session cookie at boot (guardAgainstUserSwitch). Plain logout deliberately leaves gameplan-drafts alone so the same person can recover an in-progress draft after logging back in; a detected switch to a different user clears drafts too. Also adds an update flow: the service worker no longer force-activates a new version under an open tab (no more unconditional skipWaiting on install); instead the app shows a "new version available" toast with a Refresh action once an update finishes installing, and reloads once the new worker takes control. --- frontend/src/data/draftStore.ts | 13 ++- frontend/src/data/session.ts | 19 +++- frontend/src/offline.ts | 161 +++++++++++++++++++++++++++++++- gameplan/www/gameplan-sw.js | 48 ++++++++-- 4 files changed, 231 insertions(+), 10 deletions(-) diff --git a/frontend/src/data/draftStore.ts b/frontend/src/data/draftStore.ts index 7f60b6215..1e06921bb 100644 --- a/frontend/src/data/draftStore.ts +++ b/frontend/src/data/draftStore.ts @@ -7,7 +7,7 @@ * can stay coherent. The reactive orchestration (debounced server sync, lazy row * creation, reconciliation) lives in `useDraftSync`. */ -import { get, set, del, entries, createStore } from 'idb-keyval' +import { get, set, del, entries, clear, createStore } from 'idb-keyval' export type DraftType = 'Discussion' | 'Comment' export type DraftMode = 'New' | 'Edit' @@ -65,6 +65,17 @@ export function listDraftRecords(): Promise { return entries(store).then((all) => all.map(([, record]) => record)) } +/** + * Wipe every locally stored draft, regardless of owner. `record.user` already keeps + * another account's drafts from being read back into an editor on a shared browser + * (see useDraftSync's `load()`), so this is only called when a *different* user is + * detected on this device (offline.ts's guardAgainstUserSwitch) - not on a plain + * logout, where the same person may log back in and expect their draft still there. + */ +export function clearDraftStore(): Promise { + return clear(store) +} + /** Deterministic key for singleton drafts — the same target always resolves to one * record, so two tabs editing the same post share it instead of forking. */ export function singletonKey(identity: DraftIdentity): string { diff --git a/frontend/src/data/session.ts b/frontend/src/data/session.ts index 62e3c234c..43a0745d1 100644 --- a/frontend/src/data/session.ts +++ b/frontend/src/data/session.ts @@ -2,6 +2,7 @@ import { computed, MaybeRef, reactive, ref } from 'vue' import { useCall } from 'frappe-ui' import { users } from './users' import router from '@/router' +import { clearOfflineCaches, guardAgainstUserSwitch } from '@/offline' interface LoginResponse { user: string @@ -25,15 +26,29 @@ export let session = reactive({ users.reload() sessionUser.value = getSessionUserFromCookie() session.login.reset() - router.replace(data.default_route || '/') + // Every user-scoped cacheKey in the data layer (communities, users, drafts) was + // computed once at module-eval time from whichever user (or Guest) was signed in + // when this tab first loaded - logging in as someone else here doesn't rebuild + // them. A plain router.replace would keep those singletons around, so force a full + // reload once a switch is detected and let the app rebuild everything fresh for + // the new user (same reasoning as DevUserSwitcher.vue's own hard reload). + if (guardAgainstUserSwitch(sessionUser.value)) { + window.location.href = data.default_route || '/' + } else { + router.replace(data.default_route || '/') + } }, }), logout: useCall({ url: '/api/v2/method/logout', method: 'POST', immediate: false, - onSuccess() { + async onSuccess() { sessionUser.value = getSessionUserFromCookie() + // Shared-computer safety: don't leave this session's cached content behind for + // whoever logs in next (PR #516). Awaited so the redirect (which tears down this + // page) doesn't cut the clear short. + await clearOfflineCaches() window.location.href = '/login' }, }), diff --git a/frontend/src/offline.ts b/frontend/src/offline.ts index 43cf77b49..d1160a154 100644 --- a/frontend/src/offline.ts +++ b/frontend/src/offline.ts @@ -1,8 +1,21 @@ +import { clear as clearIdbKeyval } from 'idb-keyval' +import { toast } from 'frappe-ui' +import { clearDraftStore } from '@/data/draftStore' +import { onReconnect } from '@/data/online' + const SERVICE_WORKER_URL = '/gameplan-sw.js' const SERVICE_WORKER_SCOPE = '/g' const CACHE_URLS_MESSAGE = 'CACHE_URLS' +const CLEAR_USER_CACHES_MESSAGE = 'CLEAR_USER_CACHES' +const SKIP_WAITING_MESSAGE = 'SKIP_WAITING' +const LAST_SEEN_USER_STORAGE_KEY = 'gameplan:last-seen-user' export function setupOfflineSupport() { + // Runs even in dev / non-secure contexts (unlike SW registration below): this is what + // makes the dev user switcher (DevUserSwitcher.vue) safe to test with, and it's cheap + // enough to always run at boot. + guardAgainstUserSwitch(getSessionUserFromCookie()) + if ( import.meta.env.DEV || typeof navigator === 'undefined' || @@ -18,6 +31,8 @@ export function setupOfflineSupport() { .then(async (registration) => { await unregisterLegacyServiceWorkers(registration) await warmLoadedAssets(registration) + watchForUpdates(registration) + onReconnect(() => registration.update().catch(() => {})) }) .catch((error) => { console.error('Failed to register Gameplan service worker', error) @@ -29,6 +44,8 @@ export function setupOfflineSupport() { } else { window.addEventListener('load', register, { once: true }) } + + watchForControllerChange() } export function isBrowserOffline() { @@ -39,6 +56,146 @@ export function isNetworkError(error: unknown) { return error instanceof TypeError && error.message === 'Failed to fetch' } +/** + * Shared-computer safety (PR #516's main review finding): wipe every trace of offline + * content this browser holds, so the next person logged in on this machine can't read + * a previous user's cached data. Call this on logout. + * + * Covers two physically separate stores: + * - The service worker's SHELL_CACHE and RUNTIME_CACHE (app shell HTML + cached + * avatars/files) - the hashed /assets build cache is content-addressed and the same + * for every user, so the worker deliberately leaves it alone. + * - The idb-keyval default store (IndexedDB db `keyval-store`, object store `keyval`) - + * frappe-ui's shared backing store for useList/useCall's persisted cache, useDoc's + * docStore, and the legacy Options-API listResource's saveLocal/getLocal. None of + * these call idb-keyval's `createStore` with a custom store, so clearing this one + * store wipes all of them at once (verified by reading frappe-ui beta.28's + * idbStore.ts, docStore.ts, resources/local.ts and resources/listResource.js). + * + * Deliberately does NOT touch `gameplan-drafts` (draftStore.ts's own custom idb-keyval + * store): a plain logout+re-login as the *same* user on the same device should still be + * able to recover an in-progress draft, and useDraftSync already guards reads by + * `record.user` so leaving another account's draft rows on disk isn't a leak. Drafts are + * only wiped when a genuine user switch is detected - see guardAgainstUserSwitch below, + * which calls clearDraftStore itself alongside this function. + */ +export async function clearOfflineCaches(): Promise { + await Promise.all([ + clearServiceWorkerCaches(), + clearIdbKeyval().catch((error) => console.error('Failed to clear IndexedDB cache', error)), + ]) +} + +async function clearServiceWorkerCaches(): Promise { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return + + const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE) + const activeWorker = registration?.active + if (!activeWorker) return + + await new Promise((resolve) => { + const channel = new MessageChannel() + // Don't let logout hang forever if a stuck/buggy worker never responds. + const timeoutId = window.setTimeout(resolve, 2000) + channel.port1.onmessage = () => { + window.clearTimeout(timeoutId) + resolve() + } + activeWorker.postMessage({ type: CLEAR_USER_CACHES_MESSAGE }, [channel.port2]) + }) +} + +/** + * Compares `user` against the last user this browser saw (persisted in localStorage so + * it survives full reloads) and clears every offline cache when they differ. Returns + * whether a switch was detected. + * + * Every cacheKey in the data layer (data/communities.ts, data/users.ts, data/drafts.ts) + * is computed once from the session cookie at module-eval time - correct for a fresh + * page load (the cookie is already the new user's by the time this module runs), but + * stale for a user switch that happens *without* a reload. Callers that change the + * session user in place (e.g. session.ts's login) must force a reload after a detected + * switch instead of relying on those singletons to pick up the new identity. + */ +export function guardAgainstUserSwitch(user: string | null): boolean { + if (typeof localStorage === 'undefined') return false + + const lastSeenUser = localStorage.getItem(LAST_SEEN_USER_STORAGE_KEY) + const switched = Boolean(lastSeenUser && user && lastSeenUser !== user) + if (switched) { + // Unlike a plain logout (clearOfflineCaches alone), a detected switch to a + // *different* user also wipes gameplan-drafts - the same-user recovery case that + // policy exists for doesn't apply here. + Promise.all([clearOfflineCaches(), clearDraftStore()]).catch((error) => + console.error('Failed to clear offline caches', error), + ) + } + + if (user) { + localStorage.setItem(LAST_SEEN_USER_STORAGE_KEY, user) + } else { + localStorage.removeItem(LAST_SEEN_USER_STORAGE_KEY) + } + + return switched +} + +// A local copy, not an import from data/session.ts: session.ts imports this module (to +// call clearOfflineCaches/guardAgainstUserSwitch on login/logout), so importing `session` +// back here would cycle. Same cookie-read pattern data/communities.ts, data/users.ts and +// data/session.ts itself use, each for their own version of this trap. +function getSessionUserFromCookie(): string | null { + const cookies = new URLSearchParams(document.cookie.split('; ').join('&')) + const user = cookies.get('user_id') + return user && user !== 'Guest' ? user : null +} + +/** + * Surfaces a "new version available" toast once an updated worker has finished + * installing behind the currently active one (it never auto-activates - see + * gameplan-sw.js's install handler). Also checks for a worker that was already sitting + * in `waiting` before this listener attached (e.g. this tab was open across the deploy). + */ +function watchForUpdates(registration: ServiceWorkerRegistration) { + if (registration.waiting && navigator.serviceWorker.controller) { + notifyUpdateAvailable(registration.waiting) + } + + registration.addEventListener('updatefound', () => { + const installingWorker = registration.installing + if (!installingWorker) return + + installingWorker.addEventListener('statechange', () => { + if (installingWorker.state === 'installed' && navigator.serviceWorker.controller) { + notifyUpdateAvailable(installingWorker) + } + }) + }) +} + +function notifyUpdateAvailable(worker: ServiceWorker) { + toast('A new version of Gameplan is available', { + duration: Infinity, + action: { + label: 'Refresh', + onClick: () => worker.postMessage({ type: SKIP_WAITING_MESSAGE }), + }, + }) +} + +/** Reloads once the newly-activated worker takes control, so the refresh action above + * actually picks up the new code. Guarded by a once-flag: `controllerchange` can also + * fire for reasons unrelated to our SKIP_WAITING message, and reloading more than once + * would loop. */ +function watchForControllerChange() { + let reloaded = false + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (reloaded) return + reloaded = true + window.location.reload() + }) +} + async function unregisterLegacyServiceWorkers(currentRegistration: ServiceWorkerRegistration) { const registrations = await navigator.serviceWorker.getRegistrations() const legacyScope = new URL('/g/', window.location.origin).href @@ -46,7 +203,9 @@ async function unregisterLegacyServiceWorkers(currentRegistration: ServiceWorker await Promise.all( registrations .filter((registration) => { - return registration.scope === legacyScope && registration.scope !== currentRegistration.scope + return ( + registration.scope === legacyScope && registration.scope !== currentRegistration.scope + ) }) .map((registration) => registration.unregister()), ) diff --git a/gameplan/www/gameplan-sw.js b/gameplan/www/gameplan-sw.js index 7c2de13ce..8ad237dcc 100644 --- a/gameplan/www/gameplan-sw.js +++ b/gameplan/www/gameplan-sw.js @@ -1,7 +1,13 @@ const CACHE_PREFIX = "gameplan-readonly-offline"; -const CACHE_VERSION = "v5"; +const CACHE_VERSION = "v6"; const SHELL_CACHE = `${CACHE_PREFIX}:${CACHE_VERSION}:shell`; const ASSET_CACHE = `${CACHE_PREFIX}:${CACHE_VERSION}:assets`; +// Avatars and other runtime images are user-visible content fetched by URL, with no +// user scoping (unlike the app's IndexedDB caches - see offline.ts's clearOfflineCaches). +// Keeping them in a separate bucket from ASSET_CACHE (hashed, content-addressed /assets +// build output, which is identical for every user) lets CLEAR_USER_CACHES below wipe the +// former on logout/user-switch without also evicting the latter. +const RUNTIME_CACHE = `${CACHE_PREFIX}:${CACHE_VERSION}:runtime`; const APP_SHELL_URL = "/g"; const OFFLINE_ASSET_MANIFEST_URL = "/assets/gameplan/frontend/gameplan-offline-assets.json"; @@ -16,7 +22,11 @@ const CACHEABLE_DESTINATIONS = new Set(["font", "image", "script", "style"]); self.addEventListener("install", (event) => { event.waitUntil(warmShellCache()); - self.skipWaiting(); + // No self.skipWaiting() here: when this install is replacing an already-active + // worker (a deploy landing under an open tab), the new worker should sit in + // `waiting` until the page confirms via SKIP_WAITING (offline.ts's update toast). + // Skipping unconditionally would swap the controller under a running tab with no + // warning. A first-ever install (no prior controller) activates regardless of this. }); self.addEventListener("activate", (event) => { @@ -49,11 +59,37 @@ self.addEventListener("fetch", (event) => { }); self.addEventListener("message", (event) => { - if (event.data?.type !== "CACHE_URLS" || !Array.isArray(event.data.urls)) + const type = event.data?.type; + + if (type === "CACHE_URLS" && Array.isArray(event.data.urls)) { + event.waitUntil(cacheUrls(event.data.urls)); + return; + } + + if (type === "SKIP_WAITING") { + self.skipWaiting(); return; - event.waitUntil(cacheUrls(event.data.urls)); + } + + if (type === "CLEAR_USER_CACHES") { + const port = event.ports[0]; + event.waitUntil( + clearUserCaches() + .then(() => port?.postMessage({ ok: true })) + .catch(() => port?.postMessage({ ok: false })), + ); + } }); +// Shared-computer safety (see clearOfflineCaches in offline.ts, called on logout and on +// detecting a different session user at boot): wipe everything that can hold the +// previous user's content. The app shell HTML and runtime images/files are the only +// user-visible things this worker caches - ASSET_CACHE (hashed /assets build output) is +// content-addressed and identical for every user, so it's left alone. +async function clearUserCaches() { + await Promise.all([caches.delete(SHELL_CACHE), caches.delete(RUNTIME_CACHE)]); +} + async function warmShellCache() { const cache = await caches.open(SHELL_CACHE); await Promise.all( @@ -79,7 +115,7 @@ async function warmShellCache() { } async function deleteOldCaches() { - const currentCaches = new Set([SHELL_CACHE, ASSET_CACHE]); + const currentCaches = new Set([SHELL_CACHE, ASSET_CACHE, RUNTIME_CACHE]); const names = await caches.keys(); await Promise.all( names @@ -209,7 +245,7 @@ async function cacheFirst(request) { } async function staleWhileRevalidate(request) { - const cache = await caches.open(ASSET_CACHE); + const cache = await caches.open(RUNTIME_CACHE); const cached = await cache.match(request); const fetched = fetch(request) .then((response) => { From bda9da4e8c8bc3bde137de8e622af392e52535be Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sun, 9 Aug 2026 19:20:10 +0530 Subject: [PATCH 09/68] fix(frontend): rewarm app shell cache after user-switch cache clear guardAgainstUserSwitch clears the service worker's SHELL_CACHE on a detected user switch, but nothing repopulated it until the next successful online navigation to /g. If the browser went offline before that happened, even a reload of the page already open failed with net::ERR_FAILED instead of falling back to the offline UI. Add a WARM_SHELL_CACHE message the page sends once the switch-triggered clear resolves (known to be online at that point, since a user just logged in), scoped separately from CLEAR_USER_CACHES so a plain logout still leaves the shell cache empty as intended. Bump CACHE_VERSION v6->v7 since the SW's message handling changed. --- frontend/src/offline.ts | 24 +++++++++++++++++++++--- gameplan/www/gameplan-sw.js | 17 ++++++++++++++++- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/frontend/src/offline.ts b/frontend/src/offline.ts index d1160a154..1e659ddc2 100644 --- a/frontend/src/offline.ts +++ b/frontend/src/offline.ts @@ -7,6 +7,7 @@ const SERVICE_WORKER_URL = '/gameplan-sw.js' const SERVICE_WORKER_SCOPE = '/g' const CACHE_URLS_MESSAGE = 'CACHE_URLS' const CLEAR_USER_CACHES_MESSAGE = 'CLEAR_USER_CACHES' +const WARM_SHELL_CACHE_MESSAGE = 'WARM_SHELL_CACHE' const SKIP_WAITING_MESSAGE = 'SKIP_WAITING' const LAST_SEEN_USER_STORAGE_KEY = 'gameplan:last-seen-user' @@ -105,6 +106,23 @@ async function clearServiceWorkerCaches(): Promise { }) } +/** + * Round-4 finding: without this, the SW's SHELL_CACHE stays empty from the moment a + * user-switch clear runs (clearOfflineCaches, above) until the *next* successful online + * navigation to /g - if the browser goes offline before that happens, even a reload of + * the page already open fails with net::ERR_FAILED instead of falling back to the + * offline UI. Fired only after guardAgainstUserSwitch's clear resolves, a moment known + * to be online (a user just logged in). Deliberately not run after a plain logout + * (clearOfflineCaches's other call site, data/session.ts) - an empty shell cache is the + * intended post-logout state there, same as every other offline cache. + */ +async function rewarmShellCache(): Promise { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return + + const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE) + registration?.active?.postMessage({ type: WARM_SHELL_CACHE_MESSAGE }) +} + /** * Compares `user` against the last user this browser saw (persisted in localStorage so * it survives full reloads) and clears every offline cache when they differ. Returns @@ -126,9 +144,9 @@ export function guardAgainstUserSwitch(user: string | null): boolean { // Unlike a plain logout (clearOfflineCaches alone), a detected switch to a // *different* user also wipes gameplan-drafts - the same-user recovery case that // policy exists for doesn't apply here. - Promise.all([clearOfflineCaches(), clearDraftStore()]).catch((error) => - console.error('Failed to clear offline caches', error), - ) + Promise.all([clearOfflineCaches(), clearDraftStore()]) + .then(() => rewarmShellCache()) + .catch((error) => console.error('Failed to clear offline caches', error)) } if (user) { diff --git a/gameplan/www/gameplan-sw.js b/gameplan/www/gameplan-sw.js index 8ad237dcc..16b9e42fb 100644 --- a/gameplan/www/gameplan-sw.js +++ b/gameplan/www/gameplan-sw.js @@ -1,5 +1,5 @@ const CACHE_PREFIX = "gameplan-readonly-offline"; -const CACHE_VERSION = "v6"; +const CACHE_VERSION = "v7"; const SHELL_CACHE = `${CACHE_PREFIX}:${CACHE_VERSION}:shell`; const ASSET_CACHE = `${CACHE_PREFIX}:${CACHE_VERSION}:assets`; // Avatars and other runtime images are user-visible content fetched by URL, with no @@ -78,6 +78,21 @@ self.addEventListener("message", (event) => { .then(() => port?.postMessage({ ok: true })) .catch(() => port?.postMessage({ ok: false })), ); + return; + } + + if (type === "WARM_SHELL_CACHE") { + // Round-4 finding: guardAgainstUserSwitch (offline.ts) clears SHELL_CACHE after a + // detected user switch, and nothing repopulates it until the *next* successful + // online navigation to /g. If the browser goes offline before that happens, even a + // reload of the page already open fails with net::ERR_FAILED instead of the offline + // UI. offline.ts posts this message right after that clear resolves, at a moment + // it's known to be online (a user just logged in) - warmShellCache() is already + // best-effort per-URL, so this is harmless if connectivity drops mid-fetch. + // Deliberately a separate message from CLEAR_USER_CACHES (not folded into + // clearUserCaches itself): a plain logout also clears via CLEAR_USER_CACHES, and an + // empty shell cache is the intended post-logout state there. + event.waitUntil(warmShellCache()); } }); From 6b5c91c79d56b4c965be3b58fda30ca3d13d3874 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sun, 9 Aug 2026 19:20:17 +0530 Subject: [PATCH 10/68] test(frontend): add offline Playwright suite Migrates the offline-mode Playwright suite (12 stories: US1-US8, P1-P3) from a throwaway /tmp harness into frontend/tests/offline so it survives reboots and can gate regressions. Seeded-content coupling and creds are now env-overridable via config.js instead of hardcoded. Adds playwright as a devDependency and a yarn test:offline script. --- frontend/.gitignore | 1 + frontend/package.json | 3 + frontend/tests/offline/README.md | 88 +++++ frontend/tests/offline/config.js | 72 ++++ frontend/tests/offline/helpers.js | 351 ++++++++++++++++++ frontend/tests/offline/p1.js | 222 +++++++++++ frontend/tests/offline/p2.js | 154 ++++++++ frontend/tests/offline/p3.js | 224 +++++++++++ frontend/tests/offline/package.json | 4 + frontend/tests/offline/runner.js | 47 +++ frontend/tests/offline/smoke-online-people.js | 137 +++++++ .../offline/smoke-online-shared-computer.js | 66 ++++ frontend/tests/offline/smoke-online.js | 173 +++++++++ frontend/tests/offline/us1.js | 81 ++++ frontend/tests/offline/us2.js | 196 ++++++++++ frontend/tests/offline/us3.js | 132 +++++++ frontend/tests/offline/us4.js | 214 +++++++++++ frontend/tests/offline/us5.js | 143 +++++++ frontend/tests/offline/us6.js | 111 ++++++ frontend/tests/offline/us7a.js | 203 ++++++++++ frontend/tests/offline/us7b.js | 214 +++++++++++ frontend/tests/offline/us8.js | 206 ++++++++++ frontend/yarn.lock | 19 + 23 files changed, 3061 insertions(+) create mode 100644 frontend/tests/offline/README.md create mode 100644 frontend/tests/offline/config.js create mode 100644 frontend/tests/offline/helpers.js create mode 100644 frontend/tests/offline/p1.js create mode 100644 frontend/tests/offline/p2.js create mode 100644 frontend/tests/offline/p3.js create mode 100644 frontend/tests/offline/package.json create mode 100644 frontend/tests/offline/runner.js create mode 100644 frontend/tests/offline/smoke-online-people.js create mode 100644 frontend/tests/offline/smoke-online-shared-computer.js create mode 100644 frontend/tests/offline/smoke-online.js create mode 100644 frontend/tests/offline/us1.js create mode 100644 frontend/tests/offline/us2.js create mode 100644 frontend/tests/offline/us3.js create mode 100644 frontend/tests/offline/us4.js create mode 100644 frontend/tests/offline/us5.js create mode 100644 frontend/tests/offline/us6.js create mode 100644 frontend/tests/offline/us7a.js create mode 100644 frontend/tests/offline/us7b.js create mode 100644 frontend/tests/offline/us8.js diff --git a/frontend/.gitignore b/frontend/.gitignore index f9b92b8c7..9855df6f9 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -7,6 +7,7 @@ cypress/results cypress/screenshots cypress/videos cypress/downloads +tests/offline/results coverage .nyc_output .nyc_merged diff --git a/frontend/package.json b/frontend/package.json index c054f2197..23004e81e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,8 @@ "serve": "vite preview", "test-local": "cypress open --e2e --browser chrome", "test": "cypress run", + "test:offline": "node tests/offline/runner.js", + "test:offline:smoke": "node tests/offline/smoke-online.js && node tests/offline/smoke-online-people.js && node tests/offline/smoke-online-shared-computer.js", "coverage:merge": "rm -rf .nyc_merged && mkdir -p .nyc_merged && cp coverage/baseline.json .nyc_merged/baseline.json && cp .nyc_output/out.json .nyc_merged/out.json && nyc report --nycrc-path \"$PWD/.nycrc.json\" --temp-dir \"$PWD/.nyc_merged\" --cwd \"${COVERAGE_ROOT:-$PWD}\" --report-dir \"$PWD/coverage\"" }, "dependencies": { @@ -42,6 +44,7 @@ "cypress": "14.5.1", "mocha-junit-reporter": "^2.2.1", "nyc": "^18.0.0", + "playwright": "^1.62.1", "postcss": "^8.4.5", "prettier": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.8", diff --git a/frontend/tests/offline/README.md b/frontend/tests/offline/README.md new file mode 100644 index 000000000..bdc2d1ef0 --- /dev/null +++ b/frontend/tests/offline/README.md @@ -0,0 +1,88 @@ +# Offline mode Playwright suite + +End-to-end coverage for Gameplan's offline support (service worker + shell/data caching, +`gameplan/www/gameplan-sw.js` and `frontend/src/offline.ts`). Plain CommonJS Playwright +scripts, not `@playwright/test` — each story is a `run()` function that returns a +`{ pass, checks: [...] }` result and can also be executed directly with `node`. + +Originally built as a throwaway harness at `/tmp/offline-mvp/pw`; migrated here so it +survives reboots and can gate regressions in CI/local dev. + +## What's covered (12 stories) + +| Story | Covers | +| ----- | ------------------------------------------------------------------------------------------------------------ | +| US1 | App shell loads offline (reload + deep link) instead of a browser error page | +| US2 | Previously-viewed feed / space / discussion render from cache while offline | +| US3 | Offline indicator appears when connectivity drops, clears on reconnect | +| US4 | A comment typed while offline fails gracefully and isn't lost | +| US5 | Fresh data appears automatically on reconnect, no manual reload | +| US6 | Never-cached content shows an honest "can't load this offline" fallback | +| P1 | Background prefetch (`data/offlinePrefetch.ts`) makes an unvisited member's profile offline-ready | +| P2 | A profile visited fully online (incl. Posts tab) is available offline | +| P3 | A profile opened right as the browser goes offline (before prefetch runs) degrades honestly | +| US7a | Plain logout clears shell/runtime caches and IndexedDB, but preserves the current user's draft | +| US7b | A second user logging in on the same browser (no explicit logout) never sees the first user's cached data | +| US8 | A new service worker build shows an update toast; clicking Refresh reloads exactly once onto the new version | + +## Prerequisites + +1. **A production build.** From the repo root: `yarn build`. The suite talks to a real + service worker, which Vite's dev server doesn't register the same way — always test + against the built bundle. +2. **A Frappe server serving that build**, e.g.: + ``` + bench --site serve --port 8003 + ``` + (any port works; point `GAMEPLAN_OFFLINE_BASE_URL` at it — see Configuration below). +3. **A seeded test user and content**, on that site: + - A user with a password (`GAMEPLAN_OFFLINE_USER` / `GAMEPLAN_OFFLINE_PASSWORD`), + member of at least one `GP Team` (Community) with a non-private `GP Project` + (Space) that has a `GP Discussion` with a few comments. + - A **second** user (`GAMEPLAN_OFFLINE_USER2` / `GAMEPLAN_OFFLINE_PASSWORD2`), + member of the same team — used by US7b to simulate a second person on a shared + computer. Follow the pattern in `gameplan/debug.py` (per `AGENTS.md`'s debugging + convention: add an `execute()` function there and run it with + `bench --site execute gameplan.debug.execute`) to create the user, + set its password, and add it to the team. + - A few enabled `GP User Profile` members for the People/profile stories (P1-P3) — + any real members on the site work; override their IDs via env vars if needed (see + below). + +## Running + +``` +cd frontend +yarn install +yarn test:offline +``` + +Runs all 12 stories against `GAMEPLAN_OFFLINE_BASE_URL` (default +`http://gameplan.localhost:8003`), writes a summary to `tests/offline/results/summary.json` +and per-story JSON/screenshots under `tests/offline/results/` (gitignored). Exits non-zero +if any story fails. + +Run a single story directly: `node tests/offline/us3.js`. + +Online regression smokes (confirm normal online usage isn't broken — not part of the 12 +offline stories): `yarn test:offline:smoke`. + +## Configuration + +All seeded-content coupling lives in `config.js`, overridable via env vars: + +| Env var | Default | Meaning | +| ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------- | +| `GAMEPLAN_OFFLINE_BASE_URL` | `http://gameplan.localhost:8003` | Origin serving the production build | +| `GAMEPLAN_OFFLINE_USER` / `GAMEPLAN_OFFLINE_PASSWORD` | `offline-tester@example.com` / `offline-test-1234` | Primary test account | +| `GAMEPLAN_OFFLINE_USER2` / `GAMEPLAN_OFFLINE_PASSWORD2` | `offline-tester-2@example.com` / `offline-test-1234` | Second account (US7b) | +| `GAMEPLAN_OFFLINE_COMMUNITY` | `common-room` | `GP Team` name both accounts belong to | +| `GAMEPLAN_OFFLINE_SPACE_ID` | `3` | `GP Project` name for the visited/cached space | +| `GAMEPLAN_OFFLINE_DISCUSSION_ID` | `55` | `GP Discussion` name for the visited/cached discussion | +| `GAMEPLAN_OFFLINE_UNCACHED_SPACE_ID` / `GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_SPACE_ID` / `GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_ID` | `4` / `5` / `54` | Content never visited by any story before going offline (US6) | +| `GAMEPLAN_OFFLINE_PERSON_PREFETCH` / `GAMEPLAN_OFFLINE_PERSON_VISITED` / `GAMEPLAN_OFFLINE_PERSON_NO_PREFETCH` | `priya-sharma` / `maya-iyer` / `hana-suzuki` | `GP User Profile` IDs for P1/P2/P3 | +| `GAMEPLAN_OFFLINE_RESULTS_DIR` | `tests/offline/results` | Where JSON results + screenshots are written | + +`us8.js` additionally rebuilds the app in place (bumps `gameplan-sw.js`'s +`CACHE_VERSION`, runs `yarn build`, then reverts and rebuilds again in a `finally`) — it +resolves the gameplan app root from its own file location, not an env var. diff --git a/frontend/tests/offline/config.js b/frontend/tests/offline/config.js new file mode 100644 index 000000000..088165c53 --- /dev/null +++ b/frontend/tests/offline/config.js @@ -0,0 +1,72 @@ +// Central, env-overridable configuration for the offline Playwright suite. Defaults match +// the seeded `gameplan.localhost` dev site this suite was developed against (see +// README.md for how to reproduce that seed). Every story/helper reads its URLs, creds, +// and seeded-content IDs from here instead of hardcoding them, so the suite can run +// against a differently-seeded site by only setting env vars. +const path = require('path') + +const BASE = process.env.GAMEPLAN_OFFLINE_BASE_URL || 'http://gameplan.localhost:8003' +const EMAIL = process.env.GAMEPLAN_OFFLINE_USER || 'offline-tester@example.com' +const PWD = process.env.GAMEPLAN_OFFLINE_PASSWORD || 'offline-test-1234' +// Second account, used by US7b to simulate a second person logging in on the same shared +// computer right after the first — needs to be a distinct identity, member of the same +// community so it can see the same seeded Space. +const EMAIL2 = process.env.GAMEPLAN_OFFLINE_USER2 || 'offline-tester-2@example.com' +const PWD2 = process.env.GAMEPLAN_OFFLINE_PASSWORD2 || 'offline-test-1234' +const FULL_NAME = process.env.GAMEPLAN_OFFLINE_FULL_NAME || 'Offline Tester' +const FULL_NAME2 = process.env.GAMEPLAN_OFFLINE_FULL_NAME2 || 'Offline Tester Two' + +// Seeded content coordinates (GP Team/GP Project/GP Discussion names) — see README.md. +const COMMUNITY = process.env.GAMEPLAN_OFFLINE_COMMUNITY || 'common-room' +const SPACE_ID = process.env.GAMEPLAN_OFFLINE_SPACE_ID || '3' +const DISCUSSION_ID = process.env.GAMEPLAN_OFFLINE_DISCUSSION_ID || '55' +// Never visited by any story before going offline -> used for US6 "uncached content". +const UNCACHED_SPACE_ID = process.env.GAMEPLAN_OFFLINE_UNCACHED_SPACE_ID || '4' +const UNCACHED_DISCUSSION_SPACE_ID = + process.env.GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_SPACE_ID || '5' +const UNCACHED_DISCUSSION_ID = process.env.GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_ID || '54' + +const URLS = { + feed: `${BASE}/g`, + spaceDiscussions: `${BASE}/g/community/${COMMUNITY}/space/${SPACE_ID}/discussions`, + discussion: `${BASE}/g/community/${COMMUNITY}/space/${SPACE_ID}/discussion/${DISCUSSION_ID}`, + uncachedDiscussion: `${BASE}/g/community/${COMMUNITY}/space/${UNCACHED_DISCUSSION_SPACE_ID}/discussion/${UNCACHED_DISCUSSION_ID}`, + uncachedSpace: `${BASE}/g/community/${COMMUNITY}/space/${UNCACHED_SPACE_ID}/discussions`, + people: `${BASE}/g/people`, + person: (id) => `${BASE}/g/people/${id}`, + personPosts: (id) => `${BASE}/g/people/${id}/posts`, +} + +// Real, enabled `GP User Profile` members on the seeded site, used across the +// People/profile (P1-P3) stories. Kept distinct per role so no story's "never visited" +// member is accidentally warmed by another story's "visited" step within the same run. +const PEOPLE = { + // P1: never opened in that story's context; relies entirely on the background + // prefetcher (data/offlinePrefetch.ts) to be offline-ready. + neverVisitedForPrefetch: process.env.GAMEPLAN_OFFLINE_PERSON_PREFETCH || 'priya-sharma', + // P2: visited fully online (profile + Posts tab) before going offline. + visitedFully: process.env.GAMEPLAN_OFFLINE_PERSON_VISITED || 'maya-iyer', + // P3: opened only after immediately forcing offline, before prefetch can run. + neverVisitedNoPrefetch: process.env.GAMEPLAN_OFFLINE_PERSON_NO_PREFETCH || 'hana-suzuki', +} + +// frontend/tests/offline -> the gameplan app root (used by us8.js to rebuild after +// bumping gameplan-sw.js's CACHE_VERSION). +const APP_DIR = path.join(__dirname, '..', '..', '..') +const RESULTS_DIR = process.env.GAMEPLAN_OFFLINE_RESULTS_DIR || path.join(__dirname, 'results') +const SHOTS_DIR = path.join(RESULTS_DIR, 'screenshots') + +module.exports = { + BASE, + EMAIL, + PWD, + EMAIL2, + PWD2, + FULL_NAME, + FULL_NAME2, + URLS, + PEOPLE, + APP_DIR, + RESULTS_DIR, + SHOTS_DIR, +} diff --git a/frontend/tests/offline/helpers.js b/frontend/tests/offline/helpers.js new file mode 100644 index 000000000..11def707f --- /dev/null +++ b/frontend/tests/offline/helpers.js @@ -0,0 +1,351 @@ +// Shared helpers for the offline MVP Playwright suite (plain scripts, no @playwright/test). +// Originally a throwaway harness at /tmp/offline-mvp/pw; migrated here so it survives +// reboots and can gate regressions (see README.md). +const path = require('path') +const fs = require('fs') +const { chromium, request: pwRequest } = require('playwright') +const { + BASE, + EMAIL, + PWD, + EMAIL2, + PWD2, + FULL_NAME, + FULL_NAME2, + URLS, + PEOPLE, + SHOTS_DIR, + RESULTS_DIR, +} = require('./config') + +fs.mkdirSync(SHOTS_DIR, { recursive: true }) +fs.mkdirSync(RESULTS_DIR, { recursive: true }) + +async function newLoggedInContextAs(browser, usr, pwd, contextOptions = {}) { + const context = await browser.newContext(contextOptions) + const loginResp = await context.request.post(`${BASE}/api/method/login`, { + form: { usr, pwd }, + }) + if (!loginResp.ok()) { + throw new Error(`login failed: ${loginResp.status()} ${await loginResp.text()}`) + } + const page = await context.newPage() + const consoleErrors = [] + const pageErrors = [] + const prefetchLog = [] + page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()) + if (msg.text().includes('[offline-prefetch]')) prefetchLog.push(msg.text()) + }) + page.on('pageerror', (err) => { + pageErrors.push(String(err)) + }) + return { context, page, consoleErrors, pageErrors, prefetchLog } +} + +async function newLoggedInContext(browser) { + return newLoggedInContextAs(browser, EMAIL, PWD) +} + +/** + * Logs in as a second, already-authenticated identity within the SAME browser context + * (same IndexedDB/localStorage/Cache Storage origin) — used by US7b to simulate a second + * person using the same shared computer right after the first. Reuses the context's + * cookie jar (context.request.post sets the cookie on the context), so the existing + * `page` picks up the new session on its next navigation. + */ +async function loginAsInSameContext(context, page, usr, pwd) { + const loginResp = await context.request.post(`${BASE}/api/method/login`, { + form: { usr, pwd }, + }) + if (!loginResp.ok()) { + throw new Error(`login failed: ${loginResp.status()} ${await loginResp.text()}`) + } + // The app's own guardAgainstUserSwitch (frontend/src/offline.ts) only runs at module + // boot, so a fresh navigation is required for it to see the new user_id cookie. + await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 }) + // `/g` client-side redirects to the community's discussions route once the app has + // hydrated enough to know where to send you (post-327d7ae3, that redirect itself + // waits on cache hydration under unreliable network). Wait for it to actually land + // before the caller issues its own page.goto() — otherwise the in-flight SPA redirect + // can still be settling when the next real navigation starts, and Playwright reports + // that next goto() as "interrupted by another navigation" to the redirect's target. + await page.waitForURL(/\/g\/community\//, { timeout: 10000 }).catch(() => {}) +} + +/** Opens the AppRail user-avatar dropdown (bottom-left) and clicks "Log out" — the same + * UI path a real user takes (UserDropdown.vue -> session.logout.submit(), see + * data/session.ts), so this also exercises the offline-cache-clear side effect under + * test rather than bypassing it with a raw API call. Selector is the avatar trigger + * button's own template classes (AppRail.vue) since the avatar itself may render either + * an or an initials div depending on whether the user has a profile photo. */ +async function logoutViaUI(page) { + const trigger = page.locator('button.rounded-full.size-7').last() + await trigger.click({ timeout: 8000 }) + await page.getByRole('menuitem', { name: 'Log out' }).click({ timeout: 8000 }) +} + +/** Reads idb-keyval's default store (IndexedDB db `keyval-store`, object store `keyval`) + * — frappe-ui's shared backing store for useList/useCall/useDoc caches (see offline.ts's + * clearOfflineCaches doc comment). Opening a DB that doesn't exist yet is harmless (it + * just creates an empty one with no object stores, same as p3.js already relies on). */ +async function idbKeyvalKeys(page) { + return page.evaluate( + () => + new Promise((resolve) => { + const req = indexedDB.open('keyval-store') + req.onsuccess = () => { + const db = req.result + if (!db.objectStoreNames.contains('keyval')) { + db.close() + resolve([]) + return + } + const tx = db.transaction('keyval', 'readonly') + const keysReq = tx.objectStore('keyval').getAllKeys() + keysReq.onsuccess = () => { + db.close() + resolve(keysReq.result) + } + keysReq.onerror = () => { + db.close() + resolve([]) + } + } + req.onerror = () => resolve([]) + }), + ) +} + +/** Reads draftStore.ts's custom idb-keyval store (`gameplan-drafts` db, `records` store). */ +async function draftStoreKeys(page) { + return page.evaluate( + () => + new Promise((resolve) => { + const req = indexedDB.open('gameplan-drafts') + req.onsuccess = () => { + const db = req.result + if (!db.objectStoreNames.contains('records')) { + db.close() + resolve([]) + return + } + const tx = db.transaction('records', 'readonly') + const keysReq = tx.objectStore('records').getAllKeys() + keysReq.onsuccess = () => { + db.close() + resolve(keysReq.result) + } + keysReq.onerror = () => { + db.close() + resolve([]) + } + } + req.onerror = () => resolve([]) + }), + ) +} + +/** Cache Storage bucket names the SW created — see gameplan-sw.js's SHELL_CACHE/ + * ASSET_CACHE/RUNTIME_CACHE naming (`gameplan-readonly-offline::`). */ +async function cacheStorageNames(page) { + return page.evaluate(() => (typeof caches !== 'undefined' ? caches.keys() : [])) +} + +async function lastSeenUserFromStorage(page) { + return page.evaluate(() => { + try { + return localStorage.getItem('gameplan:last-seen-user') + } catch { + return null + } + }) +} + +/** + * Waits for data/offlinePrefetch.ts's `[offline-prefetch] done members=... profiles=... + * bento=... avatars=...` console.debug line, by polling the `prefetchLog` array + * `newLoggedInContext` populates. The prefetcher is idle-delayed (up to + * IDLE_TIMEOUT_MS=10s) then fans out over every member with a small worker pool, so this + * needs a generous timeout — callers should navigate to a lightweight page (the feed) and + * just let it sit rather than doing other work while waiting. + */ +async function waitForPrefetchDone(prefetchLog, { timeoutMs = 45000, pollMs = 500 } = {}) { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const doneLine = prefetchLog.find((l) => l.includes('[offline-prefetch] done')) + if (doneLine) { + const m = doneLine.match(/members=(\d+)\s+profiles=(\d+)\s+bento=(\d+)\s+avatars=(\d+)/) + return { + sawDone: true, + sawStartFirst: prefetchLog[0]?.includes('[offline-prefetch] start') ?? false, + doneLine, + counts: m + ? { + members: Number(m[1]), + profiles: Number(m[2]), + bento: Number(m[3]), + avatars: Number(m[4]), + } + : null, + } + } + await new Promise((r) => setTimeout(r, pollMs)) + } + return { + sawDone: false, + sawStartFirst: prefetchLog[0]?.includes('[offline-prefetch] start') ?? false, + doneLine: null, + counts: null, + log: [...prefetchLog], + } +} + +async function avatarInfo(page, scope = 'img') { + return page.evaluate((sel) => { + const imgs = Array.from(document.querySelectorAll(sel)) + return imgs.slice(0, 12).map((img) => ({ + src: img.src, + complete: img.complete, + naturalWidth: img.naturalWidth, + broken: img.complete && img.naturalWidth === 0, + })) + }, scope) +} + +/** Separate, independent auth context for API calls that must succeed while the + * browser `page`/`context` is simulating offline (context.setOffline only affects + * the page's network stack, not a wholly separate APIRequestContext, but per the + * task brief we keep this fully separate for clarity and to avoid any doubt). */ +async function newApiRequestContext() { + const api = await pwRequest.newContext({ baseURL: BASE }) + const loginResp = await api.post('/api/method/login', { + form: { usr: EMAIL, pwd: PWD }, + }) + if (!loginResp.ok()) { + throw new Error(`API login failed: ${loginResp.status()} ${await loginResp.text()}`) + } + return api +} + +async function warmup(page) { + // Visit the three pages online, waiting for real content, then wait for the + // service worker to finish installing so offline tests have a warm cache. + const swBefore = await page.evaluate(() => ({ + hasSW: 'serviceWorker' in navigator, + isSecureContext: window.isSecureContext, + })) + + for (const url of [URLS.feed, URLS.spaceDiscussions, URLS.discussion]) { + await page.goto(url, { waitUntil: 'load', timeout: 15000 }) + // `/g` itself client-side redirects to the community's discussions route once the + // app has hydrated enough to know where to send you (post-327d7ae3, that redirect + // waits on cache hydration under unreliable network, so it isn't always instant). + // Let it land before starting the next goto() in this loop — otherwise, under load, + // the in-flight SPA redirect can still be settling when the next real navigation + // starts, and Playwright reports that next goto() as "interrupted by another + // navigation" to the redirect's target (same race loginAsInSameContext guards + // against for the US7b user-switch flow). + if (url === URLS.feed) { + await page.waitForURL(/\/g\/community\//, { timeout: 10000 }).catch(() => {}) + } + await page.waitForTimeout(1000) + } + + let swController = null + let swReady = false + let swReadyError = null + try { + swController = await page.evaluate(() => Boolean(navigator.serviceWorker.controller)) + await page.evaluate(() => navigator.serviceWorker.ready.then(() => true), { timeout: 10000 }) + swReady = true + } catch (e) { + swReadyError = String(e) + } + + // Give the SW's warmLoadedAssets() postMessage (fired on load + a 3s follow-up + // timer, see frontend/src/offline.ts) time to finish caching assets/shell. + await page.waitForTimeout(4000) + + const swRegistrations = await page.evaluate(async () => { + const regs = await navigator.serviceWorker.getRegistrations() + return regs.map((r) => ({ + scope: r.scope, + active: Boolean(r.active), + activeState: r.active?.state, + })) + }) + + return { ...swBefore, swController, swReady, swReadyError, swRegistrations } +} + +async function shot(page, name) { + const p = path.join(SHOTS_DIR, `${name}.png`) + try { + await page.screenshot({ path: p, fullPage: false, timeout: 8000 }) + } catch (e) { + return null + } + return p +} + +async function innerTextSafe(page, selector = '#app, body') { + try { + return await page.locator(selector).first().innerText({ timeout: 5000 }) + } catch (e) { + try { + return await page.evaluate(() => document.body?.innerText?.slice(0, 2000) || '') + } catch { + return '' + } + } +} + +async function appRootInfo(page) { + return page.evaluate(() => { + const app = document.querySelector('#app') + return { + appExists: Boolean(app), + appChildCount: app ? app.childElementCount : 0, + bodyText: document.body.innerText.slice(0, 500), + title: document.title, + } + }) +} + +function writeResult(story, obj) { + const p = path.join(RESULTS_DIR, `${story}.json`) + fs.writeFileSync(p, JSON.stringify(obj, null, 2)) + return p +} + +module.exports = { + BASE, + EMAIL, + PWD, + EMAIL2, + PWD2, + FULL_NAME, + FULL_NAME2, + URLS, + PEOPLE, + SHOTS_DIR, + RESULTS_DIR, + newLoggedInContext, + newLoggedInContextAs, + loginAsInSameContext, + logoutViaUI, + idbKeyvalKeys, + draftStoreKeys, + cacheStorageNames, + lastSeenUserFromStorage, + newApiRequestContext, + warmup, + waitForPrefetchDone, + avatarInfo, + shot, + innerTextSafe, + appRootInfo, + writeResult, + chromium, +} diff --git a/frontend/tests/offline/p1.js b/frontend/tests/offline/p1.js new file mode 100644 index 000000000..5b4c4214d --- /dev/null +++ b/frontend/tests/offline/p1.js @@ -0,0 +1,222 @@ +// P1 — Prefetch makes members offline-ready: the background prefetcher +// (frontend/src/data/offlinePrefetch.ts) should warm the People list, every member's +// GP User Profile doc, their bento cards, and their avatar bytes, all without the user +// ever opening the People page or a profile themselves. This story visits ONLY the feed +// online, waits for the prefetcher's '[offline-prefetch] done' log, then goes offline and +// checks: (a) the People page (client-nav + reload) renders member names AND avatars, +// (b) a member's profile NEVER visited in this context renders real content, and +// (c) that member's Posts tab is an honest offline fallback, not a silently-empty list +// (posts are intentionally not prefetched). +const { + chromium, + URLS, + PEOPLE, + newLoggedInContext, + waitForPrefetchDone, + avatarInfo, + shot, + innerTextSafe, + appRootInfo, + writeResult, +} = require('./helpers') + +const MEMBER = PEOPLE.neverVisitedForPrefetch // 'priya-sharma' — never opened in this context + +async function warmupFeedOnly(page) { + await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(1000) + try { + await page.evaluate(() => navigator.serviceWorker.ready.then(() => true)) + } catch (e) { + // best-effort + } +} + +async function fuiSkeletonCount(page) { + return page.evaluate(() => document.querySelectorAll('.fui-skeleton').length) +} + +async function run() { + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors, prefetchLog } = + await newLoggedInContext(browser) + const result = { story: 'P1', checks: [] } + + try { + await warmupFeedOnly(page) + + // Wait for the idle-delayed background prefetcher to finish its pass. Only the feed + // was ever visited — People/profiles/bento/avatars must come purely from the + // prefetcher, not from the user's own browsing. + const prefetch = await waitForPrefetchDone(prefetchLog, { timeoutMs: 45000 }) + result.prefetch = prefetch + result.checks.push({ + name: 'prefetch done log observed before going offline', + pass: prefetch.sawDone && Boolean(prefetch.counts) && prefetch.counts.members > 0, + symptom: prefetch.sawDone + ? `saw done line: ${prefetch.doneLine}` + : `never saw '[offline-prefetch] done' within timeout; log so far: ${JSON.stringify(prefetchLog)}`, + }) + + await context.setOffline(true) + + // (a) People page: client-nav from the feed, then a hard reload, both offline. + let checkA = { name: 'People page offline: client-nav shows members + avatars' } + try { + const peopleRailBtn = page.getByRole('button', { name: 'People', exact: true }) + if (await peopleRailBtn.count()) { + await peopleRailBtn.click() + } else { + await page.goto(URLS.people, { waitUntil: 'load', timeout: 10000 }) + } + await page.waitForTimeout(2000) + + const text = await innerTextSafe(page) + const avatars = await avatarInfo(page, 'img') + const loadedAvatars = avatars.filter((a) => a.naturalWidth > 0) + const memberishText = /\d+\s+members?/i.test(text) && !/^0\s+members/i.test(text.trim()) + + checkA.textSnippet = text.slice(0, 300) + checkA.avatarCount = avatars.length + checkA.loadedAvatarCount = loadedAvatars.length + checkA.screenshot = await shot(page, 'p1-a-clientnav-people') + checkA.pass = memberishText && loadedAvatars.length > 0 + checkA.symptom = checkA.pass + ? `member list + ${loadedAvatars.length} loaded avatar(s) rendered from cache` + : !memberishText + ? 'no non-zero member count text found (looks like silent-empty or offline fallback)' + : 'member text present but no avatar image had naturalWidth > 0 (avatars not cached)' + } catch (e) { + checkA.pass = false + checkA.symptom = `threw: ${e.message}` + } + result.checks.push(checkA) + + let checkAReload = { name: 'People page offline: hard reload shows members + avatars' } + try { + await page.goto(URLS.people, { waitUntil: 'load', timeout: 10000 }).catch((e) => { + checkAReload.gotoError = String(e) + }) + await page.waitForTimeout(2000) + + const text = await innerTextSafe(page) + const avatars = await avatarInfo(page, 'img') + const loadedAvatars = avatars.filter((a) => a.naturalWidth > 0) + const memberishText = /\d+\s+members?/i.test(text) && !/^0\s+members/i.test(text.trim()) + + checkAReload.textSnippet = text.slice(0, 300) + checkAReload.avatarCount = avatars.length + checkAReload.loadedAvatarCount = loadedAvatars.length + checkAReload.screenshot = await shot(page, 'p1-a-reload-people') + checkAReload.pass = memberishText && loadedAvatars.length > 0 + checkAReload.symptom = checkAReload.pass + ? `member list + ${loadedAvatars.length} loaded avatar(s) rendered from cache after hard reload` + : !memberishText + ? 'no non-zero member count text found after reload' + : 'member text present but no avatar image had naturalWidth > 0 after reload' + } catch (e) { + checkAReload.pass = false + checkAReload.symptom = `threw: ${e.message}` + } + result.checks.push(checkAReload) + + // (b) A member's profile never visited in this context — should render real content + // from the profile doc + bento cache the prefetcher warmed, not a skeleton forever and + // not a false NotFound. + let checkB = { name: `never-visited profile (${MEMBER}) renders offline` } + try { + await page.goto(URLS.person(MEMBER), { waitUntil: 'load', timeout: 10000 }).catch((e) => { + checkB.gotoError = String(e) + }) + await page.waitForTimeout(2500) + + const skeletonBefore = await fuiSkeletonCount(page) + await page.waitForTimeout(3000) + const skeletonAfter = await fuiSkeletonCount(page) + const stuckOnSkeleton = skeletonBefore > 0 && skeletonAfter > 0 + + const text = await innerTextSafe(page) + const info = await appRootInfo(page) + const isNotFound = /page not found/i.test(text) + const hasCardContent = await page + .locator('[data-profile-card-wrapper="true"], [data-profile-empty-state]') + .count() + + checkB.textSnippet = text.slice(0, 400) + checkB.stuckOnSkeleton = stuckOnSkeleton + checkB.isNotFound = isNotFound + checkB.hasCardContent = hasCardContent > 0 + checkB.info = info + checkB.screenshot = await shot(page, 'p1-b-unvisited-profile') + checkB.pass = !stuckOnSkeleton && !isNotFound && hasCardContent > 0 + checkB.symptom = checkB.pass + ? 'profile header + bento content rendered from prefetched cache' + : stuckOnSkeleton + ? 'stuck on skeleton — bento cards never resolved' + : isNotFound + ? 'showed NotFound instead of prefetched profile content' + : 'neither bento cards nor empty-state box rendered (blank content area)' + } catch (e) { + checkB.pass = false + checkB.symptom = `threw: ${e.message}` + checkB.screenshot = await shot(page, 'p1-b-unvisited-profile-error') + } + result.checks.push(checkB) + + // (c) That member's Posts tab — posts are NOT prefetched, so this must be the honest + // offline fallback (OfflineContentFallback), never a silently-empty "no posts" list. + let checkC = { name: `never-visited profile (${MEMBER}) Posts tab: honest offline fallback` } + try { + const postsTabBtn = page.locator('button', { hasText: 'Posts' }).first() + if (await postsTabBtn.count()) { + await postsTabBtn.click() + await page.waitForTimeout(2000) + } else { + await page.goto(URLS.personPosts(MEMBER), { waitUntil: 'load', timeout: 10000 }) + await page.waitForTimeout(2000) + } + + const text = await innerTextSafe(page) + const fallbackDetected = + /can.?t load this while offline|haven.?t been saved for offline use/i.test(text) + const retryVisible = await page.locator('button:has-text("Retry")').count() + + checkC.textSnippet = text.slice(0, 400) + checkC.fallbackDetected = fallbackDetected + checkC.retryVisible = retryVisible > 0 + checkC.screenshot = await shot(page, 'p1-c-unvisited-posts-tab') + checkC.pass = fallbackDetected && retryVisible > 0 + checkC.symptom = checkC.pass + ? 'honest offline fallback with retry shown for un-prefetched posts' + : 'no offline-fallback messaging/retry found — risk of silently-empty posts list' + } catch (e) { + checkC.pass = false + checkC.symptom = `threw: ${e.message}` + checkC.screenshot = await shot(page, 'p1-c-unvisited-posts-tab-error') + } + result.checks.push(checkC) + + result.pass = result.checks.every((c) => c.pass) + } catch (e) { + result.pass = false + result.fatalError = String(e) + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + result.prefetchLog = prefetchLog + await context.setOffline(false).catch(() => {}) + await browser.close() + } + + writeResult('p1', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } diff --git a/frontend/tests/offline/p2.js b/frontend/tests/offline/p2.js new file mode 100644 index 000000000..7575147b2 --- /dev/null +++ b/frontend/tests/offline/p2.js @@ -0,0 +1,154 @@ +// P2 — A profile visited fully online (header + bento + Posts) should work fully offline +// on reload: header, bento cards, and posts all rendering from cache — no reliance on the +// background prefetcher (which never warms posts) because this session visited everything +// itself. +const { + chromium, + URLS, + PEOPLE, + newLoggedInContext, + avatarInfo, + shot, + innerTextSafe, + appRootInfo, + writeResult, +} = require('./helpers') + +const MEMBER = PEOPLE.visitedFully // 'maya-iyer' — has real posts (see env notes) + +async function fuiSkeletonCount(page) { + return page.evaluate(() => document.querySelectorAll('.fui-skeleton').length) +} + +async function run() { + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser) + const result = { story: 'P2', checks: [] } + + try { + // Online warmup: visit the profile (header + bento) and its Posts tab, and let the + // service worker finish caching (mirrors helpers.warmup's SW-ready wait). + await page.goto(URLS.person(MEMBER), { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(2000) + const postsTabBtn = page.locator('button', { hasText: 'Posts' }).first() + if (await postsTabBtn.count()) { + await postsTabBtn.click() + await page.waitForTimeout(2000) + } + try { + await page.evaluate(() => navigator.serviceWorker.ready.then(() => true)) + } catch (e) { + // best-effort + } + await page.waitForTimeout(4000) // warmLoadedAssets() follow-up timer + + result.onlineWarmupUrl = page.url() + + await context.setOffline(true) + + // Hard reload straight onto the Posts tab URL — the harder case, since it must + // hydrate the profile header (parent route) and the posts list (child route) from + // cache in one navigation. + let checkPosts = { name: 'reload on Posts tab: header + posts render offline' } + try { + await page + .goto(URLS.personPosts(MEMBER), { waitUntil: 'load', timeout: 10000 }) + .catch((e) => { + checkPosts.gotoError = String(e) + }) + await page.waitForTimeout(2500) + + const text = await innerTextSafe(page) + const info = await appRootInfo(page) + const postLinks = await page.locator('a[href*="/discussion/"]').count() + const isNotFound = /page not found/i.test(text) + const fallbackShown = + /can.?t load this while offline|haven.?t been saved for offline use/i.test(text) + + checkPosts.textSnippet = text.slice(0, 400) + checkPosts.info = info + checkPosts.postLinks = postLinks + checkPosts.isNotFound = isNotFound + checkPosts.fallbackShown = fallbackShown + checkPosts.screenshot = await shot(page, 'p2-reload-posts-tab') + checkPosts.pass = !isNotFound && !fallbackShown && postLinks > 0 + checkPosts.symptom = checkPosts.pass + ? `${postLinks} cached post row(s) rendered offline` + : isNotFound + ? 'NotFound shown instead of cached profile' + : fallbackShown + ? 'offline fallback shown even though Posts tab was visited online first (cache miss)' + : 'no post rows found — posts list likely empty/blank' + } catch (e) { + checkPosts.pass = false + checkPosts.symptom = `threw: ${e.message}` + checkPosts.screenshot = await shot(page, 'p2-reload-posts-tab-error') + } + result.checks.push(checkPosts) + + // Now reload onto the Profile tab specifically, to check header + bento independent + // of whatever the Posts-tab reload left mounted. + let checkProfile = { name: 'reload on Profile tab: header + bento render offline' } + try { + await page.goto(URLS.person(MEMBER), { waitUntil: 'load', timeout: 10000 }).catch((e) => { + checkProfile.gotoError = String(e) + }) + await page.waitForTimeout(2500) + + const skeletonBefore = await fuiSkeletonCount(page) + await page.waitForTimeout(3000) + const skeletonAfter = await fuiSkeletonCount(page) + const stuckOnSkeleton = skeletonBefore > 0 && skeletonAfter > 0 + + const text = await innerTextSafe(page) + const isNotFound = /page not found/i.test(text) + const hasCardContent = await page + .locator('[data-profile-card-wrapper="true"], [data-profile-empty-state]') + .count() + const avatars = await avatarInfo(page, 'img') + const loadedAvatars = avatars.filter((a) => a.naturalWidth > 0) + + checkProfile.textSnippet = text.slice(0, 400) + checkProfile.stuckOnSkeleton = stuckOnSkeleton + checkProfile.isNotFound = isNotFound + checkProfile.hasCardContent = hasCardContent > 0 + checkProfile.loadedAvatarCount = loadedAvatars.length + checkProfile.screenshot = await shot(page, 'p2-reload-profile-tab') + checkProfile.pass = !stuckOnSkeleton && !isNotFound && hasCardContent > 0 + checkProfile.symptom = checkProfile.pass + ? 'header + bento content rendered from cache on reload' + : stuckOnSkeleton + ? 'stuck on skeleton — bento cards never resolved offline' + : isNotFound + ? 'showed NotFound instead of cached profile' + : 'neither bento cards nor empty-state box rendered' + } catch (e) { + checkProfile.pass = false + checkProfile.symptom = `threw: ${e.message}` + checkProfile.screenshot = await shot(page, 'p2-reload-profile-tab-error') + } + result.checks.push(checkProfile) + + result.pass = result.checks.every((c) => c.pass) + } catch (e) { + result.pass = false + result.fatalError = String(e) + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + await context.setOffline(false).catch(() => {}) + await browser.close() + } + + writeResult('p2', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } diff --git a/frontend/tests/offline/p3.js b/frontend/tests/offline/p3.js new file mode 100644 index 000000000..154944afc --- /dev/null +++ b/frontend/tests/offline/p3.js @@ -0,0 +1,224 @@ +// P3 — Honest failure without cache: a session that goes offline before the background +// prefetcher (data/offlinePrefetch.ts) or any manual browsing populated the People/profile +// caches must show an honest "can't load this offline" fallback with retry — never a +// silent "0 members" empty list, an infinite skeleton, or a misleading NotFound. +// +// Race note: we go offline as fast as possible after the very first (minimal) online page +// load, before the prefetcher's idle-delayed pass can finish. If the prefetcher still wins +// the race in a given run (slow/fast CI hardware makes the exact timing non-deterministic), +// we fall back to explicitly deleting the specific IndexedDB cache entries the prefetcher +// would have written (People list, this profile's doc, this profile's bento) — the +// documented escape hatch in the task brief — so the "no cache" condition this story is +// actually about is guaranteed either way, and which path was taken is recorded in the +// result for transparency. +const { + chromium, + URLS, + PEOPLE, + EMAIL, + newLoggedInContext, + shot, + innerTextSafe, + appRootInfo, + writeResult, +} = require('./helpers') + +const MEMBER = PEOPLE.neverVisitedNoPrefetch // 'hana-suzuki' + +// Mirrors frappe-ui's idb-keyval default store (idbStore.ts -> createStore('keyval-store', +// 'keyval')) and the exact key shapes data/people.ts, ProfileBento/profileBentoSource.ts, +// and docStore.ts write to, so this only ever touches the People/this-profile entries — not +// session/communities/spaces data the app needs to know it's still logged in offline. +async function clearPeopleAndProfileCache(page, personId, sessionUser) { + const keysToDelete = [ + JSON.stringify(['useList', 'People', sessionUser]), + JSON.stringify(['useCall', 'ProfileBento', personId, sessionUser]), + `doc:GP User Profile/${personId}`, + ] + return page.evaluate((keys) => { + return new Promise((resolve) => { + const req = indexedDB.open('keyval-store') + req.onsuccess = () => { + const db = req.result + if (!db.objectStoreNames.contains('keyval')) { + db.close() + resolve({ deleted: [], reason: 'no keyval store' }) + return + } + const tx = db.transaction('keyval', 'readwrite') + const store = tx.objectStore('keyval') + keys.forEach((k) => store.delete(k)) + tx.oncomplete = () => { + db.close() + resolve({ deleted: keys }) + } + tx.onerror = () => { + db.close() + resolve({ deleted: [], reason: String(tx.error) }) + } + } + req.onerror = () => resolve({ deleted: [], reason: String(req.error) }) + }) + }, keysToDelete) +} + +async function run() { + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors, prefetchLog } = + await newLoggedInContext(browser) + const result = { story: 'P3', checks: [] } + + try { + // Minimal online exposure: enough for the SW to register and become active (it only + // starts registering on the window `load` event - see offline.ts's setupOfflineSupport + // - and a subsequent offline navigation needs it active to serve the shell at all, + // which is a US1 concern, not what this story is testing) but deliberately not waiting + // for anything past that, so we go offline as close as possible to page load, ahead of + // the prefetcher's idle-delayed kickoff. + await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 }) + // `/g` itself client-side redirects to the community's discussions route once the + // app has hydrated enough to know where to send you (post-327d7ae3, cache-hydration + // gated, so not always instant). This story doesn't actually depend on racing ahead + // of that specific redirect — only ahead of the background prefetcher's much slower + // idle-delayed pass, and it force-clears the People/profile/bento cache entries + // below regardless of how any race went (see the comment above) — so it's safe to + // let the redirect land first, rather than risk it firing mid-`page.evaluate()` + // below and destroying the execution context out from under this test. + await page.waitForURL(/\/g\/community\//, { timeout: 10000 }).catch(() => {}) + try { + await page.evaluate(() => navigator.serviceWorker.ready.then(() => true)) + } catch (e) { + // best-effort - if this never resolves, the offline navigations below will + // surface it as a shell-level failure rather than the data-layer fallback. + } + await context.setOffline(true) + + const sawDoneBeforeOffline = prefetchLog.some((l) => l.includes('[offline-prefetch] done')) + result.racedSuccessfully = !sawDoneBeforeOffline + result.prefetchLogAtOfflineTime = [...prefetchLog] + + // Always clear, not just when the prefetcher's "done" log won the race: `data/people.ts` + // exports its `people` list as a module-level `useList({ immediate: true })` singleton, + // so the People list starts fetching the moment the app boots on ANY page (imported + // transitively by main.js -> offlinePrefetch.ts -> people.ts) - well before the + // prefetcher's own idle-delayed pass, and far faster than this script can react after + // the page `load` event. Racing `setOffline` against that fetch is not reliably + // winnable, so - per the task brief's documented escape hatch - this story always forces + // the "nothing cached yet" condition it's actually about, rather than depending on + // timing luck for a signal (`people.ts`'s own immediate fetch) this story never races on. + result.cacheClear = await clearPeopleAndProfileCache(page, MEMBER, EMAIL) + + result.checks.push({ + name: 'no People/profile/bento cache present before navigating offline', + pass: Boolean(result.cacheClear && result.cacheClear.deleted?.length), + symptom: sawDoneBeforeOffline + ? `prefetch "done" log had already fired; cleared its cache entries: ${JSON.stringify(result.cacheClear)}` + : `raced offline ahead of prefetch "done"; cleared cache entries anyway (people.ts's own immediate fetch races independently): ${JSON.stringify(result.cacheClear)}`, + }) + + // People page: must show an offline fallback with retry, not "0 members". + let checkPeople = { name: 'People page offline, no cache: honest fallback with retry' } + try { + await page.goto(URLS.people, { waitUntil: 'load', timeout: 10000 }).catch((e) => { + checkPeople.gotoError = String(e) + }) + await page.waitForTimeout(2000) + + const text = await innerTextSafe(page) + const info = await appRootInfo(page) + const fallbackDetected = /can.?t load|not available while offline|offline/i.test(text) + const retryVisible = await page.locator('button:has-text("Retry")').count() + // "0 members" in the header count is fine as long as it's paired with the honest + // fallback message below it — that's a live count reading zero because `people.data` + // is null, not a silent claim that the org has no members. Only flag it when there's + // no fallback messaging to explain the zero (the actual US6-class bug this guards). + const silentEmpty = !fallbackDetected && /\b0 members\b/i.test(text) + + checkPeople.textSnippet = text.slice(0, 400) + checkPeople.info = info + checkPeople.silentEmpty = silentEmpty + checkPeople.fallbackDetected = fallbackDetected + checkPeople.retryVisible = retryVisible > 0 + checkPeople.screenshot = await shot(page, 'p3-people-no-cache') + checkPeople.pass = !silentEmpty && fallbackDetected && retryVisible > 0 + checkPeople.symptom = checkPeople.pass + ? 'honest offline fallback with retry shown (not silent "0 members")' + : silentEmpty + ? 'silent "0 members" with no fallback messaging — indistinguishable from an empty org (US6-class bug)' + : !fallbackDetected + ? 'no offline messaging found' + : 'no retry affordance found' + } catch (e) { + checkPeople.pass = false + checkPeople.symptom = `threw: ${e.message}` + checkPeople.screenshot = await shot(page, 'p3-people-no-cache-error') + } + result.checks.push(checkPeople) + + // A profile with nothing cached: honest fallback, not infinite skeleton, not a + // misleading "page not found". + let checkProfile = { name: `profile (${MEMBER}) offline, no cache: honest fallback` } + try { + await page.goto(URLS.person(MEMBER), { waitUntil: 'load', timeout: 10000 }).catch((e) => { + checkProfile.gotoError = String(e) + }) + await page.waitForTimeout(2000) + const skeletonBefore = await page.evaluate( + () => document.querySelectorAll('.fui-skeleton').length, + ) + await page.waitForTimeout(3000) + const skeletonAfter = await page.evaluate( + () => document.querySelectorAll('.fui-skeleton').length, + ) + const stuckOnSkeleton = skeletonBefore > 0 && skeletonAfter > 0 + + const text = await innerTextSafe(page) + const isNotFound = /page not found/i.test(text) + const fallbackDetected = + /can.?t load this (profile )?while offline|isn.?t available offline/i.test(text) + const retryVisible = await page.locator('button:has-text("Retry")').count() + + checkProfile.textSnippet = text.slice(0, 400) + checkProfile.stuckOnSkeleton = stuckOnSkeleton + checkProfile.isNotFound = isNotFound + checkProfile.fallbackDetected = fallbackDetected + checkProfile.retryVisible = retryVisible > 0 + checkProfile.screenshot = await shot(page, 'p3-profile-no-cache') + checkProfile.pass = !stuckOnSkeleton && !isNotFound && fallbackDetected + checkProfile.symptom = checkProfile.pass + ? 'honest "can\'t load this profile while offline" fallback shown' + : stuckOnSkeleton + ? 'stuck on skeleton forever' + : isNotFound + ? 'misleading NotFound page shown instead of an offline fallback' + : 'no offline fallback messaging detected' + } catch (e) { + checkProfile.pass = false + checkProfile.symptom = `threw: ${e.message}` + checkProfile.screenshot = await shot(page, 'p3-profile-no-cache-error') + } + result.checks.push(checkProfile) + + result.pass = result.checks.every((c) => c.pass) + } catch (e) { + result.pass = false + result.fatalError = String(e) + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + await context.setOffline(false).catch(() => {}) + await browser.close() + } + + writeResult('p3', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } diff --git a/frontend/tests/offline/package.json b/frontend/tests/offline/package.json new file mode 100644 index 000000000..0fef86e33 --- /dev/null +++ b/frontend/tests/offline/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "commonjs" +} diff --git a/frontend/tests/offline/runner.js b/frontend/tests/offline/runner.js new file mode 100644 index 000000000..010227250 --- /dev/null +++ b/frontend/tests/offline/runner.js @@ -0,0 +1,47 @@ +// Runs the full offline suite: US1-US6 (baseline offline UX) + P1-P3 (People/profile +// offline caching + background prefetch) + US7a/US7b (shared-computer cache scoping) + +// US8 (service worker update flow). Each story launches its own fresh browser/context +// for isolation. Run with: node tests/offline/runner.js (or `yarn test:offline` from +// frontend/). +const fs = require('fs') +const path = require('path') +const { RESULTS_DIR } = require('./config') + +const stories = ['us1', 'us2', 'us3', 'us4', 'us5', 'us6', 'p1', 'p2', 'p3', 'us7a', 'us7b', 'us8'] + +async function main() { + const summary = [] + for (const story of stories) { + console.log(`\n=== Running ${story.toUpperCase()} ===`) + const mod = require(`./${story}`) + try { + const r = await mod.run() + summary.push({ + story: r.story, + pass: r.pass, + checks: (r.checks || []).map((c) => ({ name: c.name, pass: c.pass, symptom: c.symptom })), + fatalError: r.fatalError, + cleanup: r.cleanup, + }) + console.log(`${story.toUpperCase()}: ${r.pass ? 'PASS' : 'FAIL'}`) + } catch (e) { + console.error(`${story.toUpperCase()} crashed:`, e) + summary.push({ story: story.toUpperCase(), pass: false, fatalError: String(e) }) + } + } + + fs.mkdirSync(RESULTS_DIR, { recursive: true }) + const outPath = path.join(RESULTS_DIR, 'summary.json') + fs.writeFileSync(outPath, JSON.stringify(summary, null, 2)) + console.log('\n=== Summary ===') + for (const s of summary) { + console.log(`${s.story}: ${s.pass ? 'PASS' : 'FAIL'}`) + } + console.log(`\nFull summary written to ${outPath}`) + + if (summary.some((s) => !s.pass)) { + process.exitCode = 1 + } +} + +main() diff --git a/frontend/tests/offline/smoke-online-people.js b/frontend/tests/offline/smoke-online-people.js new file mode 100644 index 000000000..cc23e37a9 --- /dev/null +++ b/frontend/tests/offline/smoke-online-people.js @@ -0,0 +1,137 @@ +// Online regression smoke test (round 3 addition) — fresh context, always online. Confirms +// the People page and a member profile load normally with the new caching/prefetch code in +// place, no new console errors beyond the known :9000 socket.io refusal (see env.md), and +// the background prefetcher's '[offline-prefetch] done' log appears with plausible counts. +// Complements smoke-online.js (feed/space/discussion + comment post/delete), which this +// does not repeat. +const { + chromium, + URLS, + PEOPLE, + newLoggedInContext, + waitForPrefetchDone, + avatarInfo, + shot, + innerTextSafe, + writeResult, +} = require('./helpers') + +async function run() { + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors, prefetchLog } = + await newLoggedInContext(browser) + const result = { story: 'SMOKE-ONLINE-PEOPLE', checks: [] } + + // `msg.text()` for a "Failed to load resource" console error doesn't include the URL, so + // matching it against a socket.io/:9000 regex (as smoke-online.js's own comment describes + // doing "via a requestfailed listener") needs the request-level event instead - track how + // many of those failures are the known :9000 socket.io refusal so the console-error check + // below can subtract exactly that many, not just pattern-match on generic text. + let socketIoFailures = 0 + page.on('requestfailed', (req) => { + if (/:9000\/socket\.io/i.test(req.url())) socketIoFailures++ + }) + + try { + await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 }) + // See smoke-online.js's identical comment: let /g's client-side redirect land first. + await page.waitForURL(/\/g\/community\//, { timeout: 10000 }).catch(() => {}) + await page.waitForTimeout(1000) + + // People page + let checkPeople = { name: 'People page loads online' } + await page.goto(URLS.people, { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(1500) + const peopleText = await innerTextSafe(page) + const avatars = await avatarInfo(page, 'img') + const loadedAvatars = avatars.filter((a) => a.naturalWidth > 0) + checkPeople.textSnippet = peopleText.slice(0, 300) + checkPeople.loadedAvatarCount = loadedAvatars.length + checkPeople.pass = + /\d+\s+members?/i.test(peopleText) && !/^0\s+members/i.test(peopleText.trim()) + checkPeople.symptom = checkPeople.pass + ? 'member list rendered online' + : 'People page did not render a real member count online' + checkPeople.screenshot = await shot(page, 'smoke-people-online') + result.checks.push(checkPeople) + + // A profile + let checkProfile = { name: 'A member profile loads online' } + await page.goto(URLS.person(PEOPLE.visitedFully), { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(2000) + const profileText = await innerTextSafe(page) + const hasCardContent = await page + .locator('[data-profile-card-wrapper="true"], [data-profile-empty-state]') + .count() + checkProfile.textSnippet = profileText.slice(0, 300) + checkProfile.hasCardContent = hasCardContent > 0 + checkProfile.pass = !/page not found/i.test(profileText) && hasCardContent > 0 + checkProfile.symptom = checkProfile.pass + ? 'profile header + bento content rendered online' + : 'profile did not render bento content online (see hasCardContent)' + checkProfile.screenshot = await shot(page, 'smoke-profile-online') + result.checks.push(checkProfile) + + // Background prefetch completes with plausible counts + let checkPrefetch = { name: 'background prefetch "done" log with plausible counts' } + const prefetch = await waitForPrefetchDone(prefetchLog, { timeoutMs: 45000 }) + checkPrefetch.prefetch = prefetch + checkPrefetch.pass = Boolean( + prefetch.sawDone && + prefetch.counts && + prefetch.counts.members > 0 && + prefetch.counts.profiles > 0 && + prefetch.counts.bento > 0, + ) + checkPrefetch.symptom = checkPrefetch.pass + ? `plausible counts: ${JSON.stringify(prefetch.counts)}` + : `no plausible "done" log seen: ${JSON.stringify(prefetch)}` + result.checks.push(checkPrefetch) + + // No new console errors beyond the known socket.io :9000 refusal. Every "Failed to load + // resource" console error should be accounted for by an equal number of :9000 socket.io + // requestfailed events (see the listener above) — anything left over is unexpected. + const failedResourceErrors = consoleErrors.filter((e) => e.includes('Failed to load resource')) + const otherConsoleErrors = consoleErrors.filter((e) => !e.includes('Failed to load resource')) + const unexplainedFailedResourceCount = Math.max( + 0, + failedResourceErrors.length - socketIoFailures, + ) + result.socketIoFailures = socketIoFailures + result.checks.push({ + name: 'no unexpected console errors', + pass: + unexplainedFailedResourceCount === 0 && + otherConsoleErrors.length === 0 && + pageErrors.length === 0, + symptom: + unexplainedFailedResourceCount === 0 && + otherConsoleErrors.length === 0 && + pageErrors.length === 0 + ? `only the known socket.io :9000 refusal (${socketIoFailures} request(s)), zero uncaught exceptions` + : `unexplained failed-resource errors=${unexplainedFailedResourceCount}, other console errors=${JSON.stringify(otherConsoleErrors)}, pageErrors=${JSON.stringify(pageErrors)}`, + }) + + result.pass = result.checks.every((c) => c.pass) + } catch (e) { + result.pass = false + result.fatalError = String(e) + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + result.prefetchLog = prefetchLog + await browser.close() + } + + writeResult('smoke-online-people', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } diff --git a/frontend/tests/offline/smoke-online-shared-computer.js b/frontend/tests/offline/smoke-online-shared-computer.js new file mode 100644 index 000000000..6aeb0b37d --- /dev/null +++ b/frontend/tests/offline/smoke-online-shared-computer.js @@ -0,0 +1,66 @@ +// Online regression smoke test (round 4 addition) — confirms this round's shared-computer +// safety + update-flow changes didn't break ordinary online usage: a plain login/logout +// cycle still works and lands on /login, and a completely fresh session sees NO update +// toast on first visit (no stale 'waiting' worker, no spurious controllerchange reload). +// Complements smoke-online.js (feed/space/discussion + comment) and +// smoke-online-people.js (People/profile + prefetch), which this does not repeat. +const { chromium, URLS, newLoggedInContext, logoutViaUI, shot, writeResult } = require('./helpers') + +async function run() { + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser) + const result = { story: 'SMOKE-ONLINE-SHARED-COMPUTER', checks: [] } + + try { + await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(1500) + + // No update toast on a fresh first visit (no prior SW version to diff against). + const toastVisible = await page + .locator('text=/A new version of Gameplan is available/i') + .isVisible() + .catch(() => false) + result.checks.push({ + name: 'no update toast on first visit', + pass: !toastVisible, + symptom: toastVisible + ? 'update toast unexpectedly shown on a fresh session' + : 'no toast shown', + }) + + // Normal logout via the real UI path still works and lands on /login. + let checkLogout = { name: 'logout via UI works, lands on /login' } + try { + await logoutViaUI(page) + await page.waitForURL('**/login**', { timeout: 10000 }) + checkLogout.pass = true + checkLogout.symptom = `redirected to ${page.url()}` + } catch (e) { + checkLogout.pass = false + checkLogout.symptom = `threw: ${e.message}` + checkLogout.screenshot = await shot(page, 'smoke-shared-computer-logout-error') + } + result.checks.push(checkLogout) + + result.pass = result.checks.every((c) => c.pass) + } catch (e) { + result.pass = false + result.fatalError = String(e) + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + await browser.close() + } + + writeResult('smoke-online-shared-computer', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } diff --git a/frontend/tests/offline/smoke-online.js b/frontend/tests/offline/smoke-online.js new file mode 100644 index 000000000..2132b8b76 --- /dev/null +++ b/frontend/tests/offline/smoke-online.js @@ -0,0 +1,173 @@ +// Online regression smoke test — fresh context, online only. Confirms feed/space/ +// discussion load, post+delete a comment works, no new console errors, and the +// offline indicator is NOT visible while online. Not part of the US1-US6 suite. +const { + chromium, + URLS, + newLoggedInContext, + newApiRequestContext, + shot, + writeResult, +} = require('./helpers') + +const MARKER = `smoke-${Date.now()}` +const DISTINCTIVE_TEXT = `Online smoke test comment ${MARKER}` + +async function run() { + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser) + const result = { story: 'SMOKE-ONLINE', checks: [] } + let createdCommentName = null + + try { + // Feed + await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 }) + // `/g` client-side redirects to the community's discussions route once the app has + // hydrated — let it land before the next goto(), or that next navigation can get + // reported as "interrupted by another navigation" to the redirect's target. + await page.waitForURL(/\/g\/community\//, { timeout: 10000 }).catch(() => {}) + // Poll rather than a single fixed-delay check: under system load, the redirect and + // first render can land a bit after `waitForURL` resolves — same content, just slower. + let feedInfo = { title: '', bodyText: '' } + const feedDeadline = Date.now() + 8000 + while (Date.now() < feedDeadline) { + feedInfo = await page.evaluate(() => ({ + title: document.title, + bodyText: document.body.innerText.slice(0, 300), + })) + if (feedInfo.bodyText.length > 0) break + await page.waitForTimeout(500) + } + result.checks.push({ name: 'feed loads', pass: feedInfo.bodyText.length > 0, info: feedInfo }) + + // Space discussion list + await page.goto(URLS.spaceDiscussions, { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(1000) + const spaceInfo = await page.evaluate(() => document.body.innerText.slice(0, 300)) + result.checks.push({ + name: 'space discussion list loads', + pass: spaceInfo.includes('Art'), + info: spaceInfo, + }) + + // Discussion + await page.goto(URLS.discussion, { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(1000) + const discInfo = await page.evaluate(() => document.body.innerText.slice(0, 300)) + result.checks.push({ + name: 'discussion loads', + pass: discInfo.includes('Capsule art'), + info: discInfo, + }) + + // Offline indicator should NOT be visible online + const offlineIndicatorVisible = await page.evaluate(() => { + const els = Array.from(document.querySelectorAll('body *')) + const re = /offline|you.?re offline|showing saved|reconnect/i + return els.some((el) => { + if (el.children.length > 0) return false + const text = el.textContent?.trim() + if (!text || text.length > 100) return false + if (!re.test(text)) return false + const rect = el.getBoundingClientRect() + const style = window.getComputedStyle(el) + return ( + rect.width > 0 && + rect.height > 0 && + style.visibility !== 'hidden' && + style.display !== 'none' + ) + }) + }) + result.checks.push({ + name: 'offline indicator NOT visible while online', + pass: !offlineIndicatorVisible, + info: { offlineIndicatorVisible }, + }) + + // Post a comment + const addCommentBtn = page.locator('button:has-text("Add a comment")').first() + if (await addCommentBtn.isVisible().catch(() => false)) { + await addCommentBtn.click() + await page.waitForTimeout(300) + } + const editor = page.locator('[contenteditable="true"]').last() + await editor.click({ timeout: 8000 }) + await page.keyboard.type(DISTINCTIVE_TEXT, { delay: 10 }) + await page.waitForTimeout(300) + const submitBtn = page.locator('button:has-text("Submit"):visible').last() + await submitBtn.click({ timeout: 8000 }) + await page.waitForTimeout(2000) + const afterPostText = await page.evaluate(() => document.body.innerText) + const posted = afterPostText.includes(DISTINCTIVE_TEXT) + result.checks.push({ name: 'post comment online', pass: posted }) + result.screenshotAfterPost = await shot(page, 'smoke-online-comment-posted') + + if (posted) { + // Delete via UI: open the comment's "..." (Comment Options) dropdown, click + // Delete, confirm the danger dialog. + let deletedViaUI = false + try { + const commentRow = page.locator(':has-text("' + DISTINCTIVE_TEXT + '")').last() + const optionsBtn = page.locator('button[aria-label="Comment Options"]').last() + await optionsBtn.click({ timeout: 5000 }) + await page.waitForTimeout(200) + await page.locator('text=Delete').last().click({ timeout: 5000 }) + await page.waitForTimeout(300) + await page.locator('button:has-text("Delete")').last().click({ timeout: 5000 }) + await page.waitForTimeout(1500) + const afterDeleteText = await page.evaluate(() => document.body.innerText) + deletedViaUI = !afterDeleteText.includes(DISTINCTIVE_TEXT) + } catch (e) { + result.deleteViaUIError = String(e) + } + result.checks.push({ name: 'delete comment via UI', pass: deletedViaUI }) + result.screenshotAfterDelete = await shot(page, 'smoke-online-comment-deleted') + + // Verify + fallback cleanup via API regardless (belt and suspenders — don't + // leave a stray comment on the site if the UI delete selector was off). + const api = await newApiRequestContext() + const resp = await api.get( + `/api/method/frappe.client.get_list?doctype=GP%20Comment&filters=${encodeURIComponent( + JSON.stringify([ + ['reference_name', '=', '55'], + ['content', 'like', `%${MARKER}%`], + ]), + )}&fields=${encodeURIComponent(JSON.stringify(['name']))}`, + ) + const body = await resp.json().catch(() => null) + createdCommentName = body?.message?.[0]?.name ?? null + if (createdCommentName) { + await api.post('/api/method/frappe.client.delete', { + form: { doctype: 'GP Comment', name: String(createdCommentName) }, + }) + result.cleanup = `deletedViaUI=${deletedViaUI}; also deleted GP Comment ${createdCommentName} via API (fallback/verify)` + } else { + result.cleanup = `deletedViaUI=${deletedViaUI}; no leftover GP Comment found via API` + } + await api.dispose() + } + + result.pass = result.checks.every((c) => c.pass) + } catch (e) { + result.pass = false + result.fatalError = String(e) + result.screenshotOnError = await shot(page, 'smoke-online-error') + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + await browser.close() + } + + writeResult('smoke-online', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } diff --git a/frontend/tests/offline/us1.js b/frontend/tests/offline/us1.js new file mode 100644 index 000000000..25559b598 --- /dev/null +++ b/frontend/tests/offline/us1.js @@ -0,0 +1,81 @@ +// US1 — Launch offline: reload /g and a deep link while offline; app shell should +// render instead of the browser's net::ERR page. +const { + chromium, + URLS, + newLoggedInContext, + warmup, + shot, + appRootInfo, + writeResult, +} = require('./helpers') + +async function run() { + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser) + const result = { story: 'US1', checks: [] } + + try { + result.warmup = await warmup(page) + + await context.setOffline(true) + + // Check 1: reload /g while offline. + let check1 = { name: 'reload /g offline' } + try { + await page.goto(URLS.feed, { waitUntil: 'load', timeout: 8000 }) + await page.waitForTimeout(1500) + check1.info = await appRootInfo(page) + check1.screenshot = await shot(page, 'us1-reload-feed') + check1.pass = check1.info.appExists && check1.info.appChildCount > 0 + check1.symptom = check1.pass + ? 'app shell rendered' + : `blank/unmounted app root (appExists=${check1.info.appExists}, children=${check1.info.appChildCount})` + } catch (e) { + check1.pass = false + check1.symptom = `navigation threw: ${e.message}` + check1.screenshot = await shot(page, 'us1-reload-feed-error') + } + result.checks.push(check1) + + // Check 2: reload the discussion deep link while offline. + let check2 = { name: 'reload discussion deep link offline' } + try { + await page.goto(URLS.discussion, { waitUntil: 'load', timeout: 8000 }) + await page.waitForTimeout(1500) + check2.info = await appRootInfo(page) + check2.screenshot = await shot(page, 'us1-reload-discussion') + check2.pass = check2.info.appExists && check2.info.appChildCount > 0 + check2.symptom = check2.pass + ? 'app shell rendered' + : `blank/unmounted app root (appExists=${check2.info.appExists}, children=${check2.info.appChildCount})` + } catch (e) { + check2.pass = false + check2.symptom = `navigation threw: ${e.message}` + check2.screenshot = await shot(page, 'us1-reload-discussion-error') + } + result.checks.push(check2) + + result.pass = result.checks.every((c) => c.pass) + } catch (e) { + result.pass = false + result.fatalError = String(e) + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + await context.setOffline(false).catch(() => {}) + await browser.close() + } + + writeResult('us1', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } diff --git a/frontend/tests/offline/us2.js b/frontend/tests/offline/us2.js new file mode 100644 index 000000000..9d9bdbd4b --- /dev/null +++ b/frontend/tests/offline/us2.js @@ -0,0 +1,196 @@ +// US2 — Read what I've seen: after warming feed / space discussion list / discussion, +// go offline and revisit each via both reload and client-side nav; cached content +// (title/body/comments/list items) should be visible, no infinite spinner/error screen. +const { + chromium, + URLS, + newLoggedInContext, + warmup, + shot, + innerTextSafe, + appRootInfo, + writeResult, +} = require('./helpers') + +const EXPECTED = { + discussionTitle: 'Capsule art, near-final, need eyes before it goes on the page', + spaceTitle: 'Art', +} + +function textCheck(text, needle) { + return text.toLowerCase().includes(needle.toLowerCase()) +} + +async function evaluateContent(page, name, requiredSnippets) { + await page.waitForTimeout(2000) + const info = await appRootInfo(page) + const text = await innerTextSafe(page) + const screenshot = await shot(page, name) + const foundSnippets = requiredSnippets.filter((s) => textCheck(text, s)) + const missingSnippets = requiredSnippets.filter((s) => !textCheck(text, s)) + const looksLikeSpinnerOnly = /^\s*$/.test(text) || text.trim().length < 5 + const landedOnOnboarding = page.url().includes('/onboarding') + return { + url: page.url(), + info, + screenshot, + textSnippet: text.slice(0, 400), + foundSnippets, + missingSnippets, + looksLikeSpinnerOnly, + landedOnOnboarding, + pass: + info.appExists && + info.appChildCount > 0 && + missingSnippets.length === 0 && + !landedOnOnboarding, + } +} + +async function run() { + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser) + const result = { story: 'US2', checks: [] } + + try { + result.warmup = await warmup(page) + await context.setOffline(true) + + // --- Reload-based checks (fresh navigation while offline) --- + for (const [label, url, snippets] of [ + ['reload feed', URLS.feed, ['Common Room']], + ['reload space discussions', URLS.spaceDiscussions, [EXPECTED.spaceTitle]], + ['reload discussion', URLS.discussion, [EXPECTED.discussionTitle]], + ]) { + let check = { name: label, mode: 'reload' } + try { + await page.goto(url, { waitUntil: 'load', timeout: 8000 }) + Object.assign( + check, + await evaluateContent(page, `us2-${label.replace(/\s+/g, '-')}`, snippets), + ) + check.symptom = check.pass + ? 'cached content visible' + : check.landedOnOnboarding + ? 'redirected to /g/onboarding instead of showing cached feed content' + : check.looksLikeSpinnerOnly + ? 'blank / spinner-only, no content' + : `missing expected text: ${check.missingSnippets.join(', ') || '(app root did not mount)'}` + } catch (e) { + check.pass = false + check.symptom = `navigation threw: ${e.message}` + check.screenshot = await shot(page, `us2-${label.replace(/\s+/g, '-')}-error`) + } + result.checks.push(check) + } + + // --- Client-side navigation checks (SPA nav while offline, starting from the + // discussion page which we know loads from the reload check above) --- + let clientNavOk = true + try { + await page.goto(URLS.discussion, { waitUntil: 'load', timeout: 8000 }) + await page.waitForTimeout(1500) + } catch (e) { + clientNavOk = false + result.checks.push({ + name: 'client-nav setup (load discussion)', + mode: 'client-nav', + pass: false, + symptom: `could not establish SPA starting point offline: ${e.message}`, + }) + } + + if (clientNavOk) { + for (const [label, url, snippets] of [ + ['client-nav to space discussions', URLS.spaceDiscussions, [EXPECTED.spaceTitle]], + ['client-nav to feed', URLS.feed, ['Common Room']], + ['client-nav back to discussion', URLS.discussion, [EXPECTED.discussionTitle]], + ]) { + let check = { name: label, mode: 'client-nav' } + try { + // Use page.evaluate + history/router link click semantics via direct goto with + // waitUntil 'commit' would still be a browser navigation; to truly exercise SPA + // client-side routing we click an in-app link where possible. Falling back to + // page.goto with 'commit' (not 'load') most closely emulates a client nav wait + // profile without forcing a full reload wait, but Playwright's goto is always a + // real navigation. We accept this as "navigation" coverage; a dedicated in-app + // link click is used for at least one hop below for genuine SPA-nav evidence. + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 8000 }) + Object.assign( + check, + await evaluateContent(page, `us2-${label.replace(/\s+/g, '-')}`, snippets), + ) + check.symptom = check.pass + ? 'cached content visible' + : check.landedOnOnboarding + ? 'redirected to /g/onboarding instead of showing cached feed content' + : check.looksLikeSpinnerOnly + ? 'blank / spinner-only, no content' + : `missing expected text: ${check.missingSnippets.join(', ') || '(app root did not mount)'}` + } catch (e) { + check.pass = false + check.symptom = `navigation threw: ${e.message}` + check.screenshot = await shot(page, `us2-${label.replace(/\s+/g, '-')}-error`) + } + result.checks.push(check) + } + + // Genuine in-app SPA link click: from space discussions list, click the seeded + // discussion row and confirm it opens client-side (no full navigation) while offline. + let clickCheck = { + name: 'in-app link click: space list -> discussion', + mode: 'client-nav-click', + } + try { + await page.goto(URLS.spaceDiscussions, { waitUntil: 'load', timeout: 8000 }) + await page.waitForTimeout(1500) + const link = page.locator(`a[href*="/discussion/55"]`).first() + const linkVisible = await link.isVisible().catch(() => false) + clickCheck.linkVisible = linkVisible + if (linkVisible) { + await link.click() + await page.waitForTimeout(1500) + Object.assign( + clickCheck, + await evaluateContent(page, 'us2-click-into-discussion', [EXPECTED.discussionTitle]), + ) + clickCheck.symptom = clickCheck.pass + ? 'cached content visible after client-side link click' + : clickCheck.landedOnOnboarding + ? 'redirected to /g/onboarding instead of the discussion' + : `missing expected text: ${clickCheck.missingSnippets?.join(', ') || '(app root did not mount)'}` + } else { + clickCheck.pass = false + clickCheck.symptom = 'discussion row link not found/visible in cached space list' + clickCheck.screenshot = await shot(page, 'us2-click-into-discussion-no-link') + } + } catch (e) { + clickCheck.pass = false + clickCheck.symptom = `click nav threw: ${e.message}` + } + result.checks.push(clickCheck) + } + + result.pass = result.checks.every((c) => c.pass) + } catch (e) { + result.pass = false + result.fatalError = String(e) + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + await context.setOffline(false).catch(() => {}) + await browser.close() + } + + writeResult('us2', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } diff --git a/frontend/tests/offline/us3.js b/frontend/tests/offline/us3.js new file mode 100644 index 000000000..00d1a2196 --- /dev/null +++ b/frontend/tests/offline/us3.js @@ -0,0 +1,132 @@ +// US3 — Know I'm offline: an indicator should appear when offline and clear on +// reconnect. We search broadly (role=status/alert, common banner/toast/pill classes, +// and any element whose text matches /offline/i) since we don't know the exact +// implementation up front. +const { + chromium, + URLS, + EMAIL, + newLoggedInContext, + warmup, + shot, + writeResult, +} = require('./helpers') + +// The seeded test account's own username ("offline-tester") literally contains +// "offline" and renders on the page regardless of connectivity (header/sidebar/hover +// cards showing the signed-in user's name) — exclude an exact match on it so it can't +// masquerade as the real connectivity indicator below. +const USERNAME = EMAIL.split('@')[0] + +async function findOfflineIndicator(page, excludeExact) { + return page.evaluate((exclude) => { + const re = /offline|you.?re offline|showing saved|no connection|reconnect/i + const candidates = [] + const all = document.querySelectorAll('body *') + for (const el of all) { + // Only leaf-ish elements with direct text, to avoid matching giant containers. + const text = el.textContent?.trim() || '' + if (!text || text.length > 200) continue + if (re.test(text)) { + const ownText = Array.from(el.childNodes) + .filter((n) => n.nodeType === Node.TEXT_NODE) + .map((n) => n.textContent) + .join('') + .trim() + if (ownText && re.test(ownText) && ownText.toLowerCase() !== exclude.toLowerCase()) { + const rect = el.getBoundingClientRect() + candidates.push({ + tag: el.tagName, + class: el.className?.toString?.() || '', + text: ownText.slice(0, 200), + visible: rect.width > 0 && rect.height > 0, + role: el.getAttribute('role'), + }) + } + } + } + return candidates + }, excludeExact) +} + +async function run() { + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser) + const result = { story: 'US3', checks: [] } + + try { + result.warmup = await warmup(page) + + // Baseline (online): no offline indicator should be present. + const onlineCandidates = await findOfflineIndicator(page, USERNAME) + result.onlineBaseline = { candidates: onlineCandidates } + + await context.setOffline(true) + // Give any 'offline'/'online' event listener a moment to react (no reload — + // this exercises the live indicator, not a reload-triggered one). + await page.waitForTimeout(3000) + // Nudge in case the app only reacts to navigation/focus rather than the + // browser 'offline' event. + await page.evaluate(() => window.dispatchEvent(new Event('offline'))) + await page.waitForTimeout(1000) + + const offlineCandidates = await findOfflineIndicator(page, USERNAME) + const offlineShot = await shot(page, 'us3-offline-indicator-search') + const visibleOfflineCandidates = offlineCandidates.filter((c) => c.visible) + + let check1 = { + name: 'indicator appears when offline', + candidates: offlineCandidates, + screenshot: offlineShot, + pass: visibleOfflineCandidates.length > 0, + } + check1.symptom = check1.pass + ? `found ${visibleOfflineCandidates.length} visible offline-related element(s)` + : 'no offline indicator UI exists anywhere in the DOM (searched all elements for /offline|reconnect|no connection/i text)' + result.checks.push(check1) + + // Go back online and check the indicator clears (only meaningful if one appeared). + await context.setOffline(false) + await page.evaluate(() => window.dispatchEvent(new Event('online'))) + await page.waitForTimeout(3000) + const afterOnlineCandidates = await findOfflineIndicator(page, USERNAME) + const stillVisible = afterOnlineCandidates.filter((c) => c.visible) + const afterOnlineShot = await shot(page, 'us3-after-reconnect') + + let check2 = { + name: 'indicator clears on reconnect', + candidates: afterOnlineCandidates, + screenshot: afterOnlineShot, + // Only a meaningful pass if an indicator existed in the first place. + pass: check1.pass ? stillVisible.length === 0 : null, + } + check2.symptom = !check1.pass + ? 'n/a — no indicator existed to clear' + : check2.pass + ? 'indicator cleared after reconnect' + : 'indicator still visible after going back online' + result.checks.push(check2) + + result.pass = check1.pass && (check2.pass === null || check2.pass === true) + } catch (e) { + result.pass = false + result.fatalError = String(e) + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + await context.setOffline(false).catch(() => {}) + await browser.close() + } + + writeResult('us3', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } diff --git a/frontend/tests/offline/us4.js b/frontend/tests/offline/us4.js new file mode 100644 index 000000000..44db5060c --- /dev/null +++ b/frontend/tests/offline/us4.js @@ -0,0 +1,214 @@ +// US4 — Don't lose my words: offline comment submit should fail gracefully (error +// surfaced, text preserved in the editor), then succeed once back online. +const { + chromium, + URLS, + newLoggedInContext, + newApiRequestContext, + warmup, + shot, + writeResult, +} = require('./helpers') + +const MARKER = `offline-us4-${Date.now()}` +const DISTINCTIVE_TEXT = `Testing offline comment preservation ${MARKER}` + +async function openComposer(page) { + const addCommentBtn = page.locator('button:has-text("Add a comment")').first() + if (await addCommentBtn.isVisible().catch(() => false)) { + await addCommentBtn.click() + await page.waitForTimeout(300) + } +} + +async function getEditor(page) { + // The comment composer's ProseMirror/TipTap root; last contenteditable on the + // page corresponds to the new-comment box (existing comments render read-only). + return page.locator('[contenteditable="true"]').last() +} + +async function getVisibleSubmitButton(page) { + // The composer renders both a Comment submit and a (v-show hidden) Poll submit + // button with the same text; `:visible` filters out the inactive tab's button. + return page.locator('button:has-text("Submit"):visible').last() +} + +async function bodyTextSnapshot(page) { + try { + return await page.evaluate(() => document.body.innerText) + } catch { + return '' + } +} + +function findNewErrorText(before, after) { + const re = /(fail|error|offline|network|could not|try again|unable|no internet|not connected)/i + const beforeLines = new Set( + before + .split('\n') + .map((l) => l.trim()) + .filter(Boolean), + ) + const afterLines = after + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + return afterLines.filter((l) => re.test(l) && !beforeLines.has(l)) +} + +async function run() { + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser) + const result = { story: 'US4', checks: [] } + let createdCommentName = null + + try { + result.warmup = await warmup(page) + await page.goto(URLS.discussion, { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(1500) + await openComposer(page) + + await context.setOffline(true) + + const editor = await getEditor(page) + let check1 = { name: 'offline submit fails gracefully, text preserved' } + try { + await editor.click({ timeout: 8000 }) + await page.keyboard.type(DISTINCTIVE_TEXT, { delay: 10 }) + await page.waitForTimeout(300) + + const beforeSubmitText = await bodyTextSnapshot(page) + const submitBtn = await getVisibleSubmitButton(page) + await submitBtn.click({ timeout: 8000 }).catch((e) => { + check1.clickError = String(e) + }) + await page.waitForTimeout(2000) + const afterSubmitText = await bodyTextSnapshot(page) + + const newErrorLines = findNewErrorText(beforeSubmitText, afterSubmitText) + const editorTextAfter = await editor.innerText().catch(() => '') + const textPreserved = editorTextAfter.includes(DISTINCTIVE_TEXT) + + check1.newErrorLines = newErrorLines + check1.textPreserved = textPreserved + check1.editorTextAfter = editorTextAfter.slice(0, 300) + check1.screenshot = await shot(page, 'us4-offline-submit-attempt') + check1.pass = newErrorLines.length > 0 && textPreserved + check1.symptom = check1.pass + ? `error shown (${newErrorLines[0]}) and text preserved` + : !textPreserved + ? 'typed text was lost from the editor after failed submit (silent data loss)' + : 'no visible error/toast surfaced after offline submit attempt' + } catch (e) { + check1.pass = false + check1.symptom = `threw: ${e.message}` + check1.screenshot = await shot(page, 'us4-offline-submit-error') + } + result.checks.push(check1) + + // Now go online and resubmit — should succeed. + let check2 = { name: 'submit succeeds after reconnect' } + try { + await context.setOffline(false) + await page.waitForTimeout(1500) + + // The offline-failure toast from check1 is a real sonner Toast whose + // auto-dismiss timer is paused while `document.visibilityState` isn't + // "visible" (vue-sonner's `isDocumentHidden` gate) — headless Chromium + // pages can sit in that state indefinitely, so the toast lingers over + // the Submit button far longer than the 4s TOAST_LIFETIME a real, + // focused tab would give it. Dismiss it explicitly (same action a real + // user would take) rather than waiting on a timer that may never fire + // in this environment. + const closeButtons = page.locator('[data-close-button="true"]') + const closeCount = await closeButtons.count().catch(() => 0) + for (let i = 0; i < closeCount; i++) { + await closeButtons + .first() + .click({ timeout: 2000 }) + .catch(() => {}) + } + await page.waitForTimeout(300) + + const submitBtn = await getVisibleSubmitButton(page) + const editorNow = await getEditor(page) + const editorTextNow = await editorNow.innerText().catch(() => '') + check2.editorTextBeforeRetry = editorTextNow.slice(0, 300) + + if (!editorTextNow.includes(DISTINCTIVE_TEXT)) { + check2.pass = false + check2.symptom = + 'skipped: distinctive text was not present in editor to resubmit (see check1)' + } else { + await submitBtn.click({ timeout: 8000 }) + await page.waitForTimeout(2500) + const pageText = await bodyTextSnapshot(page) + check2.commentAppeared = pageText.includes(DISTINCTIVE_TEXT) + check2.screenshot = await shot(page, 'us4-online-resubmit') + check2.pass = check2.commentAppeared + check2.symptom = check2.pass + ? 'comment posted successfully after reconnect' + : 'comment did not appear after resubmitting online' + + if (check2.commentAppeared) { + // Find the created comment's name via API for cleanup. + const api = await newApiRequestContext() + const resp = await api.get( + `/api/method/frappe.client.get_list?doctype=GP%20Comment&filters=${encodeURIComponent( + JSON.stringify([ + ['reference_name', '=', '55'], + ['content', 'like', `%${MARKER}%`], + ]), + )}&fields=${encodeURIComponent(JSON.stringify(['name']))}`, + ) + const body = await resp.json().catch(() => null) + createdCommentName = body?.message?.[0]?.name ?? null + await api.dispose() + } + } + } catch (e) { + check2.pass = false + check2.symptom = `threw: ${e.message}` + check2.screenshot = await shot(page, 'us4-online-resubmit-error') + } + result.checks.push(check2) + + result.pass = result.checks.every((c) => c.pass) + } catch (e) { + result.pass = false + result.fatalError = String(e) + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + await context.setOffline(false).catch(() => {}) + await browser.close() + + // Cleanup: delete the comment we created during this test, if any. + if (createdCommentName) { + try { + const api = await newApiRequestContext() + await api.post('/api/method/frappe.client.delete', { + form: { doctype: 'GP Comment', name: String(createdCommentName) }, + }) + await api.dispose() + result.cleanup = `deleted GP Comment ${createdCommentName}` + } catch (e) { + result.cleanup = `FAILED to delete GP Comment ${createdCommentName}: ${e.message}` + } + } else { + result.cleanup = 'no comment created (or name lookup failed) — nothing to delete' + } + } + + writeResult('us4', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } diff --git a/frontend/tests/offline/us5.js b/frontend/tests/offline/us5.js new file mode 100644 index 000000000..0a2a948f2 --- /dev/null +++ b/frontend/tests/offline/us5.js @@ -0,0 +1,143 @@ +// US5 — Seamless recovery: content created elsewhere while we're offline should +// show up automatically after reconnect, without a hard reload. +const { + chromium, + URLS, + newLoggedInContext, + newApiRequestContext, + warmup, + shot, + writeResult, +} = require('./helpers') + +const MARKER = `offline-us5-${Date.now()}` +const DISTINCTIVE_TEXT = `US5 recovery probe comment ${MARKER}` + +async function run() { + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser) + const result = { story: 'US5', checks: [] } + let createdCommentName = null + + try { + result.warmup = await warmup(page) + await page.goto(URLS.discussion, { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(1500) + + await context.setOffline(true) + await page.waitForTimeout(500) + + // Create the comment via a fully separate, still-online APIRequestContext while + // the page/context under test is offline. + const api = await newApiRequestContext() + const insertResp = await api.post('/api/method/frappe.client.insert', { + form: { + doc: JSON.stringify({ + doctype: 'GP Comment', + reference_doctype: 'GP Discussion', + reference_name: '55', + content: `

${DISTINCTIVE_TEXT}

`, + }), + }, + }) + const insertOk = insertResp.ok() + const insertBody = await insertResp.json().catch(() => null) + createdCommentName = insertBody?.message?.name ?? null + result.apiInsert = { ok: insertOk, status: insertResp.status(), name: createdCommentName } + + if (!insertOk || !createdCommentName) { + result.pass = false + result.fatalError = `Failed to create probe comment via API while offline: ${JSON.stringify(insertBody)}` + } else { + // Reconnect. + await context.setOffline(false) + const reconnectAt = Date.now() + + // Nudge the app the ways a real reconnect plausibly would, without navigating: + // browser 'online' event, window focus, and document visibility. + await page.evaluate(() => window.dispatchEvent(new Event('online'))) + await page.bringToFront() + await page.evaluate(() => { + window.dispatchEvent(new Event('focus')) + document.dispatchEvent(new Event('visibilitychange')) + }) + + const deadlineMs = 30000 + const pollIntervalMs = 1000 + let appearedAt = null + let lastText = '' + while (Date.now() - reconnectAt < deadlineMs) { + lastText = await page.evaluate(() => document.body.innerText).catch(() => '') + if (lastText.includes(DISTINCTIVE_TEXT)) { + appearedAt = Date.now() + break + } + await page.waitForTimeout(pollIntervalMs) + } + + const screenshot = await shot(page, 'us5-after-reconnect-wait') + let check = { + name: 'new content appears after reconnect without reload', + waitedMs: appearedAt ? appearedAt - reconnectAt : deadlineMs, + appeared: Boolean(appearedAt), + navigationUsed: false, + screenshot, + lastBodyTextSnippet: lastText.slice(0, 400), + pass: Boolean(appearedAt), + } + check.symptom = check.pass + ? `appeared ${check.waitedMs}ms after reconnect (no reload, only online/focus/visibility events)` + : `did not appear within ${deadlineMs}ms of reconnect without a reload` + result.checks.push(check) + + // Diagnostic-only follow-up: does a client-side navigation/refetch pick it up, + // to distinguish "no revalidation mechanism at all" from "revalidation is slower + // than 30s"? Does not affect the US5 pass/fail above. + if (!check.pass) { + await page.goto(URLS.discussion, { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(1500) + const afterNavText = await page.evaluate(() => document.body.innerText).catch(() => '') + result.diagnosticAfterFullReload = { + appearedAfterReload: afterNavText.includes(DISTINCTIVE_TEXT), + } + } + + result.pass = result.checks.every((c) => c.pass) + } + } catch (e) { + result.pass = false + result.fatalError = String(e) + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + await context.setOffline(false).catch(() => {}) + await browser.close() + + if (createdCommentName) { + try { + const api = await newApiRequestContext() + await api.post('/api/method/frappe.client.delete', { + form: { doctype: 'GP Comment', name: String(createdCommentName) }, + }) + await api.dispose() + result.cleanup = `deleted GP Comment ${createdCommentName}` + } catch (e) { + result.cleanup = `FAILED to delete GP Comment ${createdCommentName}: ${e.message}` + } + } else { + result.cleanup = 'no comment created — nothing to delete' + } + } + + writeResult('us5', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } diff --git a/frontend/tests/offline/us6.js b/frontend/tests/offline/us6.js new file mode 100644 index 000000000..54d05fed7 --- /dev/null +++ b/frontend/tests/offline/us6.js @@ -0,0 +1,111 @@ +// US6 — Honest dead ends: offline navigation to content never visited before should +// show a friendly "can't load this while offline" state with retry — not a blank +// screen, crash, or infinite spinner. +const { + chromium, + URLS, + newLoggedInContext, + warmup, + shot, + innerTextSafe, + appRootInfo, + writeResult, +} = require('./helpers') + +async function looksLikeFriendlyFallback(text) { + return /offline|not available|can.?t load|unable to load|no connection|try again|retry/i.test( + text, + ) +} + +async function checkSpinnerStillSpinning(page) { + // Sample the DOM twice, ~1.5s apart, looking for animate-pulse/animate-spin + // markers that never resolve — a crude "infinite spinner" detector. + const sample = () => + page.evaluate(() => { + const spinners = document.querySelectorAll('.animate-spin, .animate-pulse, [role="status"]') + return spinners.length + }) + const a = await sample() + await page.waitForTimeout(4000) + const b = await sample() + return { first: a, second: b, stillPresent: a > 0 && b > 0 } +} + +async function run() { + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser) + const result = { story: 'US6', checks: [] } + + try { + result.warmup = await warmup(page) + await context.setOffline(true) + + for (const [label, url] of [ + ['never-visited discussion', URLS.uncachedDiscussion], + ['never-visited space', URLS.uncachedSpace], + ]) { + let check = { name: label, url } + const errCountBefore = pageErrors.length + try { + await page.goto(url, { waitUntil: 'load', timeout: 10000 }).catch((e) => { + check.gotoError = String(e) + }) + await page.waitForTimeout(2500) + + const info = await appRootInfo(page) + const text = await innerTextSafe(page) + const spinner = await checkSpinnerStillSpinning(page) + const friendly = await looksLikeFriendlyFallback(text) + const newPageErrors = pageErrors.slice(errCountBefore) + const isBlank = !info.appExists || info.appChildCount === 0 || text.trim().length < 5 + + check.info = info + check.textSnippet = text.slice(0, 400) + check.friendlyFallbackDetected = friendly + check.isBlank = isBlank + check.infiniteSpinner = spinner.stillPresent + check.newPageErrors = newPageErrors + check.screenshot = await shot(page, `us6-${label.replace(/\s+/g, '-')}`) + + check.pass = friendly && !isBlank && !spinner.stillPresent && newPageErrors.length === 0 + check.symptom = check.pass + ? 'friendly offline fallback shown with no crash/spinner' + : isBlank + ? 'blank page (no friendly fallback)' + : spinner.stillPresent + ? 'infinite spinner — content never resolved' + : newPageErrors.length > 0 + ? `uncaught exception(s): ${newPageErrors.join(' | ')}` + : 'no offline/retry/"not available" messaging detected — page rendered something else' + } catch (e) { + check.pass = false + check.symptom = `threw: ${e.message}` + check.screenshot = await shot(page, `us6-${label.replace(/\s+/g, '-')}-error`) + } + result.checks.push(check) + } + + result.pass = result.checks.every((c) => c.pass) + } catch (e) { + result.pass = false + result.fatalError = String(e) + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + await context.setOffline(false).catch(() => {}) + await browser.close() + } + + writeResult('us6', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } diff --git a/frontend/tests/offline/us7a.js b/frontend/tests/offline/us7a.js new file mode 100644 index 000000000..744e95b1a --- /dev/null +++ b/frontend/tests/offline/us7a.js @@ -0,0 +1,203 @@ +// US7a — Shared-computer safety, logout path: after a real UI logout, every trace of the +// logged-out user's offline content should be gone from this browser (frappe-ui's shared +// idb-keyval store, and the service worker's SHELL_CACHE + RUNTIME_CACHE), so the next +// person on this machine can't read it — EXCEPT gameplan-drafts, which the task's Step 0 +// policy adjustment keeps around on a plain logout (same person may log back in and expect +// their in-progress draft still there; useDraftSync already guards reads by `record.user` +// so leaving it isn't a leak). See frontend/src/offline.ts's clearOfflineCaches / +// data/draftStore.ts's clearDraftStore. +const { + chromium, + URLS, + PEOPLE, + EMAIL, + newLoggedInContext, + waitForPrefetchDone, + logoutViaUI, + idbKeyvalKeys, + draftStoreKeys, + cacheStorageNames, + shot, + writeResult, +} = require('./helpers') + +const MEMBER = PEOPLE.visitedFully // 'maya-iyer' +const DRAFT_MARKER = `us7a-draft-${Date.now()}` + +async function openComposer(page) { + const addCommentBtn = page.locator('button:has-text("Add a comment")').first() + if (await addCommentBtn.isVisible().catch(() => false)) { + await addCommentBtn.click() + await page.waitForTimeout(300) + } +} + +async function run() { + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors, prefetchLog } = + await newLoggedInContext(browser) + const result = { story: 'US7a', checks: [] } + + try { + // Warm caches: People, a profile, and a discussion, plus give the background + // prefetcher (data/offlinePrefetch.ts) and the SW's warmLoadedAssets a chance to run, + // same as P1/P2's warmup pattern. + await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 }) + // `/g` client-side redirects to the community's discussions route once the app has + // hydrated (post-327d7ae3, that redirect waits on cache hydration, so it isn't + // always instant) — let it land before the next goto(), or that next navigation can + // get reported as "interrupted by another navigation" to the redirect's target. + await page.waitForURL(/\/g\/community\//, { timeout: 10000 }).catch(() => {}) + await page.waitForTimeout(1000) + await page.goto(URLS.people, { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(1000) + await page.goto(URLS.person(MEMBER), { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(1500) + await page.goto(URLS.discussion, { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(1500) + + try { + await page.evaluate(() => navigator.serviceWorker.ready.then(() => true)) + } catch (e) { + // best-effort + } + const prefetch = await waitForPrefetchDone(prefetchLog, { timeoutMs: 45000 }) + result.prefetch = prefetch + await page.waitForTimeout(2000) // warmLoadedAssets() follow-up timer + + // Create a draft (comment composer, not submitted) so we can assert it survives a + // plain logout below — the Step 0 policy this story is specifically verifying. + let draftCreated = false + try { + await openComposer(page) + const editor = page.locator('[contenteditable="true"]').last() + await editor.click({ timeout: 5000 }) + await page.keyboard.type(DRAFT_MARKER, { delay: 10 }) + await page.waitForTimeout(500) + draftCreated = true + } catch (e) { + result.draftCreateError = String(e) + } + + result.preLogoutKeyvalKeys = await idbKeyvalKeys(page) + result.preLogoutDraftKeys = draftCreated ? await draftStoreKeys(page) : null + result.preLogoutCaches = await cacheStorageNames(page) + + result.checks.push({ + name: 'IndexedDB (keyval-store) has data before logout', + pass: result.preLogoutKeyvalKeys.length > 0, + symptom: + result.preLogoutKeyvalKeys.length > 0 + ? `${result.preLogoutKeyvalKeys.length} key(s) present pre-logout` + : 'keyval-store empty before logout — warmup did not populate any cache, test setup problem', + }) + + if (draftCreated) { + result.checks.push({ + name: 'draft store has the new draft before logout', + pass: result.preLogoutDraftKeys.length > 0, + symptom: + result.preLogoutDraftKeys.length > 0 + ? `${result.preLogoutDraftKeys.length} draft record(s) present pre-logout` + : 'gameplan-drafts empty even after typing a comment — draft never persisted locally', + }) + } else { + result.checks.push({ + name: 'draft store has the new draft before logout', + pass: null, + symptom: `skipped — could not create a draft: ${result.draftCreateError}`, + }) + } + + // Log out via the real UI path (UserDropdown.vue -> session.logout.submit()), so + // this exercises the actual clearOfflineCaches side effect, not a bypass. + let checkLogout = { name: 'logout via UI redirects to /login' } + try { + await logoutViaUI(page) + await page.waitForURL('**/login**', { timeout: 10000 }) + checkLogout.pass = true + checkLogout.symptom = `redirected to ${page.url()}` + } catch (e) { + checkLogout.pass = false + checkLogout.symptom = `threw: ${e.message}` + checkLogout.screenshot = await shot(page, 'us7a-logout-error') + } + result.checks.push(checkLogout) + + if (checkLogout.pass) { + // Give clearOfflineCaches's async work (awaited by session.ts before the redirect, + // but the redirect itself tears down the page — read state on the /login page + // that's already loaded, plus a short grace window for the SW round-trip) a beat. + await page.waitForTimeout(1000) + + const postLogoutKeyvalKeys = await idbKeyvalKeys(page) + const postLogoutDraftKeys = await draftStoreKeys(page) + const postLogoutCaches = await cacheStorageNames(page) + result.postLogoutKeyvalKeys = postLogoutKeyvalKeys + result.postLogoutDraftKeys = postLogoutDraftKeys + result.postLogoutCaches = postLogoutCaches + + result.checks.push({ + name: 'IndexedDB (keyval-store) empty after logout', + pass: postLogoutKeyvalKeys.length === 0, + symptom: + postLogoutKeyvalKeys.length === 0 + ? 'keyval-store fully cleared' + : `${postLogoutKeyvalKeys.length} key(s) still present after logout: ${JSON.stringify(postLogoutKeyvalKeys.slice(0, 10))}`, + }) + + const leftoverShellOrRuntime = postLogoutCaches.filter( + (name) => name.includes(':shell') || name.includes(':runtime'), + ) + const assetCachesRemain = postLogoutCaches.some((name) => name.includes(':assets')) + result.checks.push({ + name: 'Cache Storage: shell/runtime caches gone, assets cache may remain', + pass: leftoverShellOrRuntime.length === 0, + symptom: + leftoverShellOrRuntime.length === 0 + ? `shell/runtime caches cleared (remaining: ${JSON.stringify(postLogoutCaches)}, assets present=${assetCachesRemain})` + : `shell/runtime cache(s) still present after logout: ${JSON.stringify(leftoverShellOrRuntime)}`, + }) + + if (draftCreated) { + result.checks.push({ + name: 'gameplan-drafts NOT cleared on plain logout (Step 0 policy)', + pass: postLogoutDraftKeys.length > 0, + symptom: + postLogoutDraftKeys.length > 0 + ? `${postLogoutDraftKeys.length} draft record(s) survived logout, as intended` + : 'draft store was wiped on plain logout — Step 0 policy regression (drafts should only clear on a detected user switch)', + }) + } else { + result.checks.push({ + name: 'gameplan-drafts NOT cleared on plain logout (Step 0 policy)', + pass: null, + symptom: 'skipped — no draft was created pre-logout to check', + }) + } + } + + // Overall pass ignores `pass: null` (skipped) checks. + result.pass = result.checks.every((c) => c.pass !== false) + } catch (e) { + result.pass = false + result.fatalError = String(e) + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + await context.setOffline(false).catch(() => {}) + await browser.close() + } + + writeResult('us7a', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } diff --git a/frontend/tests/offline/us7b.js b/frontend/tests/offline/us7b.js new file mode 100644 index 000000000..5b84a85e3 --- /dev/null +++ b/frontend/tests/offline/us7b.js @@ -0,0 +1,214 @@ +// US7b — Shared-computer safety, user-switch path: a SECOND person logging in on the +// same browser right after the first must not be able to read anything the first user's +// session left behind. Continues in the SAME browser context as a fresh login (not a new +// context) — that's what actually exercises guardAgainstUserSwitch (frontend/src/offline.ts), +// which compares the incoming user_id cookie against localStorage's +// 'gameplan:last-seen-user' and clears every offline cache (+ drafts — see US7a's Step 0 +// note) the moment it detects a mismatch. +const { + chromium, + URLS, + PEOPLE, + EMAIL, + EMAIL2, + PWD2, + newLoggedInContext, + loginAsInSameContext, + waitForPrefetchDone, + idbKeyvalKeys, + draftStoreKeys, + lastSeenUserFromStorage, + innerTextSafe, + shot, + writeResult, +} = require('./helpers') + +// A profile user A visited and cached; used to check user B can't read it offline. +const MEMBER = PEOPLE.visitedFully // 'maya-iyer' + +async function run() { + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors, prefetchLog } = + await newLoggedInContext(browser) + const result = { story: 'US7b', checks: [] } + + try { + // --- User A: warm real caches, including the People page and MEMBER's profile. --- + await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 }) + // See us7a.js/loginAsInSameContext's identical comment: let /g's client-side + // redirect to the community's discussions route land before the next goto(), or it + // can get reported as "interrupted by another navigation". + await page.waitForURL(/\/g\/community\//, { timeout: 10000 }).catch(() => {}) + await page.waitForTimeout(1000) + await page.goto(URLS.people, { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(1000) + await page.goto(URLS.person(MEMBER), { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(1500) + try { + await page.evaluate(() => navigator.serviceWorker.ready.then(() => true)) + } catch (e) { + // best-effort + } + await waitForPrefetchDone(prefetchLog, { timeoutMs: 20000 }) // best-effort, not required to pass + + const userAKeyvalKeys = await idbKeyvalKeys(page) + const userALastSeen = await lastSeenUserFromStorage(page) + result.userAKeyvalCount = userAKeyvalKeys.length + result.userALastSeen = userALastSeen + + result.checks.push({ + name: 'user A caches populated, last-seen-user recorded as A', + pass: userAKeyvalKeys.length > 0 && userALastSeen === EMAIL, + symptom: `keyvalKeys=${userAKeyvalKeys.length} lastSeen=${userALastSeen}`, + }) + + // --- Switch: log in as user B in the SAME browser context, no explicit logout first + // (worst case for a shared computer — someone just switches accounts). guardAgainstUserSwitch + // runs at module boot on the next navigation, so loginAsInSameContext already does the + // follow-up page.goto(feed) that triggers it. --- + await loginAsInSameContext(context, page, EMAIL2, PWD2) + await page.waitForTimeout(1500) // let the async clearOfflineCaches()/clearDraftStore() settle + + const userBLastSeen = await lastSeenUserFromStorage(page) + const postSwitchKeyvalKeys = await idbKeyvalKeys(page) + // Any key still containing A's identity is the actual leak signal. Keys stamped with + // B's own identity are expected here — the app's own useCall/useList singletons start + // re-fetching for B the instant the post-switch reload boots, racing (and normally + // winning shortly after) the async clear, and correctly write back under B's own + // cacheKey. That's not a leak; it's B's data, scoped to B. + const leakedAKeys = postSwitchKeyvalKeys.filter((k) => k.includes(EMAIL)) + result.userBLastSeen = userBLastSeen + result.postSwitchKeyvalKeys = postSwitchKeyvalKeys + result.leakedAKeys = leakedAKeys + + result.checks.push({ + name: 'localStorage last-seen-user updated to B after switch', + pass: userBLastSeen === EMAIL2, + symptom: `lastSeen=${userBLastSeen} (expected ${EMAIL2})`, + }) + + result.checks.push({ + name: "user-switch clear ran: no key tagged with A's identity survives the switch", + pass: leakedAKeys.length === 0, + symptom: + leakedAKeys.length === 0 + ? `A's keys cleared on detected user switch (${postSwitchKeyvalKeys.length} key(s) remain, all B's own: ${JSON.stringify(postSwitchKeyvalKeys)})` + : `${leakedAKeys.length} key(s) still tagged with A's identity after switch: ${JSON.stringify(leakedAKeys)}`, + }) + + // Diagnostic only (not gating US7b's pass/fail, which is about cross-user leakage, + // not general offline-shell availability — see the note below and results-round4.md): + // CLEAR_USER_CACHES also deletes SHELL_CACHE (gameplan-sw.js's clearUserCaches), and + // nothing repopulates it until the NEXT successful online navigation. The online + // reload above that triggered this switch detection did write a fresh shell via + // networkFirstNavigation - but the switch-clear (fired from this same page's JS, + // shortly after) deletes it again moments later, with no further online navigation + // in between. Verified independently (ad hoc script) that this leaves the browser + // with an empty SHELL_CACHE indefinitely - even a plain reload of /g itself then + // fails with net::ERR_FAILED while offline, not the app's own offline UI. This is a + // real app-side gap (not present before this round's user-switch clearing feature), + // not typo-level, so it is reported here as evidence rather than fixed. + const postSwitchCaches = await page.evaluate(() => caches.keys()) + result.postSwitchCaches = postSwitchCaches + result.checks.push({ + name: '[diagnostic, non-gating] SHELL_CACHE survives the user-switch clear', + pass: null, + symptom: postSwitchCaches.some((n) => n.includes(':shell')) + ? 'shell cache present after switch' + : `SHELL_CACHE absent after switch clear (caches now: ${JSON.stringify(postSwitchCaches)}) - a reload while offline from here fails with net::ERR_FAILED instead of the app's offline UI; see results-round4.md`, + }) + + // --- Now go offline immediately, before B's own prefetch/browsing could plausibly + // have cached anything of A's, and check B truly can't see A's content. --- + await context.setOffline(true) + + let checkPeople = { name: "People page offline as B: does not show A's cached member list" } + try { + await page.goto(URLS.people, { waitUntil: 'load', timeout: 10000 }).catch((e) => { + checkPeople.gotoError = String(e) + }) + await page.waitForTimeout(2000) + const text = await innerTextSafe(page) + const honestFallback = /can.?t load|not available while offline|offline/i.test(text) + // The only way this could wrongly "succeed" is if A's member list actually rendered + // (a real leak) — a large member count with no fallback messaging is the signature. + const rendersMemberList = /\d+\s+members?/i.test(text) && !/^0\s+members/i.test(text.trim()) + const leaked = rendersMemberList && !honestFallback + + checkPeople.textSnippet = text.slice(0, 400) + checkPeople.honestFallback = honestFallback + checkPeople.rendersMemberList = rendersMemberList + checkPeople.screenshot = await shot(page, 'us7b-people-offline-userB') + checkPeople.pass = !leaked + checkPeople.symptom = checkPeople.pass + ? honestFallback + ? 'honest offline fallback shown (no leaked member list)' + : 'no member list rendered (empty/loading state, not a leak)' + : "user A's cached member list rendered for user B offline — cross-user cache leak" + } catch (e) { + checkPeople.pass = false + checkPeople.symptom = `threw: ${e.message}` + checkPeople.screenshot = await shot(page, 'us7b-people-offline-userB-error') + } + result.checks.push(checkPeople) + + let checkProfile = { + name: `profile (${MEMBER}) offline as B: does not show A's cached profile`, + } + try { + await page.goto(URLS.person(MEMBER), { waitUntil: 'load', timeout: 10000 }).catch((e) => { + checkProfile.gotoError = String(e) + }) + await page.waitForTimeout(2500) + const text = await innerTextSafe(page) + const isNotFound = /page not found/i.test(text) + const honestFallback = + /can.?t load this (profile )?while offline|isn.?t available offline/i.test(text) + const hasCardContent = await page.locator('[data-profile-card-wrapper="true"]').count() + // Real leak signature: A's bento/profile card content actually rendered for B. + const leaked = hasCardContent > 0 && !honestFallback + + checkProfile.textSnippet = text.slice(0, 400) + checkProfile.isNotFound = isNotFound + checkProfile.honestFallback = honestFallback + checkProfile.hasCardContent = hasCardContent > 0 + checkProfile.screenshot = await shot(page, 'us7b-profile-offline-userB') + // Honest fallback, an empty/not-found state, or a stuck skeleton are all acceptable + // ("honest fallback/empty is correct" per the task brief) — only a rendered card with + // A's content and no fallback messaging is a fail. + checkProfile.pass = !leaked + checkProfile.symptom = checkProfile.pass + ? honestFallback + ? 'honest "can\'t load this profile while offline" fallback shown' + : 'no profile content rendered for B (not-found/empty/skeleton — not a leak)' + : "user A's cached profile content rendered for user B offline — cross-user cache leak" + } catch (e) { + checkProfile.pass = false + checkProfile.symptom = `threw: ${e.message}` + checkProfile.screenshot = await shot(page, 'us7b-profile-offline-userB-error') + } + result.checks.push(checkProfile) + + result.pass = result.checks.every((c) => c.pass !== false) + } catch (e) { + result.pass = false + result.fatalError = String(e) + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + await context.setOffline(false).catch(() => {}) + await browser.close() + } + + writeResult('us7b', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } diff --git a/frontend/tests/offline/us8.js b/frontend/tests/offline/us8.js new file mode 100644 index 000000000..06accdd8e --- /dev/null +++ b/frontend/tests/offline/us8.js @@ -0,0 +1,206 @@ +// US8 — Update flow: when a new service worker build lands while the app is open, the +// user should see a "new version available" toast (not a silent swap, and not a forced +// reload out from under them) with a Refresh action; clicking it should activate the new +// worker and reload exactly once (frontend/src/offline.ts's watchForUpdates/ +// notifyUpdateAvailable/watchForControllerChange, gameplan-sw.js's install handler +// deliberately not calling self.skipWaiting() so an update sits in `waiting` until the +// user confirms). +// +// This test mutates the real gameplan-sw.js on disk (bumps CACHE_VERSION to the next +// integer, e.g. v7->v8), rebuilds, and reverts + rebuilds again in a `finally` — +// regardless of pass/fail — so it leaves the repo and the running dev server exactly as +// it found them for every other story in the suite. The version is read from the file +// rather than hardcoded so this doesn't drift out of sync with whatever CACHE_VERSION is +// actually committed. +const fs = require('fs') +const path = require('path') +const { execSync } = require('child_process') +const { chromium, URLS, newLoggedInContext, shot, writeResult } = require('./helpers') +const { APP_DIR } = require('./config') + +const SW_FILE = path.join(APP_DIR, 'gameplan/www/gameplan-sw.js') + +function readCurrentVersion() { + const original = fs.readFileSync(SW_FILE, 'utf8') + const m = original.match(/const CACHE_VERSION = "(v\d+)";/) + if (!m) { + throw new Error(`could not find a CACHE_VERSION line in ${SW_FILE} — file has drifted`) + } + return m[1] +} + +function nextVersion(version) { + const n = parseInt(version.slice(1), 10) + return `v${n + 1}` +} + +function bumpVersion(from, to) { + const original = fs.readFileSync(SW_FILE, 'utf8') + const marker = `const CACHE_VERSION = "${from}";` + if (!original.includes(marker)) { + throw new Error(`expected to find ${JSON.stringify(marker)} in ${SW_FILE} — file has drifted`) + } + fs.writeFileSync(SW_FILE, original.replace(marker, `const CACHE_VERSION = "${to}";`)) +} + +function build() { + execSync('yarn build', { cwd: APP_DIR, stdio: 'pipe', timeout: 120000 }) +} + +async function run() { + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser) + const result = { story: 'US8', checks: [] } + let bumped = false + const FROM_VERSION = readCurrentVersion() + const TO_VERSION = nextVersion(FROM_VERSION) + + try { + // --- Baseline: the committed worker version active, controlling the page. --- + await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(1000) + await page.evaluate(() => navigator.serviceWorker.ready.then(() => true)).catch(() => {}) + await page.waitForTimeout(4000) // warmLoadedAssets() follow-up timer + + const baselineController = await page.evaluate(() => ({ + scriptURL: navigator.serviceWorker.controller?.scriptURL ?? null, + state: navigator.serviceWorker.controller?.state ?? null, + })) + const baselineCaches = await page.evaluate(() => caches.keys()) + result.baselineController = baselineController + result.baselineCaches = baselineCaches + result.checks.push({ + name: `baseline: ${FROM_VERSION} worker controls the page before the update`, + pass: + Boolean(baselineController.scriptURL) && + baselineCaches.some((n) => n.includes(`:${FROM_VERSION}:`)), + symptom: `controller=${JSON.stringify(baselineController)} caches=${JSON.stringify(baselineCaches)}`, + }) + + // --- Ship a new SW build (bump CACHE_VERSION by one). --- + bumpVersion(FROM_VERSION, TO_VERSION) + bumped = true + build() + + // --- Reload: the browser fetches the new SW bytes, byte-diffs, and (since install() + // doesn't call skipWaiting()) parks the new worker in `waiting` rather than swapping + // the controller. Force an explicit update() check too, rather than relying solely on + // the browser's own navigation-triggered check, for determinism. --- + await page.reload({ waitUntil: 'load', timeout: 15000 }) + await page.evaluate(async () => { + const reg = await navigator.serviceWorker.getRegistration('/g') + await reg?.update() + }) + + let checkToast = { name: 'update toast appears with a Refresh action' } + try { + const toastText = page.locator('text=/A new version of Gameplan is available/i') + await toastText.waitFor({ state: 'visible', timeout: 30000 }) + const refreshBtn = page.getByRole('button', { name: 'Refresh' }) + const refreshVisible = await refreshBtn.isVisible().catch(() => false) + + checkToast.toastVisible = true + checkToast.refreshVisible = refreshVisible + checkToast.screenshot = await shot(page, 'us8-update-toast') + checkToast.pass = refreshVisible + checkToast.symptom = refreshVisible + ? 'toast shown with visible Refresh action' + : 'toast text found but no visible "Refresh" button' + } catch (e) { + checkToast.pass = false + checkToast.symptom = `threw/timed out: ${e.message}` + checkToast.screenshot = await shot(page, 'us8-update-toast-missing') + } + result.checks.push(checkToast) + + if (checkToast.pass) { + // --- Click Refresh: should postMessage SKIP_WAITING, the new worker activates, + // fires `controllerchange`, and offline.ts's watchForControllerChange reloads the + // page exactly once (guarded by a once-flag against a reload loop). --- + let loadCount = 0 + page.on('load', () => { + loadCount += 1 + }) + + let checkRefresh = { + name: `clicking Refresh reloads exactly once and ${TO_VERSION} takes control`, + } + try { + // Wait for the actual reload's `load` event (deterministic) rather than a fixed + // sleep — the click -> postMessage -> activate -> controllerchange -> reload + // chain has no fixed duration, so a blind timeout is either flaky (too short) or + // slow (too generous). Race the click against the reload so a `load` that fires + // mid-click isn't missed. + await Promise.all([ + page.waitForEvent('load', { timeout: 15000 }), + page.getByRole('button', { name: 'Refresh' }).click({ timeout: 8000 }), + ]) + // Let the reloaded page's own JS (offline.ts re-registering, SW settling) finish + // booting before reading its state. + await page.waitForTimeout(1500) + + const afterController = await page.evaluate(() => ({ + scriptURL: navigator.serviceWorker.controller?.scriptURL ?? null, + state: navigator.serviceWorker.controller?.state ?? null, + })) + const afterCaches = await page.evaluate(() => caches.keys()) + + checkRefresh.loadCount = loadCount + checkRefresh.afterController = afterController + checkRefresh.afterCaches = afterCaches + const controllerActivated = afterController.state === 'activated' + const newCachesPresent = afterCaches.some((n) => n.includes(`:${TO_VERSION}:`)) + const oldCachesGone = !afterCaches.some((n) => n.includes(`:${FROM_VERSION}:`)) + // Exactly one reload from the Refresh click. `load` also already fired once for + // this listener's own attachment point (the page we're on when we attach), so we + // count loads strictly AFTER attaching — loadCount should be exactly 1. + checkRefresh.pass = + loadCount === 1 && controllerActivated && newCachesPresent && oldCachesGone + checkRefresh.symptom = checkRefresh.pass + ? `single reload (loadCount=1), ${TO_VERSION} worker activated and controlling, old ${FROM_VERSION} caches evicted` + : `loadCount=${loadCount} (expected 1), controllerActivated=${controllerActivated}, newCachesPresent=${newCachesPresent}, oldCachesGone=${oldCachesGone} — caches=${JSON.stringify(afterCaches)}` + } catch (e) { + checkRefresh.pass = false + checkRefresh.symptom = `threw: ${e.message}` + checkRefresh.screenshot = await shot(page, 'us8-refresh-error') + } + result.checks.push(checkRefresh) + } + + result.pass = result.checks.every((c) => c.pass !== false) + } catch (e) { + result.pass = false + result.fatalError = String(e) + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + await context.setOffline(false).catch(() => {}) + await browser.close() + + // Always restore the original version + rebuild, regardless of outcome, so the rest + // of the suite (and the repo state for the eventual commit) is unaffected by this + // story having run. + if (bumped) { + try { + bumpVersion(TO_VERSION, FROM_VERSION) + build() + result.reverted = true + } catch (e) { + result.revertError = String(e) + result.pass = false + } + } + } + + writeResult('us8', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 64df89b1a..8f98dddb4 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2605,6 +2605,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" @@ -3796,6 +3801,20 @@ pkg-types@^2.1.0, pkg-types@^2.3.0: exsolve "^1.0.8" pathe "^2.0.3" +playwright-core@1.62.1: + version "1.62.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.62.1.tgz#120f67a19181bfd183c60fa903c0d99330b56785" + integrity sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw== + +playwright@^1.62.1: + version "1.62.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.62.1.tgz#8447b6755e8aec85a3cb7207c823e3ed2fc66700" + integrity sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg== + dependencies: + playwright-core "1.62.1" + optionalDependencies: + fsevents "2.3.2" + postcss-import@^15.1.0: version "15.1.0" resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70" From 940cc70ee3df544cfde323a9158cd776e7b27c25 Mon Sep 17 00:00:00 2001 From: ebrahimgamdiwala Date: Mon, 7 Sep 2026 06:59:16 +0000 Subject: [PATCH 11/68] fix(frontend): await the user-switch cache clear before redirecting guardAgainstUserSwitch used to kick off clearOfflineCaches() in a fire-and-forget Promise.all().then() and return synchronously. Two consequences, both flagged by review (PR #516, round 4): - session.ts's login handler hard-navigates the instant it sees `true` back from the guard, which could tear the page down mid-clear. - The marker (localStorage's last-seen-user) was written unconditionally, so a switch that got cut off still looked "handled" on the next boot - a shared browser could keep serving the previous user's SHELL_CACHE. guardAgainstUserSwitch is now async: the clear (and the marker write, which now happens only after the clear settles) are awaited, and session.ts's login handler awaits the guard before its hard-navigate. Also hardens clearServiceWorkerCaches' worker lookup: guardAgainstUserSwitch runs before this module's own service worker registration, so a plain getRegistration() could legitimately find nothing yet even though an earlier browser session's worker (and its stale SHELL_CACHE) is still around. It now falls back to a bounded wait on navigator.serviceWorker.ready when a worker is expected to exist, instead of treating "not registered on this page load yet" as "nothing to clear". Co-Authored-By: Claude Sonnet 5 --- frontend/src/data/session.ts | 11 ++++- frontend/src/offline.ts | 91 ++++++++++++++++++++++++++++-------- 2 files changed, 81 insertions(+), 21 deletions(-) diff --git a/frontend/src/data/session.ts b/frontend/src/data/session.ts index 43a0745d1..24e23b9b2 100644 --- a/frontend/src/data/session.ts +++ b/frontend/src/data/session.ts @@ -22,7 +22,7 @@ export let session = reactive({ login: useCall({ url: '/api/v2/method/login', immediate: false, - onSuccess(data) { + async onSuccess(data) { users.reload() sessionUser.value = getSessionUserFromCookie() session.login.reset() @@ -32,7 +32,14 @@ export let session = reactive({ // them. A plain router.replace would keep those singletons around, so force a full // reload once a switch is detected and let the app rebuild everything fresh for // the new user (same reasoning as DevUserSwitcher.vue's own hard reload). - if (guardAgainstUserSwitch(sessionUser.value)) { + // + // Awaited (PR #516 review round 4 finding): guardAgainstUserSwitch's cache clear + // used to be fire-and-forget, so this redirect could tear the page down before the + // previous user's SHELL_CACHE/IndexedDB were actually wiped - the switch marker + // would already say "handled" while the old data was still sitting there for the + // next offline load to serve. Waiting here means the hard-navigate below only + // happens once the clear has actually settled. + if (await guardAgainstUserSwitch(sessionUser.value)) { window.location.href = data.default_route || '/' } else { router.replace(data.default_route || '/') diff --git a/frontend/src/offline.ts b/frontend/src/offline.ts index 1e659ddc2..a14ff1c52 100644 --- a/frontend/src/offline.ts +++ b/frontend/src/offline.ts @@ -14,15 +14,15 @@ const LAST_SEEN_USER_STORAGE_KEY = 'gameplan:last-seen-user' export function setupOfflineSupport() { // Runs even in dev / non-secure contexts (unlike SW registration below): this is what // makes the dev user switcher (DevUserSwitcher.vue) safe to test with, and it's cheap - // enough to always run at boot. - guardAgainstUserSwitch(getSessionUserFromCookie()) - - if ( - import.meta.env.DEV || - typeof navigator === 'undefined' || - !window.isSecureContext || - !('serviceWorker' in navigator) - ) { + // enough to always run at boot. Fire-and-forget here specifically - nothing after this + // call in the boot sequence depends on the switch being fully resolved, unlike + // session.ts's login handler, which awaits guardAgainstUserSwitch directly because it + // hard-navigates right after. + guardAgainstUserSwitch(getSessionUserFromCookie()).catch((error) => + console.error('Failed to run user-switch guard', error), + ) + + if (!serviceWorkerSupportEnabled()) { return } @@ -49,6 +49,20 @@ export function setupOfflineSupport() { watchForControllerChange() } +// Shared by setupOfflineSupport's own registration gate and clearServiceWorkerCaches' +// decision whether it's worth waiting for a registration to appear (see getActiveWorker) - +// in every case this returns false, no worker will ever be registered for this tab, so +// there's nothing to wait for. +function serviceWorkerSupportEnabled(): boolean { + return ( + !import.meta.env.DEV && + typeof navigator !== 'undefined' && + typeof window !== 'undefined' && + window.isSecureContext && + 'serviceWorker' in navigator + ) +} + export function isBrowserOffline() { return typeof navigator !== 'undefined' && navigator.onLine === false } @@ -90,8 +104,7 @@ export async function clearOfflineCaches(): Promise { async function clearServiceWorkerCaches(): Promise { if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return - const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE) - const activeWorker = registration?.active + const activeWorker = await getActiveWorker() if (!activeWorker) return await new Promise((resolve) => { @@ -106,6 +119,32 @@ async function clearServiceWorkerCaches(): Promise { }) } +const REGISTRATION_WAIT_TIMEOUT_MS = 3000 + +/** + * Round-4 follow-up finding: `guardAgainstUserSwitch` runs synchronously at the very top + * of `setupOfflineSupport`, before that function's own `navigator.serviceWorker.register()` + * call. On a switch detected at boot, a plain `getRegistration()` can come back with no + * active worker even though a worker registered by an *earlier* browser session for this + * origin is still sitting there holding the previous user's SHELL_CACHE - the clear would + * silently no-op and report success. Only fall back to waiting when a worker is actually + * going to show up (serviceWorkerSupportEnabled) and bound the wait, so a browser/build + * that will never register one (dev, insecure context) doesn't hang the clear. + */ +async function getActiveWorker(): Promise { + const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE) + if (registration?.active) return registration.active + if (!serviceWorkerSupportEnabled()) return undefined + + const ready = await Promise.race([ + navigator.serviceWorker.ready, + new Promise((resolve) => + window.setTimeout(() => resolve(undefined), REGISTRATION_WAIT_TIMEOUT_MS), + ), + ]) + return ready?.active +} + /** * Round-4 finding: without this, the SW's SHELL_CACHE stays empty from the moment a * user-switch clear runs (clearOfflineCaches, above) until the *next* successful online @@ -125,7 +164,7 @@ async function rewarmShellCache(): Promise { /** * Compares `user` against the last user this browser saw (persisted in localStorage so - * it survives full reloads) and clears every offline cache when they differ. Returns + * it survives full reloads) and clears every offline cache when they differ. Resolves to * whether a switch was detected. * * Every cacheKey in the data layer (data/communities.ts, data/users.ts, data/drafts.ts) @@ -134,21 +173,35 @@ async function rewarmShellCache(): Promise { * stale for a user switch that happens *without* a reload. Callers that change the * session user in place (e.g. session.ts's login) must force a reload after a detected * switch instead of relying on those singletons to pick up the new identity. + * + * Round-4 finding (PR #516): the clear used to be started and left to run in the + * background while this function returned synchronously. session.ts's login handler + * hard-navigates the instant it sees `true` back from here, which could tear the page + * down mid-clear - and the marker below was written unconditionally, so a switch that + * got cut off still looked "handled" on the next boot. This is now async: callers that + * are about to navigate away (session.ts) must `await` it, and the marker is only + * written once the clear this call kicked off (if any) has itself settled. */ -export function guardAgainstUserSwitch(user: string | null): boolean { +export async function guardAgainstUserSwitch(user: string | null): Promise { if (typeof localStorage === 'undefined') return false const lastSeenUser = localStorage.getItem(LAST_SEEN_USER_STORAGE_KEY) const switched = Boolean(lastSeenUser && user && lastSeenUser !== user) if (switched) { - // Unlike a plain logout (clearOfflineCaches alone), a detected switch to a - // *different* user also wipes gameplan-drafts - the same-user recovery case that - // policy exists for doesn't apply here. - Promise.all([clearOfflineCaches(), clearDraftStore()]) - .then(() => rewarmShellCache()) - .catch((error) => console.error('Failed to clear offline caches', error)) + try { + // Unlike a plain logout (clearOfflineCaches alone), a detected switch to a + // *different* user also wipes gameplan-drafts - the same-user recovery case that + // policy exists for doesn't apply here. + await Promise.all([clearOfflineCaches(), clearDraftStore()]) + await rewarmShellCache() + } catch (error) { + console.error('Failed to clear offline caches', error) + } } + // Written only after the clear above (if any) has settled, successfully or not - see + // the round-4 note above. A caller that navigates away on `true` only does so once + // this has actually finished. if (user) { localStorage.setItem(LAST_SEEN_USER_STORAGE_KEY, user) } else { From eb8ddb14c7e54fbf9019e0153576899f9d5200b4 Mon Sep 17 00:00:00 2001 From: ebrahimgamdiwala Date: Mon, 7 Sep 2026 06:59:28 +0000 Subject: [PATCH 12/68] fix(frontend): stop offline route validation from proceeding with a null space router.ts's offline/network-error fallback let navigation continue when a space or community couldn't be resolved (isRouteValidationUnavailable), so a deep link to a genuine-but-uncached space or community rendered downstream page components with space/community === null instead of either a wrongful NotFound or a working page. Flagged by review (PR #516, round 3, escalated P2 -> P1: "Missing Space Proceeds"). Both branches now redirect to a new OfflineUnavailable page that says honestly that the content isn't cached yet, with a retry action - matching the pattern OfflineContentFallback.vue already uses for a failed discussion/space-list fetch. Also adds backend regression tests for GP User Profile's get_list - the only backend change in this PR (parsing fields/filters/start/limit as GET query-string values) had no test coverage. Co-Authored-By: Claude Sonnet 5 --- frontend/src/pages/OfflineUnavailable.vue | 31 ++++++++++++++++ frontend/src/router.ts | 17 ++++++--- gameplan/tests/features/test_profiles.py | 43 +++++++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/OfflineUnavailable.vue diff --git a/frontend/src/pages/OfflineUnavailable.vue b/frontend/src/pages/OfflineUnavailable.vue new file mode 100644 index 000000000..500d02abe --- /dev/null +++ b/frontend/src/pages/OfflineUnavailable.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 0a5010a4e..64c5060c1 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -268,6 +268,11 @@ const routes: RouteRecordRaw[] = [ name: 'NotFound', component: () => import('@/pages/NotFound.vue'), }, + { + path: '/offline-unavailable', + name: 'OfflineUnavailable', + component: () => import('@/pages/OfflineUnavailable.vue'), + }, { path: '/list', name: 'Teams', @@ -809,9 +814,13 @@ router.beforeEach(async (to, from) => { let space = to.params.spaceId ? getSpace(routeParam(to.params.spaceId)) : null if (to.params.spaceId && !space) { + // Greptile P1 (PR #516, round 3): letting navigation continue here used to leave + // `space === null` for every downstream page component that assumes a real space - + // this deep link may be genuine (just never cached), so send it to an honest + // "not available offline" page instead of either a wrongful NotFound or a route that + // silently proceeds with no space to render. if (isRouteValidationUnavailable()) { - communityState.scope(communityId) - return + return { name: 'OfflineUnavailable' } } return { name: 'NotFound' } } @@ -826,9 +835,9 @@ router.beforeEach(async (to, from) => { // Public communities are visible even when the user has not joined them, so route validity // cannot be tied to the active sidebar community list. if (!community) { + // Same reasoning as the spaceId branch above: don't proceed with `community === null`. if (isRouteValidationUnavailable()) { - communityState.scope(communityId) - return + return { name: 'OfflineUnavailable' } } return { name: 'NotFound' } } diff --git a/gameplan/tests/features/test_profiles.py b/gameplan/tests/features/test_profiles.py index e74cd3eb8..af2efdafa 100644 --- a/gameplan/tests/features/test_profiles.py +++ b/gameplan/tests/features/test_profiles.py @@ -1,11 +1,14 @@ # Copyright (c) 2022, Frappe Technologies Pvt Ltd and Contributors # See license.txt +import json + import frappe from gameplan.api import get_user_info from gameplan.gameplan.doctype.gp_user_profile.gp_user_profile import ( get_bento_cards, + get_list, get_my_bento_cards, has_permission, reset_my_bento_cards, @@ -691,6 +694,46 @@ def test_bento_cards_accept_the_json_payload_sent_over_http(self): self.assertEqual([card["id"] for card in response["cards"]], ["intro"]) +class TestGetListQueryParams(GameplanTestCase): + """`get_list` is built for frappe-ui's `useList` (PR #516's offline caching), which + always fetches over GET - so `fields`/`filters` arrive JSON-stringified and + `start`/`limit` arrive as strings, never as the `dict`/`int` shapes the query + builder wants. See the comment on `get_list` itself for why they're parsed by hand.""" + + def setUp(self): + super().setUp() + self.alice = create_member("test_alice_getlist@example.com", "Alice Getlist") + self.bob = create_member("test_bob_getlist@example.com", "Bob Getlist") + frappe.set_user(self.alice.name) + + def test_accepts_json_stringified_fields_and_filters_with_string_pagination(self): + result = get_list( + fields=json.dumps(["name", "user"]), + filters=json.dumps({"user": self.alice.name}), + start="0", + limit="5", + ) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["user"], self.alice.name) + + def test_still_accepts_native_dict_filters_and_int_pagination(self): + """The direct (non-HTTP) call path other Gameplan code may still use.""" + result = get_list(fields=["name", "user"], filters={"user": self.bob.name}, start=0, limit=5) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["user"], self.bob.name) + + def test_defaults_still_work_when_every_argument_is_omitted(self): + # Just needs to not raise - the default `limit=20` means a specific row (e.g. + # self.alice's) isn't guaranteed to be in this page on a site with more than 20 + # profiles, so this only checks the call succeeds and shapes a list. + result = get_list() + + self.assertIsInstance(result, list) + self.assertLessEqual(len(result), 20) + + class TestCustomEmojis(GameplanTestCase): def setUp(self): super().setUp() From ec387b4e89305bf1189b82931aba30fadf4f8085 Mon Sep 17 00:00:00 2001 From: ebrahimgamdiwala Date: Mon, 7 Sep 2026 08:57:12 +0000 Subject: [PATCH 13/68] fix(frontend): disable post/comment/poll submit buttons while offline Posting a comment, a poll, or publishing a new discussion while offline hit the network and surfaced a raw "TypeError: Failed to fetch" instead of failing gracefully. Disable the relevant submit buttons outright when isOnline is false, rather than letting the attempt happen and reporting the error after the fact: - CommentsArea.vue: the comment and poll submit buttons' existing `disabled` bindings now also check isOnline. submitComment/submitPoll themselves are guarded too, since ctrl/cmd+Enter reaches submitComment directly and bypasses the disabled button. - DiscussionHeader.vue: the "Publish" button's disabled condition (previously just isComposerEditable) is now a canPublish computed that also requires isOnline, and its tooltip explains why ("You're offline" alongside the existing "Draft is loading" case). The draft body and space selector are untouched and stay editable offline - only the final publish step needs a network round trip. - useNewDiscussion.ts's publish() gets the same isOnline check as a backstop, in case it's ever reached another way. Co-Authored-By: Claude Sonnet 5 --- frontend/src/components/CommentsArea.vue | 12 ++++++--- .../pages/NewDiscussion/DiscussionHeader.vue | 26 ++++++++++++------- .../pages/NewDiscussion/useNewDiscussion.ts | 8 ++++++ 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/CommentsArea.vue b/frontend/src/components/CommentsArea.vue index 92509164c..3f6bf4aad 100644 --- a/frontend/src/components/CommentsArea.vue +++ b/frontend/src/components/CommentsArea.vue @@ -209,7 +209,7 @@ variant: 'solid', onClick: submitComment, loading: comments.insert.loading, - disabled: commentEmpty, + disabled: commentEmpty || !isOnline, }" :discardButtonProps="{ onClick: discardComment, @@ -237,6 +237,7 @@ :submitButtonProps="{ onClick: submitPoll, loading: polls.insert.loading, + disabled: !isOnline, }" :discardButtonProps="{ onClick: discardPoll, @@ -288,7 +289,7 @@ import { tags } from '@/data/tags' import { isNewCommentOpen } from '@/data/newComment' import { useRichQuotes } from '@/components/RichQuoteExtension/useRichQuotes' import { useDraftSync } from '@/data/useDraftSync' -import { onReconnect } from '@/data/online' +import { isOnline, onReconnect } from '@/data/online' import { useSessionUser } from '@/data/users' import type { Space } from '@/data/spaces' import { useIsMobile } from '@/utils/useIsMobile' @@ -689,7 +690,10 @@ function resetCommentState() { } async function submitComment() { - if (commentEmpty.value || comments.insert.loading) return + // The submit button is disabled while offline, but ctrl/cmd+Enter (bound below on + // the editor) reaches this directly and isn't gated by that - guard here too, rather + // than let it hit the network and surface a raw "Failed to fetch". + if (commentEmpty.value || comments.insert.loading || !isOnline.value) return const comment = await comments.insert.submit({ reference_doctype: props.doctype, @@ -770,7 +774,7 @@ function wait(ms: number) { } function submitPoll() { - if (props.doctype !== 'GP Discussion') return + if (props.doctype !== 'GP Discussion' || !isOnline.value) return return polls.insert .submit({ discussion: props.name, diff --git a/frontend/src/pages/NewDiscussion/DiscussionHeader.vue b/frontend/src/pages/NewDiscussion/DiscussionHeader.vue index e7edaff33..dad15544a 100644 --- a/frontend/src/pages/NewDiscussion/DiscussionHeader.vue +++ b/frontend/src/pages/NewDiscussion/DiscussionHeader.vue @@ -16,16 +16,13 @@ >
diff --git a/frontend/src/components/OfflineIndicator.vue b/frontend/src/components/OfflineIndicator.vue index 71cd004d0..7344c6c28 100644 --- a/frontend/src/components/OfflineIndicator.vue +++ b/frontend/src/components/OfflineIndicator.vue @@ -26,6 +26,8 @@ diff --git a/frontend/src/components/TaskDetail.vue b/frontend/src/components/TaskDetail.vue index df9f659b4..6552312cf 100644 --- a/frontend/src/components/TaskDetail.vue +++ b/frontend/src/components/TaskDetail.vue @@ -181,6 +181,12 @@ + diff --git a/frontend/src/pages/PersonProfileReplies.vue b/frontend/src/pages/PersonProfileReplies.vue index d7c90e8bb..70b673bd1 100644 --- a/frontend/src/pages/PersonProfileReplies.vue +++ b/frontend/src/pages/PersonProfileReplies.vue @@ -1,30 +1,15 @@ diff --git a/frontend/src/pages/Search.vue b/frontend/src/pages/Search.vue index c884d80c2..2bf723d35 100644 --- a/frontend/src/pages/Search.vue +++ b/frontend/src/pages/Search.vue @@ -167,9 +167,11 @@ @@ -299,6 +301,7 @@ import { activeCommunities } from '@/data/communities' import { activeUsers } from '@/data/users' import { vFocus } from '@/directives' import { isOnline } from '@/data/online' +import { isNetworkError } from '@/offline' // Type Definitions interface SearchSummary { diff --git a/frontend/src/pages/SpaceDiscussions.vue b/frontend/src/pages/SpaceDiscussions.vue index bd385fbf7..a099d8c57 100644 --- a/frontend/src/pages/SpaceDiscussions.vue +++ b/frontend/src/pages/SpaceDiscussions.vue @@ -47,24 +47,12 @@ - - (null) const discussionListRef = useTemplateRef('discussionListRef') const router = useRouter() -// DiscussionList owns the useList resource; reach into it through its exposed ref rather -// than duplicating the fetch here, so this page can tell "loaded, genuinely empty" (handled -// inside DiscussionList already) apart from "fetch failed, nothing cached" (not handled -// there - see the v-show/OfflineContentFallback pairing below). -const discussionsResource = computed(() => discussionListRef.value?.discussions) -const listFailure = computed(() => { - const discussions = discussionsResource.value - if (!discussions) return null - const failed = - discussions.isFinished && !discussions.loading && discussions.error && discussions.data == null - if (!failed) return null - - const offline = isBrowserOffline() || isNetworkError(discussions.error) - return offline - ? { - title: "Can't load this while offline", - message: "This space's discussions haven't been saved for offline use yet.", - } - : { - title: 'Could not load discussions', - message: 'Something went wrong while loading this list. Retry to try again.', - } -}) const { space: currentSpace, isArchived, diff --git a/frontend/tests/offline/p2.js b/frontend/tests/offline/p2.js index 11227fe46..48dd3bc5e 100644 --- a/frontend/tests/offline/p2.js +++ b/frontend/tests/offline/p2.js @@ -61,8 +61,7 @@ async function run() { const info = await appRootInfo(page) const postLinks = await page.locator('a[href*="/discussion/"]').count() const isNotFound = /page not found/i.test(text) - const fallbackShown = - /can.?t load this while offline|haven.?t been saved for offline use/i.test(text) + const fallbackShown = /can.?t load [^\n]*while offline|been saved for offline use/i.test(text) checkPosts.textSnippet = text.slice(0, 400) checkPosts.info = info From 48074faca476480d8ad5d3b0ef05fb111d0a8480 Mon Sep 17 00:00:00 2001 From: ebrahimgamdiwala Date: Thu, 17 Sep 2026 13:58:45 +0000 Subject: [PATCH 29/68] feat(frontend): stale-while-revalidate while offline - API requests are no longer sent while offline. Resources show their cached copy or the offline empty state, and the request is refused locally instead. - On reconnect, resources on screen revalidate, and shared stores whose request failed offline retry. useList/useDoc/useCall are imported from data/staleWhileRevalidate instead of frappe-ui, which replaces the per-component reconnect reloads. - A discussion being refreshed keeps its cached copy on screen instead of swapping in the loading skeleton, which also remounted and refetched the comments. - Visits are recorded, error reports sent and the socket reconnected once the connection returns, instead of failing offline. - TaskDetail no longer piles up a track_visit callback on the cached task on every mount. Co-Authored-By: Claude Opus 5 --- .../AppRail/CustomizeSidebarDialog.vue | 3 +- .../CommandPalette/CommandPalette.vue | 3 +- frontend/src/components/Comment.vue | 2 +- frontend/src/components/CommentsArea.vue | 18 +---- frontend/src/components/CommentsList.vue | 13 +--- frontend/src/components/DevUserSwitcher.vue | 3 +- frontend/src/components/DiscussionView.vue | 13 ++-- frontend/src/components/Poll.vue | 2 +- .../ProfileBento/profileBentoSource.ts | 3 +- .../ProfileBento/useProfileFieldEditing.ts | 3 +- frontend/src/components/RevisionsDialog.vue | 3 +- .../src/components/Settings/InvitePeople.vue | 2 +- .../components/Settings/MembersSettings.vue | 13 +--- .../components/Settings/ProfileSettings.vue | 2 +- frontend/src/components/SpaceAccessDialog.vue | 3 +- frontend/src/components/TaskDetail.vue | 15 ++-- frontend/src/components/TaskList.vue | 2 +- .../UnsplashPicker/UnsplashPicker.vue | 11 +-- frontend/src/data/apps.ts | 2 +- frontend/src/data/communities.ts | 3 +- frontend/src/data/communitySpaces.ts | 2 +- frontend/src/data/customEmojis.ts | 2 +- frontend/src/data/discussions.ts | 11 +-- frontend/src/data/drafts.ts | 2 +- frontend/src/data/notifications.ts | 6 +- frontend/src/data/online.ts | 15 +++- frontend/src/data/people.ts | 2 +- frontend/src/data/reactions.ts | 2 +- frontend/src/data/session.ts | 2 +- frontend/src/data/spaces.ts | 3 +- frontend/src/data/staleWhileRevalidate.ts | 78 +++++++++++++++++++ frontend/src/data/tags.ts | 2 +- frontend/src/data/tasks.ts | 5 +- frontend/src/data/unreadCount.ts | 5 +- frontend/src/data/users.ts | 2 +- .../pages/Configure/CommunityGuestsList.vue | 3 +- .../pages/Configure/useCommunitySpaceData.ts | 2 +- frontend/src/pages/Drafts.vue | 2 +- frontend/src/pages/MobileHome.vue | 3 +- frontend/src/pages/Notifications.vue | 3 +- frontend/src/pages/Onboarding.vue | 3 +- frontend/src/pages/Page.vue | 8 +- frontend/src/pages/PageGrid.vue | 3 +- frontend/src/pages/PersonProfile.vue | 3 +- frontend/src/pages/ProfileCustomize.vue | 12 +-- frontend/src/pages/Search.vue | 3 +- frontend/src/pages/Space.vue | 7 +- frontend/src/pages/SpaceDiscussions.vue | 3 +- frontend/src/socket.ts | 8 ++ frontend/src/utils/errorReporting.ts | 16 ++-- 50 files changed, 214 insertions(+), 123 deletions(-) create mode 100644 frontend/src/data/staleWhileRevalidate.ts diff --git a/frontend/src/components/AppRail/CustomizeSidebarDialog.vue b/frontend/src/components/AppRail/CustomizeSidebarDialog.vue index ed66df097..d74ebb9a5 100644 --- a/frontend/src/components/AppRail/CustomizeSidebarDialog.vue +++ b/frontend/src/components/AppRail/CustomizeSidebarDialog.vue @@ -151,7 +151,8 @@ diff --git a/frontend/src/pages/SpaceDiscussions.vue b/frontend/src/pages/SpaceDiscussions.vue index a099d8c57..c2817191f 100644 --- a/frontend/src/pages/SpaceDiscussions.vue +++ b/frontend/src/pages/SpaceDiscussions.vue @@ -85,7 +85,8 @@ diff --git a/frontend/src/components/Settings/SettingsDialog.vue b/frontend/src/components/Settings/SettingsDialog.vue index 7b7b7d770..e788af28a 100644 --- a/frontend/src/components/Settings/SettingsDialog.vue +++ b/frontend/src/components/Settings/SettingsDialog.vue @@ -61,6 +61,7 @@ import NotificationsSettings from './NotificationsSettings.vue' import ProfileSettings from './ProfileSettings.vue' import CustomEmojiSettings from './CustomEmojiSettings.vue' import PreferencesSettings from './PreferencesSettings.vue' +import OfflineSettings from './OfflineSettings.vue' interface SettingsTab extends Tab { // Tabs that drive global role management / invites; these only make sense for @@ -103,6 +104,13 @@ const allTabs: SettingsTab[] = [ icon: 'lucide-bell', component: markRaw(NotificationsSettings), }, + { + label: 'Offline', + slug: 'offline', + group: 'User settings', + icon: 'lucide-cloud-download', + component: markRaw(OfflineSettings), + }, { label: 'Communities', slug: 'communities', diff --git a/frontend/src/data/discussionTimeline.ts b/frontend/src/data/discussionTimeline.ts new file mode 100644 index 000000000..4b5e5be7a --- /dev/null +++ b/frontend/src/data/discussionTimeline.ts @@ -0,0 +1,46 @@ +/** + * What a discussion's timeline lists fetch and where they cache it. Shared by CommentsArea, + * which fetches them, and offline downloads, which fills the same cache entries so a + * downloaded discussion opens offline exactly as if it had been visited. + */ + +export const COMMENT_FIELDS = [ + 'name', + 'content', + 'owner', + 'creation', + 'modified', + 'edited_at', + 'deleted_at', + { reactions: ['name', 'user', 'emoji'] }, +] + +export const ACTIVITY_FIELDS = ['name', 'user', 'action', 'data', 'creation'] + +export const POLL_FIELDS = [ + 'name', + 'title', + 'anonymous', + 'multiple_answers', + 'creation', + 'owner', + 'stopped_at', + { options: ['name', 'title', 'idx', 'percentage'] }, + { votes: ['user', 'option'] }, + { reactions: ['name', 'user', 'emoji'] }, +] + +// Scoped to the session user: a discussion can live in a private space, so a second account +// on the same browser must not see these cached offline before its own permission-checked +// fetch resolves (review finding from PR #516). +export function commentsCacheKey(doctype: string, name: string, user: string) { + return ['Comments', doctype, name, user] +} + +export function activitiesCacheKey(doctype: string, name: string, user: string) { + return ['Activities', doctype, name, user] +} + +export function pollsCacheKey(discussion: string, user: string) { + return ['Polls', discussion, user] +} diff --git a/frontend/src/data/offlineDownloads.ts b/frontend/src/data/offlineDownloads.ts new file mode 100644 index 000000000..500339e4c --- /dev/null +++ b/frontend/src/data/offlineDownloads.ts @@ -0,0 +1,357 @@ +import { computed, reactive, watch } from 'vue' +import { useLocalStorage } from '@vueuse/core' +import { call, dialog, toast } from 'frappe-ui' +import { delMany, get, set, setMany } from 'idb-keyval' +import { isOnline, onReconnect } from './online' +import { session } from './session' +import { + ACTIVITY_FIELDS, + COMMENT_FIELDS, + POLL_FIELDS, + activitiesCacheKey, + commentsCacheKey, + pollsCacheKey, +} from './discussionTimeline' + +/** + * "Download for offline" (Settings > Offline): keeps the discussions from joined spaces with + * activity in the chosen window on this device, with their comments, activity and polls. + * + * Downloads are filed into the same IndexedDB entries frappe-ui's resources read (`doc:` for + * documents, `["useList", ...cacheKey]` for lists), so a downloaded discussion opens offline + * like a visited one and needs no second copy. frappe-ui has no public API to seed its cache, + * hence the key formats below. Everything lives in the default idb-keyval store, so logout and + * user switch (offline.ts) wipe it with the rest. + */ + +export type OfflineWindow = 0 | 7 | 30 | 90 + +export const WINDOW_OPTIONS: { label: string; value: OfflineWindow }[] = [ + { label: 'Recently viewed only', value: 0 }, + { label: 'Past week', value: 7 }, + { label: 'Past month', value: 30 }, + { label: 'Past 3 months', value: 90 }, +] + +const INDEX = 'gameplan.offline_downloads.get_offline_index' +const BUNDLE = 'gameplan.offline_downloads.get_offline_bundle' +const META_KEY = 'gameplan:offline-downloads' +const LOCK_NAME = 'gameplan-offline-downloads' +// Background syncs only fetch what changed, but still cost an index query each; this keeps +// them to a few a day per device. +const SYNC_INTERVAL = 6 * 60 * 60 * 1000 +// Spreads the first sync after load so a team opening the app together doesn't sync together. +const MAX_START_DELAY = 30 * 1000 + +interface Meta { + user: string + window: number + /** Server time the last complete sync started; the next one asks for changes since. */ + since: string | null + names: string[] + lastSyncedAt: number | null + /** Where an interrupted sync stopped, so the next one carries on from there. */ + cursor: { start: number; since: string | null; syncedAt: string } | null +} + +interface Bundle { + discussions: Array & { name: string | number }> + comments: Record + activities: Record + polls: Record + has_next_page: boolean +} + +type Row = Record & { name: string | number } + +/** What the admin allows, from the boot data; updated in place when an admin changes it. */ +export const policy = reactive({ + enabled: window.offline_downloads?.enabled ?? false, + maxWindow: (window.offline_downloads?.max_window_days ?? 0) as number, +}) + +const chosenWindow = useLocalStorage(`gameplan:offline-window:${session.user}`, 0) + +/** The window actually downloaded: the user's choice, capped by the admin. */ +export const offlineWindow = computed({ + get: () => { + if (!policy.enabled) return 0 + return Math.min(chosenWindow.value, policy.maxWindow) as OfflineWindow + }, + set: (value) => { + chosenWindow.value = value + }, +}) + +export const downloads = reactive({ + syncing: false, + done: 0, + total: 0, + count: 0, + lastSyncedAt: null as number | null, + error: null as string | null, +}) + +let meta: Meta | null = null + +async function readMeta(): Promise { + const stored = (await get(META_KEY).catch(() => null)) as Meta | undefined + meta = stored?.user === session.user ? stored : null + downloads.count = meta?.names.length ?? 0 + downloads.lastSyncedAt = meta?.lastSyncedAt ?? null + return meta +} + +async function writeMeta(next: Meta) { + meta = next + downloads.count = next.names.length + downloads.lastSyncedAt = next.lastSyncedAt + await set(META_KEY, next) +} + +/** + * Brings the device in line with the chosen window. Automatic runs are skipped when a sync + * happened recently, on Data Saver, or while the tab is hidden; `manual` runs skip those checks. + */ +export async function syncOfflineDownloads({ manual = false } = {}): Promise { + if (!session.isLoggedIn) return false + // A manual run (a new window picked) waits for the current one, then brings it up to date. + if (inflight) { + await inflight.catch(() => {}) + if (!manual) return false + } + inflight = sync(manual).finally(() => (inflight = null)) + return inflight +} + +let inflight: Promise | null = null + +async function sync(manual: boolean): Promise { + const days = offlineWindow.value + const current = meta ?? (await readMeta()) + + if (!days) { + if (current) await removeOfflineDownloads() + return true + } + if (!isOnline.value) return false + if (!manual) { + if (document.visibilityState !== 'visible' || saveData()) return false + // An interrupted sync resumes whenever it can, however recent the last complete one. + const fresh = current?.window === days && !current.cursor && current.lastSyncedAt + if (fresh && Date.now() - fresh < SYNC_INTERVAL) return true + } + + // One tab at a time; another tab already syncing covers this one. + if (!navigator.locks) return runSync(days) + return navigator.locks.request(LOCK_NAME, { ifAvailable: true }, (lock) => + lock ? runSync(days) : false, + ) +} + +/** Returns whether the sync finished; losing the connection pauses it at the saved cursor. */ +async function runSync(days: OfflineWindow): Promise { + downloads.syncing = true + downloads.error = null + downloads.done = 0 + downloads.total = 0 + try { + const previous = meta ?? (await readMeta()) + const index = await call<{ discussions: string[]; synced_at: string }>(INDEX, { + window_days: days, + }) + const names = index.discussions + const dropped = (previous?.names ?? []).filter((name) => !names.includes(name)) + await forgetDiscussions(dropped) + + const sameWindow = previous?.window === days + const since = sameWindow ? previous.since : null + const resume = sameWindow && previous.cursor?.since === since ? previous.cursor : null + let start = resume?.start ?? 0 + const syncedAt = resume?.syncedAt ?? index.synced_at + downloads.total = since ? 0 : names.length + downloads.done = since ? 0 : start + + const base: Meta = { + user: session.user!, + window: days, + since, + names, + lastSyncedAt: sameWindow ? previous.lastSyncedAt : null, + cursor: { start, since, syncedAt }, + } + await writeMeta(base) + + let hasNext = true + while (hasNext) { + // Picks up on the next reconnect or app load, from the saved cursor. + if (!isOnline.value) return false + const bundle = await call(BUNDLE, { + window_days: days, + since, + start, + fields: { comments: COMMENT_FIELDS, activities: ACTIVITY_FIELDS, polls: POLL_FIELDS }, + }) + await storeBundle(bundle) + start += bundle.discussions.length + downloads.done += bundle.discussions.length + hasNext = bundle.has_next_page + await writeMeta({ ...base, cursor: { start, since, syncedAt } }) + if (hasNext) await idle() + } + + await writeMeta({ ...base, since: syncedAt, lastSyncedAt: Date.now(), cursor: null }) + return true + } catch (error) { + downloads.error = error instanceof Error ? error.message : String(error) + throw error + } finally { + downloads.syncing = false + } +} + +async function storeBundle(bundle: Bundle) { + const user = session.user! + const entries: [string, string][] = [] + for (const discussion of bundle.discussions) { + const name = String(discussion.name) + entries.push([docKey('GP Discussion', name), JSON.stringify({ ...discussion, name })]) + entries.push( + listEntry(commentsCacheKey('GP Discussion', name, user), bundle.comments[name]), + listEntry(activitiesCacheKey('GP Discussion', name, user), bundle.activities[name]), + listEntry(pollsCacheKey(name, user), bundle.polls[name]), + ) + } + await setMany(entries) +} + +async function forgetDiscussions(names: string[]) { + if (!names.length) return + const user = session.user! + await delMany( + names.flatMap((name) => [ + docKey('GP Discussion', name), + listKey(commentsCacheKey('GP Discussion', name, user)), + listKey(activitiesCacheKey('GP Discussion', name, user)), + listKey(pollsCacheKey(name, user)), + ]), + ) +} + +/** Deletes everything downloaded; a discussion opened again online is cached as usual. */ +export async function removeOfflineDownloads() { + const current = meta ?? (await readMeta()) + if (!current) return + await forgetDiscussions(current.names) + await delMany([META_KEY]) + meta = null + downloads.count = 0 + downloads.lastSyncedAt = null +} + +/** Picks a window and downloads it now, with a toast for the foreground download. */ +export function downloadForOffline(days: OfflineWindow) { + offlineWindow.value = days + if (!days) return removeOfflineDownloads() + // Without this the browser may evict the downloads under storage pressure (Safari does + // after a week of not opening the site). + navigator.storage?.persist?.().catch(() => {}) + const finished = syncOfflineDownloads({ manual: true }).then((done) => { + if (!done) throw new Error('Offline download did not finish') + }) + return toast.promise(finished, { + loading: 'Downloading discussions for offline reading…', + success: 'Discussions are ready to read offline', + error: 'Could not finish the offline download. It will retry later.', + }) +} + +// The key formats frappe-ui's useList and docStore use for IndexedDB. +function listKey(cacheKey: unknown[]) { + return JSON.stringify(['useList', ...cacheKey]) +} + +function docKey(doctype: string, name: string) { + return `doc:${doctype}/${name}` +} + +function listEntry(cacheKey: unknown[], rows: Row[] = []): [string, string] { + // Raw rows, names as strings like useList stores them; each list applies its own transform + // when it reads the cache. + return [ + listKey(cacheKey), + JSON.stringify(rows.map((row) => ({ ...row, name: String(row.name) }))), + ] +} + +function saveData() { + return Boolean( + (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData, + ) +} + +function idle() { + return new Promise((resolve) => + 'requestIdleCallback' in window + ? requestIdleCallback(() => resolve(), { timeout: 2000 }) + : setTimeout(resolve, 200), + ) +} + +/** Starts background syncing: once shortly after load, then on reconnect and when the tab returns. */ +export function setupOfflineDownloads() { + const background = () => syncOfflineDownloads().catch(() => {}) + readMeta().then((current) => { + if (!policy.enabled && current) removeOfflineDownloads() + }) + setTimeout(background, 5000 + Math.random() * MAX_START_DELAY) + onReconnect(background) + document.addEventListener('visibilitychange', background) + watch(offlineWindow, (days, previous) => { + if (!days && previous) removeOfflineDownloads() + }) + setupIntroduction() +} + +const introduction = useLocalStorage<'new' | 'seen-offline' | 'done'>( + `gameplan:offline-intro:${session.user}`, + 'new', +) + +/** + * Introduces downloads once per device: a toast the first time the connection drops (nothing + * can be downloaded then), and an offer to download on the next app load. Not on reconnect, + * where a dialog would land on top of whatever the person was in the middle of. + */ +function setupIntroduction() { + const pending = () => policy.enabled && !offlineWindow.value + watch(isOnline, (online) => { + if (online || !pending() || introduction.value !== 'new') return + introduction.value = 'seen-offline' + toast.info("You're offline. Only discussions you've opened are available.", { + action: { label: 'Set up offline reading', onClick: openOfflineSettings }, + }) + }) + if (introduction.value === 'seen-offline') setTimeout(offerDownload, 3000) +} + +function offerDownload() { + if (!policy.enabled || offlineWindow.value || !isOnline.value) return + introduction.value = 'done' + const days = Math.min(30, policy.maxWindow) as OfflineWindow + const period = WINDOW_OPTIONS.find((option) => option.value === days)!.label.toLowerCase() + dialog.confirm({ + title: 'Read Gameplan offline', + message: `Keep discussions from the ${period} in your spaces on this device, so they open even without a connection. You can change this in Settings.`, + confirmLabel: 'Download', + cancelLabel: 'Not now', + onConfirm: () => { + downloadForOffline(days) + }, + }) +} + +function openOfflineSettings() { + // Imported on demand: the settings module reaches the router, which reaches this module. + import('@/components/Settings').then(({ showSettingsDialog }) => showSettingsDialog('Offline')) +} diff --git a/frontend/src/globals.d.ts b/frontend/src/globals.d.ts index e2cdce176..88a17cf85 100644 --- a/frontend/src/globals.d.ts +++ b/frontend/src/globals.d.ts @@ -34,6 +34,8 @@ declare global { site_name: string /** Set from the boot data in `gameplan/www/g.py`; absent when the site has no DSN. */ gameplan_frontend_sentry_dsn?: string + /** What the admin allows for offline downloads (GP Settings), from the boot data. */ + offline_downloads?: { enabled: boolean; max_window_days: number } } } diff --git a/frontend/src/main.js b/frontend/src/main.js index 389c41426..cc4da9e00 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -22,6 +22,7 @@ import { initSocket } from './socket' import { installErrorReporting } from './utils/errorReporting' import resetDataMixin from './utils/resetDataMixin' import { setupOfflineSupport } from './offline' +import { setupOfflineDownloads } from './data/offlineDownloads' let globalComponents = { Button, @@ -80,6 +81,7 @@ function setupApp() { app.config.globalProperties.$socket = socket app.mount('#app') setupOfflineSupport() + if (session.isLoggedIn) setupOfflineDownloads() } if (import.meta.env.DEV) { diff --git a/frontend/tests/offline/README.md b/frontend/tests/offline/README.md index 2a9d66e2a..0015be259 100644 --- a/frontend/tests/offline/README.md +++ b/frontend/tests/offline/README.md @@ -8,22 +8,23 @@ scripts, not `@playwright/test` — each story is a `run()` function that return Originally built as a throwaway harness at `/tmp/offline-mvp/pw`; migrated here so it survives reboots and can gate regressions in CI/local dev. -## What's covered (12 stories) +## What's covered (13 stories) -| Story | Covers | -| ----- | ------------------------------------------------------------------------------------------------------------ | -| US1 | App shell loads offline (reload + deep link) instead of a browser error page | -| US2 | Previously-viewed feed / space / discussion render from cache while offline | -| US3 | Offline indicator appears when connectivity drops, clears on reconnect | -| US4 | A comment typed while offline fails gracefully and isn't lost | -| US5 | Fresh data appears automatically on reconnect, no manual reload | -| US6 | Never-cached content shows an honest "can't load this offline" fallback | -| P2 | A profile visited fully online (incl. Posts tab) is available offline | -| P3 | A never-cached People page/profile opened offline degrades honestly | -| US7a | Plain logout clears shell/runtime caches and IndexedDB, but preserves the current user's draft | -| US7b | A second user logging in on the same browser (no explicit logout) never sees the first user's cached data | -| US7c | A session that goes stale without a logout (timeout, old tab) still lets the next login detect the switch | -| US8 | A new service worker build shows an update toast; clicking Refresh reloads exactly once onto the new version | +| Story | Covers | +| ----- | ---------------------------------------------------------------------------------------------------------------- | +| US1 | App shell loads offline (reload + deep link) instead of a browser error page | +| US2 | Previously-viewed feed / space / discussion render from cache while offline | +| US3 | Offline indicator appears when connectivity drops, clears on reconnect | +| US4 | A comment typed while offline fails gracefully and isn't lost | +| US5 | Fresh data appears automatically on reconnect, no manual reload | +| US6 | Never-cached content shows an honest "can't load this offline" fallback | +| P2 | A profile visited fully online (incl. Posts tab) is available offline | +| P3 | A never-cached People page/profile opened offline degrades honestly | +| US7a | Plain logout clears shell/runtime caches and IndexedDB, but preserves the current user's draft | +| US7b | A second user logging in on the same browser (no explicit logout) never sees the first user's cached data | +| US7c | A session that goes stale without a logout (timeout, old tab) still lets the next login detect the switch | +| US8 | A new service worker build shows an update toast; clicking Refresh reloads exactly once onto the new version | +| US9 | "Download for offline" makes a never-opened discussion readable offline, in about one request per 20 discussions | ## Prerequisites @@ -52,6 +53,9 @@ survives reboots and can gate regressions in CI/local dev. one `GP Discussion` in it — used by US7c to check the second user genuinely has no access to it. Hardcoded to space `1426` / discussion `722` in `us7c.js`; reseed at those names or edit the story's constants to match your site. + - US9 downloads from that same private space (`GAMEPLAN_OFFLINE_JOINED_SPACE_ID`), since + downloads only cover spaces the account has joined. It creates and deletes its own + discussion. ## Running @@ -61,14 +65,14 @@ yarn install yarn test:offline ``` -Runs all 12 stories against `GAMEPLAN_OFFLINE_BASE_URL` (default +Runs all 13 stories against `GAMEPLAN_OFFLINE_BASE_URL` (default `http://gameplan.localhost:8003`), writes a summary to `tests/offline/results/summary.json` and per-story JSON/screenshots under `tests/offline/results/` (gitignored). Exits non-zero if any story fails. Run a single story directly: `node tests/offline/us3.js`. -Online regression smokes (confirm normal online usage isn't broken — not part of the 12 +Online regression smokes (confirm normal online usage isn't broken — not part of the 13 offline stories): `yarn test:offline:smoke`. ## Configuration diff --git a/frontend/tests/offline/config.js b/frontend/tests/offline/config.js index 0417f41dc..6363d0b44 100644 --- a/frontend/tests/offline/config.js +++ b/frontend/tests/offline/config.js @@ -25,6 +25,8 @@ const UNCACHED_SPACE_ID = process.env.GAMEPLAN_OFFLINE_UNCACHED_SPACE_ID || '4' const UNCACHED_DISCUSSION_SPACE_ID = process.env.GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_SPACE_ID || '5' const UNCACHED_DISCUSSION_ID = process.env.GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_ID || '54' +// A space the primary account has joined (US9 downloads only cover joined spaces). +const JOINED_SPACE_ID = process.env.GAMEPLAN_OFFLINE_JOINED_SPACE_ID || '1426' const URLS = { feed: `${BASE}/g`, @@ -55,6 +57,8 @@ const SHOTS_DIR = path.join(RESULTS_DIR, 'screenshots') module.exports = { BASE, + COMMUNITY, + JOINED_SPACE_ID, EMAIL, PWD, EMAIL2, diff --git a/frontend/tests/offline/runner.js b/frontend/tests/offline/runner.js index f5468cc88..c98256114 100644 --- a/frontend/tests/offline/runner.js +++ b/frontend/tests/offline/runner.js @@ -1,6 +1,6 @@ // Runs the full offline suite: US1-US6 (baseline offline UX) + P2-P3 (People/profile // offline caching) + US7a/US7b/US7c (shared-computer cache scoping) + -// US8 (service worker update flow). Each story launches its own fresh browser/context +// US8 (service worker update flow) + US9 (download for offline). Each story launches its own fresh browser/context // for isolation. Run with: node tests/offline/runner.js (or `yarn test:offline` from // frontend/). const fs = require('fs') @@ -20,6 +20,7 @@ const stories = [ 'us7b', 'us7c', 'us8', + 'us9', ] async function main() { diff --git a/frontend/tests/offline/us9.js b/frontend/tests/offline/us9.js new file mode 100644 index 000000000..3901a1bb6 --- /dev/null +++ b/frontend/tests/offline/us9.js @@ -0,0 +1,153 @@ +// US9 — Download for offline: picking "Past week" in Settings > Offline downloads the +// discussions from joined spaces with recent activity, so one never opened before reads +// offline with its comments. Content outside the window still shows the honest offline +// fallback, the download costs about one request per 20 discussions, and a reload soon after +// doesn't download again. +const { + chromium, + BASE, + URLS, + newLoggedInContext, + newApiRequestContext, + idbKeyvalKeys, + innerTextSafe, + shot, + writeResult, +} = require('./helpers') +const { COMMUNITY, JOINED_SPACE_ID } = require('./config') + +const MARKER = `us9-${Date.now()}` +const OFFLINE_API = /gameplan\.offline_downloads\.get_offline_(index|bundle)/ + +async function createDiscussion(api) { + const discussion = await api.post('/api/v2/document/GP Discussion', { + data: { + title: `${MARKER} downloaded thread`, + project: JOINED_SPACE_ID, + content: '

Body

', + }, + }) + if (!discussion.ok()) throw new Error(`create discussion: ${await discussion.text()}`) + const name = String((await discussion.json()).data.name) + const comment = await api.post('/api/v2/document/GP Comment', { + data: { + reference_doctype: 'GP Discussion', + reference_name: name, + content: `

${MARKER} reply

`, + }, + }) + if (!comment.ok()) throw new Error(`create comment: ${await comment.text()}`) + return name +} + +function spaNavigate(page, path) { + return page.evaluate((p) => { + history.pushState({}, '', p) + dispatchEvent(new PopStateEvent('popstate', { state: history.state })) + }, path) +} + +async function run() { + const api = await newApiRequestContext() + const browser = await chromium.launch({ headless: true }) + const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser) + const result = { story: 'US9', checks: [] } + const offlineRequests = [] + page.on('request', (req) => { + if (OFFLINE_API.test(req.url())) offlineRequests.push(req.url()) + }) + let created = null + + try { + created = await createDiscussion(api) + const discussionPath = `/g/community/${COMMUNITY}/space/${JOINED_SPACE_ID}/discussion/${created}` + + await page.goto(`${BASE}/g/settings/offline`, { waitUntil: 'load', timeout: 20000 }) + await page.getByText('Download for offline').waitFor({ timeout: 15000 }) + await page.getByRole('combobox').first().click() + await page.getByRole('option', { name: 'Past week' }).click() + await page + .getByText('Discussions are ready to read offline') + .waitFor({ timeout: 30000 }) + .catch(() => {}) + + const keys = await idbKeyvalKeys(page) + const index = await ( + await api.post('/api/method/gameplan.offline_downloads.get_offline_index', { + data: { window_days: 7 }, + }) + ).json() + const expectedRequests = 1 + Math.max(1, Math.ceil(index.message.discussions.length / 20)) + result.checks.push({ + name: 'the new discussion and its comments are stored in the app caches', + pass: + keys.includes(`doc:GP Discussion/${created}`) && + keys.some((key) => key.includes('"Comments"') && key.includes(`"${created}"`)), + symptom: `downloaded keys for ${created}: ${keys.filter((k) => String(k).includes(created)).length}`, + }) + result.checks.push({ + name: 'the download costs one index call plus one call per 20 discussions', + pass: offlineRequests.length === expectedRequests, + symptom: `${offlineRequests.length} offline-download requests, expected ${expectedRequests}`, + }) + + await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 }) + await page.waitForTimeout(2000) + await context.setOffline(true) + await page.waitForTimeout(500) + + await spaNavigate(page, discussionPath) + await page.waitForTimeout(2500) + const text = await innerTextSafe(page) + result.checks.push({ + name: 'a never-opened discussion in the window reads offline with its reply', + pass: text.includes(`${MARKER} downloaded thread`) && text.includes(`${MARKER} reply`), + symptom: text.slice(0, 300), + screenshot: await shot(page, 'us9-downloaded-discussion-offline'), + }) + + await spaNavigate(page, new URL(URLS.uncachedDiscussion).pathname) + await page.waitForTimeout(2500) + const outside = await innerTextSafe(page) + result.checks.push({ + name: 'a discussion outside the window still shows the offline fallback', + pass: /isn.?t available offline|can.?t load/i.test(outside), + symptom: outside.slice(0, 300), + }) + + await context.setOffline(false) + offlineRequests.length = 0 + await page.reload({ waitUntil: 'load' }) + // The first background sync waits up to 35s after load. + await page.waitForTimeout(40000) + result.checks.push({ + name: 'a reload soon after a complete sync does not download again', + pass: offlineRequests.length === 0, + symptom: `${offlineRequests.length} offline-download requests after reload`, + }) + + result.pass = result.checks.every((check) => check.pass) + } catch (e) { + result.pass = false + result.fatalError = String(e) + } finally { + result.consoleErrors = consoleErrors + result.pageErrors = pageErrors + await context.setOffline(false).catch(() => {}) + if (created) await api.delete(`/api/v2/document/GP Discussion/${created}`).catch(() => {}) + await browser.close() + await api.dispose() + } + + writeResult('us9', result) + return result +} + +if (require.main === module) { + run().then((r) => { + console.log(JSON.stringify(r, null, 2)) + process.exit(r.pass ? 0 : 1) + }) +} + +module.exports = { run } From 0c1dd9c7b78836b14c939ff3721ef7724ac30772 Mon Sep 17 00:00:00 2001 From: ebrahimgamdiwala Date: Fri, 18 Sep 2026 06:40:54 +0000 Subject: [PATCH 34/68] fix(frontend): quiet failures when the server can't be reached - Recording a space visit left its rejection unhandled when the request failed, which surfaced as an uncaught TypeError whenever the server was down. - Error reporting skipped network failures only while offline. A server that is down while the browser counts as online produced reports that couldn't be delivered either, so skip network failures always. Co-Authored-By: Claude Opus 5 --- frontend/src/pages/Space.vue | 3 ++- frontend/src/utils/errorReporting.ts | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/Space.vue b/frontend/src/pages/Space.vue index 205c55231..3da1795b2 100644 --- a/frontend/src/pages/Space.vue +++ b/frontend/src/pages/Space.vue @@ -124,5 +124,6 @@ function routeParam(value: string | string[] | undefined) { return Array.isArray(value) ? value[0] : value } -whenOnline(() => trackSpaceVisit(props.spaceId)) +// A missed visit only leaves the space's read state as it was; the next visit records it. +whenOnline(() => trackSpaceVisit(props.spaceId).catch(() => {})) diff --git a/frontend/src/utils/errorReporting.ts b/frontend/src/utils/errorReporting.ts index 03c2812cb..173a3ac64 100644 --- a/frontend/src/utils/errorReporting.ts +++ b/frontend/src/utils/errorReporting.ts @@ -15,7 +15,7 @@ import { call } from 'frappe-ui' import type { App } from 'vue' import type { Router } from 'vue-router' -import { isOnline, whenOnline } from '@/data/online' +import { whenOnline } from '@/data/online' import { isNetworkError } from '@/offline' const LOG_CLIENT_ERROR = 'gameplan.api.log_client_error' @@ -97,8 +97,9 @@ function sendToServer(error: unknown, context: ErrorContext): void { // failure, and twenty copies of it say nothing the first one did not. if (reportedFingerprints.has(fingerprint)) return if (reportCount >= MAX_REPORTS_PER_PAGE_LOAD) return - // A failed request while offline is the connection, not a bug. - if (!isOnline.value && isNetworkError(error)) return + // A request that never reached the server is the connection (offline, or the server is + // down), not a bug, and a report about it could not be delivered either. + if (isNetworkError(error)) return reportedFingerprints.add(fingerprint) reportCount += 1 From 891be473313cff3b407bc724ef4da14aa336b3f4 Mon Sep 17 00:00:00 2001 From: ebrahimgamdiwala Date: Fri, 18 Sep 2026 07:05:02 +0000 Subject: [PATCH 35/68] feat(offline): remove visited discussions the user can no longer read A discussion opened while online stays on the device, so after access to its space was taken away (or it was deleted) it could still be read offline. Each download sync now sends the discussions the device holds from the user's own visits with its index request, and removes the ones the server reports as no longer readable. No extra request; one more query on the server. Co-Authored-By: Claude Opus 5 --- frontend/src/data/offlineDownloads.ts | 20 +++++++++---- frontend/tests/offline/us9.js | 29 +++++++++++++++++-- gameplan/offline_downloads.py | 19 ++++++++++-- .../tests/features/test_offline_downloads.py | 17 +++++++++++ 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/frontend/src/data/offlineDownloads.ts b/frontend/src/data/offlineDownloads.ts index 500339e4c..e654bb948 100644 --- a/frontend/src/data/offlineDownloads.ts +++ b/frontend/src/data/offlineDownloads.ts @@ -1,7 +1,7 @@ import { computed, reactive, watch } from 'vue' import { useLocalStorage } from '@vueuse/core' import { call, dialog, toast } from 'frappe-ui' -import { delMany, get, set, setMany } from 'idb-keyval' +import { delMany, get, keys, set, setMany } from 'idb-keyval' import { isOnline, onReconnect } from './online' import { session } from './session' import { @@ -157,12 +157,14 @@ async function runSync(days: OfflineWindow): Promise { downloads.total = 0 try { const previous = meta ?? (await readMeta()) - const index = await call<{ discussions: string[]; synced_at: string }>(INDEX, { - window_days: days, - }) + const visited = (await cachedDiscussions()).filter((name) => !previous?.names.includes(name)) + const index = await call<{ discussions: string[]; revoked: string[]; synced_at: string }>( + INDEX, + { window_days: days, cached: visited }, + ) const names = index.discussions const dropped = (previous?.names ?? []).filter((name) => !names.includes(name)) - await forgetDiscussions(dropped) + await forgetDiscussions([...dropped, ...index.revoked]) const sameWindow = previous?.window === days const since = sameWindow ? previous.since : null @@ -225,6 +227,14 @@ async function storeBundle(bundle: Bundle) { await setMany(entries) } +/** Discussions saved on this device, whether downloaded or from the user's own visits. */ +async function cachedDiscussions() { + const prefix = docKey('GP Discussion', '') + return (await keys()) + .filter((key): key is string => typeof key === 'string' && key.startsWith(prefix)) + .map((key) => key.slice(prefix.length)) +} + async function forgetDiscussions(names: string[]) { if (!names.length) return const user = session.user! diff --git a/frontend/tests/offline/us9.js b/frontend/tests/offline/us9.js index 3901a1bb6..3e150da02 100644 --- a/frontend/tests/offline/us9.js +++ b/frontend/tests/offline/us9.js @@ -1,8 +1,8 @@ // US9 — Download for offline: picking "Past week" in Settings > Offline downloads the // discussions from joined spaces with recent activity, so one never opened before reads // offline with its comments. Content outside the window still shows the honest offline -// fallback, the download costs about one request per 20 discussions, and a reload soon after -// doesn't download again. +// fallback, the download costs about one request per 20 discussions, a reload soon after +// doesn't download again, and a visited copy of a discussion that's since gone is removed. const { chromium, BASE, @@ -18,6 +18,25 @@ const { COMMUNITY, JOINED_SPACE_ID } = require('./config') const MARKER = `us9-${Date.now()}` const OFFLINE_API = /gameplan\.offline_downloads\.get_offline_(index|bundle)/ +// Stands in for a discussion the user opened that has since been deleted. +const GONE_KEY = 'doc:GP Discussion/999999999' + +function putIdbKey(page, key) { + return page.evaluate( + (k) => + new Promise((resolve, reject) => { + const req = indexedDB.open('keyval-store') + req.onsuccess = () => { + const tx = req.result.transaction('keyval', 'readwrite') + tx.objectStore('keyval').put(JSON.stringify({ name: '999999999' }), k) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + } + req.onerror = () => reject(req.error) + }), + key, + ) +} async function createDiscussion(api) { const discussion = await api.post('/api/v2/document/GP Discussion', { @@ -64,6 +83,7 @@ async function run() { await page.goto(`${BASE}/g/settings/offline`, { waitUntil: 'load', timeout: 20000 }) await page.getByText('Download for offline').waitFor({ timeout: 15000 }) + await putIdbKey(page, GONE_KEY) await page.getByRole('combobox').first().click() await page.getByRole('option', { name: 'Past week' }).click() await page @@ -85,6 +105,11 @@ async function run() { keys.some((key) => key.includes('"Comments"') && key.includes(`"${created}"`)), symptom: `downloaded keys for ${created}: ${keys.filter((k) => String(k).includes(created)).length}`, }) + result.checks.push({ + name: 'a visited copy of a discussion that no longer exists is removed', + pass: !keys.includes(GONE_KEY), + symptom: keys.includes(GONE_KEY) ? `${GONE_KEY} still stored` : 'removed on sync', + }) result.checks.push({ name: 'the download costs one index call plus one call per 20 discussions', pass: offlineRequests.length === expectedRequests, diff --git a/gameplan/offline_downloads.py b/gameplan/offline_downloads.py index bf6a8b120..83df0598c 100644 --- a/gameplan/offline_downloads.py +++ b/gameplan/offline_downloads.py @@ -21,6 +21,8 @@ PAGE_SIZE = 20 # A 90-day download of a busy site is a few dozen pages. This only stops a runaway client. REQUESTS_PER_HOUR = 200 +# Visited discussions checked for lost access in one request. +MAX_CACHED = 2000 CHILD_LISTS = { "comments": ("GP Comment", "reference"), "activities": ("GP Activity", "reference"), @@ -29,12 +31,17 @@ @frappe.whitelist(methods=["POST"]) -def get_offline_index(window_days): - """Discussions the device should hold for this window, newest activity first.""" +def get_offline_index(window_days, cached=None): + """Discussions the device should hold for this window, newest activity first. + + `cached` names other discussions the device holds from the user's own visits; the ones they + can no longer read (deleted, or access taken away) come back as `revoked` to be removed. + """ window = _allowed_window(window_days) synced_at = now_datetime() return { "discussions": [row.name for row in _discussions_in_window(window)], + "revoked": _revoked(frappe.parse_json(cached) or []), "synced_at": str(synced_at), } @@ -112,6 +119,14 @@ def _discussions_in_window(window): return rows +def _revoked(names): + names = [str(name) for name in names[:MAX_CACHED]] + if not names: + return [] + readable = {str(row.name) for row in _query("GP Discussion", ["name"], {"name": ["in", names]})} + return [name for name in names if name not in readable] + + def _changed_since(rows, since): """Discussions edited, replied to or reacted in after `since`. diff --git a/gameplan/tests/features/test_offline_downloads.py b/gameplan/tests/features/test_offline_downloads.py index 679dbe296..a12389d27 100644 --- a/gameplan/tests/features/test_offline_downloads.py +++ b/gameplan/tests/features/test_offline_downloads.py @@ -112,6 +112,23 @@ def test_leaving_a_private_space_drops_its_discussions(self): private.save(ignore_permissions=True) self.assertNotIn(str(secret.name), self.index(90)) + def test_reports_visited_discussions_the_user_can_no_longer_read(self): + private = create_space("Secret", self.community, is_private=1, members=[self.member]) + secret = create_discussion("Secret thread", private, owner=self.member) + gone = create_discussion("Gone thread", self.joined, owner=self.member) + cached = [str(self.elsewhere.name), str(secret.name), str(gone.name)] + with self.as_user(self.member): + self.assertEqual(get_offline_index(30, json.dumps(cached))["revoked"], []) + + private.reload() + private.members = [] + private.save(ignore_permissions=True) + frappe.delete_doc("GP Discussion", gone.name, ignore_permissions=True) + with self.as_user(self.member): + revoked = get_offline_index(30, json.dumps(cached))["revoked"] + # The public space's discussion stays: reading it never needed membership. + self.assertEqual(sorted(revoked), sorted([str(secret.name), str(gone.name)])) + def test_rejects_an_empty_window(self): with self.as_user(self.member), self.assertRaises(frappe.ValidationError): get_offline_index(0) From b81245b5ee3bf368848ee884a921a5d56fccdb20 Mon Sep 17 00:00:00 2001 From: ebrahimgamdiwala Date: Fri, 18 Sep 2026 07:20:51 +0000 Subject: [PATCH 36/68] fix(offline): download discussions missing from the device, not just changed ones After the first download, a sync only fetched discussions that changed since the last one. A space joined later brought discussions that were in the index but unchanged, so they were listed as downloaded without ever being fetched, and opened offline as unavailable. A sync now counts only what is really on the device as downloaded. It fetches changes to those, then everything else in the index by name, which also covers a first download and an interrupted one. This replaces the resume cursor. Co-Authored-By: Claude Opus 5 --- frontend/src/data/offlineDownloads.ts | 83 ++++++++++++------- frontend/tests/offline/us9.js | 38 ++++++++- gameplan/offline_downloads.py | 11 ++- .../tests/features/test_offline_downloads.py | 8 ++ 4 files changed, 108 insertions(+), 32 deletions(-) diff --git a/frontend/src/data/offlineDownloads.ts b/frontend/src/data/offlineDownloads.ts index e654bb948..b99249b40 100644 --- a/frontend/src/data/offlineDownloads.ts +++ b/frontend/src/data/offlineDownloads.ts @@ -37,6 +37,8 @@ const INDEX = 'gameplan.offline_downloads.get_offline_index' const BUNDLE = 'gameplan.offline_downloads.get_offline_bundle' const META_KEY = 'gameplan:offline-downloads' const LOCK_NAME = 'gameplan-offline-downloads' +// Matches the server's page size (gameplan/offline_downloads.py). +const PAGE_SIZE = 20 // Background syncs only fetch what changed, but still cost an index query each; this keeps // them to a few a day per device. const SYNC_INTERVAL = 6 * 60 * 60 * 1000 @@ -48,10 +50,11 @@ interface Meta { window: number /** Server time the last complete sync started; the next one asks for changes since. */ since: string | null + /** Discussions downloaded to this device. */ names: string[] lastSyncedAt: number | null - /** Where an interrupted sync stopped, so the next one carries on from there. */ - cursor: { start: number; since: string | null; syncedAt: string } | null + /** The last sync stopped before finishing, so the next one runs whenever it can. */ + incomplete: boolean } interface Bundle { @@ -137,8 +140,7 @@ async function sync(manual: boolean): Promise { if (!isOnline.value) return false if (!manual) { if (document.visibilityState !== 'visible' || saveData()) return false - // An interrupted sync resumes whenever it can, however recent the last complete one. - const fresh = current?.window === days && !current.cursor && current.lastSyncedAt + const fresh = current?.window === days && !current.incomplete && current.lastSyncedAt if (fresh && Date.now() - fresh < SYNC_INTERVAL) return true } @@ -149,7 +151,10 @@ async function sync(manual: boolean): Promise { ) } -/** Returns whether the sync finished; losing the connection pauses it at the saved cursor. */ +/** + * Returns whether the sync finished. Losing the connection stops it; the next run fetches + * whatever is still missing. + */ async function runSync(days: OfflineWindow): Promise { downloads.syncing = true downloads.error = null @@ -157,7 +162,8 @@ async function runSync(days: OfflineWindow): Promise { downloads.total = 0 try { const previous = meta ?? (await readMeta()) - const visited = (await cachedDiscussions()).filter((name) => !previous?.names.includes(name)) + const onDevice = new Set(await cachedDiscussions()) + const visited = [...onDevice].filter((name) => !previous?.names.includes(name)) const index = await call<{ discussions: string[]; revoked: string[]; synced_at: string }>( INDEX, { window_days: days, cached: visited }, @@ -166,43 +172,64 @@ async function runSync(days: OfflineWindow): Promise { const dropped = (previous?.names ?? []).filter((name) => !names.includes(name)) await forgetDiscussions([...dropped, ...index.revoked]) + // Only what is really on the device counts as downloaded. A new window starts over. const sameWindow = previous?.window === days - const since = sameWindow ? previous.since : null - const resume = sameWindow && previous.cursor?.since === since ? previous.cursor : null - let start = resume?.start ?? 0 - const syncedAt = resume?.syncedAt ?? index.synced_at - downloads.total = since ? 0 : names.length - downloads.done = since ? 0 : start - + const stored = new Set( + sameWindow ? previous.names.filter((name) => names.includes(name) && onDevice.has(name)) : [], + ) const base: Meta = { user: session.user!, window: days, - since, - names, + since: sameWindow ? previous.since : null, + names: [...stored], lastSyncedAt: sameWindow ? previous.lastSyncedAt : null, - cursor: { start, since, syncedAt }, + incomplete: true, } await writeMeta(base) - let hasNext = true - while (hasNext) { - // Picks up on the next reconnect or app load, from the saved cursor. - if (!isOnline.value) return false + const fetchPage = async (params: Record) => { const bundle = await call(BUNDLE, { window_days: days, - since, - start, fields: { comments: COMMENT_FIELDS, activities: ACTIVITY_FIELDS, polls: POLL_FIELDS }, + ...params, }) await storeBundle(bundle) - start += bundle.discussions.length - downloads.done += bundle.discussions.length - hasNext = bundle.has_next_page - await writeMeta({ ...base, cursor: { start, since, syncedAt } }) - if (hasNext) await idle() + for (const discussion of bundle.discussions) stored.add(String(discussion.name)) + await writeMeta({ ...base, names: [...stored] }) + return bundle } - await writeMeta({ ...base, since: syncedAt, lastSyncedAt: Date.now(), cursor: null }) + // Changes to what the device already holds. + if (base.since && stored.size) { + let start = 0 + let hasNext = true + while (hasNext) { + if (!isOnline.value) return false + const bundle = await fetchPage({ since: base.since, start }) + start += bundle.discussions.length + hasNext = bundle.has_next_page + if (hasNext) await idle() + } + } + + // Everything not on the device yet: a first download, a space joined since the last sync, + // or what an interrupted sync didn't reach. + const missing = names.filter((name) => !stored.has(name)) + downloads.total = missing.length + for (let i = 0; i < missing.length; i += PAGE_SIZE) { + if (!isOnline.value) return false + if (i) await idle() + await fetchPage({ names: missing.slice(i, i + PAGE_SIZE) }) + downloads.done = Math.min(i + PAGE_SIZE, missing.length) + } + + await writeMeta({ + ...base, + names: [...stored], + since: index.synced_at, + lastSyncedAt: Date.now(), + incomplete: false, + }) return true } catch (error) { downloads.error = error instanceof Error ? error.message : String(error) diff --git a/frontend/tests/offline/us9.js b/frontend/tests/offline/us9.js index 3e150da02..948b369a2 100644 --- a/frontend/tests/offline/us9.js +++ b/frontend/tests/offline/us9.js @@ -2,7 +2,9 @@ // discussions from joined spaces with recent activity, so one never opened before reads // offline with its comments. Content outside the window still shows the honest offline // fallback, the download costs about one request per 20 discussions, a reload soon after -// doesn't download again, and a visited copy of a discussion that's since gone is removed. +// doesn't download again, a visited copy of a discussion that's since gone is removed, and +// Sync now fetches a discussion missing from the device even though it hasn't changed (a +// space joined after the first download). const { chromium, BASE, @@ -21,6 +23,23 @@ const OFFLINE_API = /gameplan\.offline_downloads\.get_offline_(index|bundle)/ // Stands in for a discussion the user opened that has since been deleted. const GONE_KEY = 'doc:GP Discussion/999999999' +function deleteIdbKey(page, key) { + return page.evaluate( + (k) => + new Promise((resolve, reject) => { + const req = indexedDB.open('keyval-store') + req.onsuccess = () => { + const tx = req.result.transaction('keyval', 'readwrite') + tx.objectStore('keyval').delete(k) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + } + req.onerror = () => reject(req.error) + }), + key, + ) +} + function putIdbKey(page, key) { return page.evaluate( (k) => @@ -116,6 +135,23 @@ async function run() { symptom: `${offlineRequests.length} offline-download requests, expected ${expectedRequests}`, }) + const createdKey = `doc:GP Discussion/${created}` + await deleteIdbKey(page, createdKey) + await page + .getByText('Discussions are ready to read offline') + .waitFor({ state: 'detached', timeout: 15000 }) + .catch(() => {}) + await page.getByRole('button', { name: 'Sync now' }).click() + await page + .getByText('Discussions are ready to read offline') + .waitFor({ timeout: 30000 }) + .catch(() => {}) + result.checks.push({ + name: 'Sync now fetches a discussion missing from the device though it has not changed', + pass: (await idbKeyvalKeys(page)).includes(createdKey), + symptom: `${createdKey} after Sync now: ${(await idbKeyvalKeys(page)).includes(createdKey) ? 'present' : 'missing'}`, + }) + await page.goto(URLS.feed, { waitUntil: 'load', timeout: 15000 }) await page.waitForTimeout(2000) await context.setOffline(true) diff --git a/gameplan/offline_downloads.py b/gameplan/offline_downloads.py index 83df0598c..219fffee0 100644 --- a/gameplan/offline_downloads.py +++ b/gameplan/offline_downloads.py @@ -47,19 +47,24 @@ def get_offline_index(window_days, cached=None): @frappe.whitelist(methods=["POST"]) -def get_offline_bundle(window_days, fields, since=None, start=0): +def get_offline_bundle(window_days, fields, since=None, start=0, names=None): """One page of discussions in the window, each with its comments, activity and polls. `fields` maps comments/activities/polls to the field lists the app's own lists request, so the rows come back in exactly the shape those lists cache. With `since`, only - discussions that changed after it are included. + discussions that changed after it are included. With `names`, just those (a page's worth), + for a device fetching what it doesn't hold yet. """ window = _allowed_window(window_days) fields = frappe.parse_json(fields) start = cint(start) rows = _discussions_in_window(window) - if since: + if names: + wanted = {str(name) for name in frappe.parse_json(names)[:PAGE_SIZE]} + rows = [row for row in rows if row.name in wanted] + start = 0 + elif since: rows = _changed_since(rows, get_datetime(since)) page = rows[start : start + PAGE_SIZE] names = [row.name for row in page] diff --git a/gameplan/tests/features/test_offline_downloads.py b/gameplan/tests/features/test_offline_downloads.py index a12389d27..656aba01d 100644 --- a/gameplan/tests/features/test_offline_downloads.py +++ b/gameplan/tests/features/test_offline_downloads.py @@ -169,6 +169,14 @@ def test_pages_through_the_window(self): self.assertEqual(len(names), offline_downloads.PAGE_SIZE + 1) self.assertEqual(len(set(names)), len(names)) + def test_names_fetches_just_those_within_the_window(self): + other = create_discussion("Another thread", self.joined, owner=self.member) + wanted = [str(other.name), str(self.old.name), str(self.elsewhere.name)] + bundle = self.bundle(30, names=json.dumps(wanted)) + # The old thread is outside 30 days and the other space isn't joined. + self.assertEqual([str(d["name"]) for d in bundle["discussions"]], [str(other.name)]) + self.assertFalse(bundle["has_next_page"]) + def test_since_returns_only_changed_discussions(self): since = add_to_date(now_datetime(), minutes=-5) set_modified("GP Discussion", self.recent.name, add_to_date(since, minutes=-10)) From ca8db14c77acf72bae075b29115f6f7937e995f1 Mon Sep 17 00:00:00 2001 From: ebrahimgamdiwala Date: Fri, 18 Sep 2026 07:30:43 +0000 Subject: [PATCH 37/68] feat(offline): download images and custom emojis with discussions A downloaded discussion opened offline showed broken images, and custom emojis never seen in the browser were missing everywhere. Each sync now asks the service worker to save the uploaded images inside downloaded discussions and comments, plus the custom emojis, in the runtime cache. Images already saved are skipped, and at most 300 are added per sync. Images of discussions the device drops (deleted, access lost, out of the window) are removed with them, and logout clears them with the rest of the runtime cache. Co-Authored-By: Claude Opus 5 --- frontend/src/data/offlineDownloads.ts | 53 ++++++++++++++++++++++----- gameplan/www/gameplan-sw.js | 50 +++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/frontend/src/data/offlineDownloads.ts b/frontend/src/data/offlineDownloads.ts index b99249b40..50e9df49e 100644 --- a/frontend/src/data/offlineDownloads.ts +++ b/frontend/src/data/offlineDownloads.ts @@ -1,9 +1,10 @@ import { computed, reactive, watch } from 'vue' import { useLocalStorage } from '@vueuse/core' import { call, dialog, toast } from 'frappe-ui' -import { delMany, get, keys, set, setMany } from 'idb-keyval' +import { delMany, get, getMany, keys, set, setMany } from 'idb-keyval' import { isOnline, onReconnect } from './online' import { session } from './session' +import { customEmojis } from './customEmojis' import { ACTIVITY_FIELDS, COMMENT_FIELDS, @@ -39,6 +40,8 @@ const META_KEY = 'gameplan:offline-downloads' const LOCK_NAME = 'gameplan-offline-downloads' // Matches the server's page size (gameplan/offline_downloads.py). const PAGE_SIZE = 20 +// Images one sync may add. Already saved ones are skipped, so later syncs add only new ones. +const MAX_IMAGES = 300 // Background syncs only fetch what changed, but still cost an index query each; this keeps // them to a few a day per device. const SYNC_INTERVAL = 6 * 60 * 60 * 1000 @@ -187,6 +190,7 @@ async function runSync(days: OfflineWindow): Promise { } await writeMeta(base) + const images = new Set() const fetchPage = async (params: Record) => { const bundle = await call(BUNDLE, { window_days: days, @@ -195,6 +199,7 @@ async function runSync(days: OfflineWindow): Promise { }) await storeBundle(bundle) for (const discussion of bundle.discussions) stored.add(String(discussion.name)) + for (const url of bundleImages(bundle)) images.add(url) await writeMeta({ ...base, names: [...stored] }) return bundle } @@ -223,6 +228,9 @@ async function runSync(days: OfflineWindow): Promise { downloads.done = Math.min(i + PAGE_SIZE, missing.length) } + const emojis = (customEmojis.data ?? []).map((emoji) => emoji.image).filter(Boolean) + saveImages([...emojis, ...images].slice(0, MAX_IMAGES) as string[]) + await writeMeta({ ...base, names: [...stored], @@ -265,14 +273,41 @@ async function cachedDiscussions() { async function forgetDiscussions(names: string[]) { if (!names.length) return const user = session.user! - await delMany( - names.flatMap((name) => [ - docKey('GP Discussion', name), - listKey(commentsCacheKey('GP Discussion', name, user)), - listKey(activitiesCacheKey('GP Discussion', name, user)), - listKey(pollsCacheKey(name, user)), - ]), - ) + const docKeys = names.map((name) => docKey('GP Discussion', name)) + const commentKeys = names.map((name) => listKey(commentsCacheKey('GP Discussion', name, user))) + // Their images go too, so a discussion the user lost access to leaves nothing behind. + const stored = await getMany([...docKeys, ...commentKeys]).catch(() => []) + forgetImages(stored.flatMap((value) => (value ? htmlImages(value) : []))) + await delMany([ + ...docKeys, + ...commentKeys, + ...names.map((name) => listKey(activitiesCacheKey('GP Discussion', name, user))), + ...names.map((name) => listKey(pollsCacheKey(name, user))), + ]) +} + +function bundleImages(bundle: Bundle) { + return [ + ...bundle.discussions.flatMap((discussion) => htmlImages(discussion.content)), + ...Object.values(bundle.comments) + .flat() + .flatMap((comment) => htmlImages(comment.content)), + ] +} + +/** Uploaded images an HTML string (or a stored JSON copy of rows holding HTML) shows. */ +function htmlImages(html: unknown) { + if (typeof html !== 'string') return [] + return [...html.matchAll(/]+src=\\?["']([^"'\\]+)/g)].map(([, src]) => src) +} + +// The service worker keeps the images (gameplan-sw.js); without one there's nowhere to put them. +function saveImages(urls: string[]) { + if (urls.length) navigator.serviceWorker?.controller?.postMessage({ type: 'CACHE_IMAGES', urls }) +} + +function forgetImages(urls: string[]) { + if (urls.length) navigator.serviceWorker?.controller?.postMessage({ type: 'FORGET_IMAGES', urls }) } /** Deletes everything downloaded; a discussion opened again online is cached as usual. */ diff --git a/gameplan/www/gameplan-sw.js b/gameplan/www/gameplan-sw.js index 16b9e42fb..d74516802 100644 --- a/gameplan/www/gameplan-sw.js +++ b/gameplan/www/gameplan-sw.js @@ -66,6 +66,16 @@ self.addEventListener("message", (event) => { return; } + if (type === "CACHE_IMAGES" && Array.isArray(event.data.urls)) { + event.waitUntil(cacheImages(event.data.urls)); + return; + } + + if (type === "FORGET_IMAGES" && Array.isArray(event.data.urls)) { + event.waitUntil(forgetImages(event.data.urls)); + return; + } + if (type === "SKIP_WAITING") { self.skipWaiting(); return; @@ -215,6 +225,46 @@ async function cacheUrls(urls) { ); } +// Offline downloads (offlineDownloads.ts): images inside downloaded discussions and custom +// emojis. Kept in the runtime cache with images seen while browsing, so logout clears them. +const IMAGE_FETCHES_AT_ONCE = 4; + +async function cacheImages(urls) { + const cache = await caches.open(RUNTIME_CACHE); + const queue = urls.filter(isUploadedFileUrl); + // A few at a time, so a first download doesn't send every image request at once. + const next = async () => { + while (queue.length) { + const request = new Request(queue.shift(), { credentials: "include" }); + try { + if (await cache.match(request)) continue; + const response = await fetch(request); + if (isCacheableResponse(response)) await cache.put(request, response); + } catch { + // The next sync, or viewing the image online, tries again. + } + } + }; + await Promise.all(Array.from({ length: IMAGE_FETCHES_AT_ONCE }, next)); +} + +async function forgetImages(urls) { + const cache = await caches.open(RUNTIME_CACHE); + await Promise.all(urls.filter(isUploadedFileUrl).map((url) => cache.delete(url))); +} + +function isUploadedFileUrl(url) { + try { + const { origin, pathname } = new URL(url, self.location.origin); + return ( + origin === self.location.origin && + (pathname.startsWith("/files/") || pathname.startsWith("/private/files/")) + ); + } catch { + return false; + } +} + async function cacheOfflineAssetManifest(response) { try { const manifestResponse = From 460401b29c1f1bdb5089719e575744f38f4e391d Mon Sep 17 00:00:00 2001 From: ebrahimgamdiwala Date: Fri, 18 Sep 2026 07:49:26 +0000 Subject: [PATCH 38/68] fix(sw): delete old build files from the asset cache Build files are content-hashed, so every deploy added a new ~5 MB set under the same asset cache and nothing ever removed the previous ones. After caching the current build's manifest, the worker now deletes files under Gameplan's build folder that the manifest no longer lists. Icons and Frappe's own assets are left alone. Co-Authored-By: Claude Opus 5 --- gameplan/www/gameplan-sw.js | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/gameplan/www/gameplan-sw.js b/gameplan/www/gameplan-sw.js index d74516802..369351d97 100644 --- a/gameplan/www/gameplan-sw.js +++ b/gameplan/www/gameplan-sw.js @@ -273,14 +273,35 @@ async function cacheOfflineAssetManifest(response) { if (!isCacheableResponse(manifestResponse)) return; const urls = await manifestResponse.clone().json(); - if (Array.isArray(urls)) { + if (Array.isArray(urls) && urls.length) { await cacheUrls(urls); + await deleteOldBuildAssets(urls); } } catch { // Older builds do not have the manifest; route assets will still be cached as they load. } } +// Build files are content-hashed, so every deploy adds a new set (~5 MB) under the same +// ASSET_CACHE. Keep only the current build's; the server no longer has the old ones anyway. +const BUILD_ASSETS_PATH = "/assets/gameplan/frontend/assets/"; + +async function deleteOldBuildAssets(currentUrls) { + const current = new Set( + currentUrls.map((url) => new URL(url, self.location.origin).pathname), + ); + const cache = await caches.open(ASSET_CACHE); + const requests = await cache.keys(); + await Promise.all( + requests + .filter((request) => { + const { pathname } = new URL(request.url); + return pathname.startsWith(BUILD_ASSETS_PATH) && !current.has(pathname); + }) + .map((request) => cache.delete(request)), + ); +} + function isSameOriginAssetUrl(url) { try { const assetUrl = new URL(url, self.location.origin); From abeab0b55d81a67437bb5d5b5bdb7b3ef0f51da1 Mon Sep 17 00:00:00 2001 From: ebrahimgamdiwala Date: Fri, 18 Sep 2026 07:59:11 +0000 Subject: [PATCH 39/68] test(offline): fix US2, US3 and US4 - US2: the visited space was set to Game Design while the visited discussion lives in Art, so the link it clicks was never in the list (the space checks passed only because "Art" is also in the sidebar). The space defaults now match the seeded discussions, and the link uses DISCUSSION_ID. - US3: looked for any text matching /offline/i, which the test account's name ("Offline Tester") always matched. It now looks for the banner itself. - US4: expected an error after submitting offline, but Submit is now disabled offline. It checks that the button is disabled, that tapping it shows the offline toast, and that the typed text is kept. The full offline suite now passes (13 of 13). Co-Authored-By: Claude Opus 5 --- frontend/tests/offline/README.md | 6 +-- frontend/tests/offline/config.js | 6 ++- frontend/tests/offline/us2.js | 3 +- frontend/tests/offline/us3.js | 70 ++++++++------------------------ frontend/tests/offline/us4.js | 46 ++++++++------------- 5 files changed, 43 insertions(+), 88 deletions(-) diff --git a/frontend/tests/offline/README.md b/frontend/tests/offline/README.md index 0015be259..164cba6be 100644 --- a/frontend/tests/offline/README.md +++ b/frontend/tests/offline/README.md @@ -15,7 +15,7 @@ survives reboots and can gate regressions in CI/local dev. | US1 | App shell loads offline (reload + deep link) instead of a browser error page | | US2 | Previously-viewed feed / space / discussion render from cache while offline | | US3 | Offline indicator appears when connectivity drops, clears on reconnect | -| US4 | A comment typed while offline fails gracefully and isn't lost | +| US4 | While offline, Submit is disabled and says why; the typed comment is kept and posts on reconnect | | US5 | Fresh data appears automatically on reconnect, no manual reload | | US6 | Never-cached content shows an honest "can't load this offline" fallback | | P2 | A profile visited fully online (incl. Posts tab) is available offline | @@ -85,9 +85,9 @@ All seeded-content coupling lives in `config.js`, overridable via env vars: | `GAMEPLAN_OFFLINE_USER` / `GAMEPLAN_OFFLINE_PASSWORD` | `offline-tester@example.com` / `offline-test-1234` | Primary test account | | `GAMEPLAN_OFFLINE_USER2` / `GAMEPLAN_OFFLINE_PASSWORD2` | `offline-tester-2@example.com` / `offline-test-1234` | Second account (US7b) | | `GAMEPLAN_OFFLINE_COMMUNITY` | `common-room` | `GP Team` name both accounts belong to | -| `GAMEPLAN_OFFLINE_SPACE_ID` | `3` | `GP Project` name for the visited/cached space | +| `GAMEPLAN_OFFLINE_SPACE_ID` | `5` | `GP Project` holding the visited discussion | | `GAMEPLAN_OFFLINE_DISCUSSION_ID` | `55` | `GP Discussion` name for the visited/cached discussion | -| `GAMEPLAN_OFFLINE_UNCACHED_SPACE_ID` / `GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_SPACE_ID` / `GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_ID` | `4` / `5` / `54` | Content never visited by any story before going offline (US6) | +| `GAMEPLAN_OFFLINE_UNCACHED_SPACE_ID` / `GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_SPACE_ID` / `GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_ID` | `4` / `7` / `54` | Content never visited by any story before going offline (US6) | | `GAMEPLAN_OFFLINE_PERSON_VISITED` / `GAMEPLAN_OFFLINE_PERSON_NO_PREFETCH` | `maya-iyer` / `hana-suzuki` | `GP User Profile` IDs for P2/P3 | | `GAMEPLAN_OFFLINE_RESULTS_DIR` | `tests/offline/results` | Where JSON results + screenshots are written | diff --git a/frontend/tests/offline/config.js b/frontend/tests/offline/config.js index 6363d0b44..47d4db022 100644 --- a/frontend/tests/offline/config.js +++ b/frontend/tests/offline/config.js @@ -18,12 +18,13 @@ const FULL_NAME2 = process.env.GAMEPLAN_OFFLINE_FULL_NAME2 || 'Offline Tester Tw // Seeded content coordinates (GP Team/GP Project/GP Discussion names) — see README.md. const COMMUNITY = process.env.GAMEPLAN_OFFLINE_COMMUNITY || 'common-room' -const SPACE_ID = process.env.GAMEPLAN_OFFLINE_SPACE_ID || '3' +// The space discussion DISCUSSION_ID belongs to (Art on the seeded site). +const SPACE_ID = process.env.GAMEPLAN_OFFLINE_SPACE_ID || '5' const DISCUSSION_ID = process.env.GAMEPLAN_OFFLINE_DISCUSSION_ID || '55' // Never visited by any story before going offline -> used for US6 "uncached content". const UNCACHED_SPACE_ID = process.env.GAMEPLAN_OFFLINE_UNCACHED_SPACE_ID || '4' const UNCACHED_DISCUSSION_SPACE_ID = - process.env.GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_SPACE_ID || '5' + process.env.GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_SPACE_ID || '7' const UNCACHED_DISCUSSION_ID = process.env.GAMEPLAN_OFFLINE_UNCACHED_DISCUSSION_ID || '54' // A space the primary account has joined (US9 downloads only cover joined spaces). const JOINED_SPACE_ID = process.env.GAMEPLAN_OFFLINE_JOINED_SPACE_ID || '1426' @@ -58,6 +59,7 @@ const SHOTS_DIR = path.join(RESULTS_DIR, 'screenshots') module.exports = { BASE, COMMUNITY, + DISCUSSION_ID, JOINED_SPACE_ID, EMAIL, PWD, diff --git a/frontend/tests/offline/us2.js b/frontend/tests/offline/us2.js index 9d9bdbd4b..04ad08fdc 100644 --- a/frontend/tests/offline/us2.js +++ b/frontend/tests/offline/us2.js @@ -11,6 +11,7 @@ const { appRootInfo, writeResult, } = require('./helpers') +const { DISCUSSION_ID } = require('./config') const EXPECTED = { discussionTitle: 'Capsule art, near-final, need eyes before it goes on the page', @@ -144,7 +145,7 @@ async function run() { try { await page.goto(URLS.spaceDiscussions, { waitUntil: 'load', timeout: 8000 }) await page.waitForTimeout(1500) - const link = page.locator(`a[href*="/discussion/55"]`).first() + const link = page.locator(`a[href*="/discussion/${DISCUSSION_ID}/"]`).first() const linkVisible = await link.isVisible().catch(() => false) clickCheck.linkVisible = linkVisible if (linkVisible) { diff --git a/frontend/tests/offline/us3.js b/frontend/tests/offline/us3.js index 00d1a2196..da3600814 100644 --- a/frontend/tests/offline/us3.js +++ b/frontend/tests/offline/us3.js @@ -1,52 +1,18 @@ // US3 — Know I'm offline: an indicator should appear when offline and clear on -// reconnect. We search broadly (role=status/alert, common banner/toast/pill classes, -// and any element whose text matches /offline/i) since we don't know the exact -// implementation up front. -const { - chromium, - URLS, - EMAIL, - newLoggedInContext, - warmup, - shot, - writeResult, -} = require('./helpers') +// reconnect. Looks for the banner OfflineIndicator.vue renders (a role=status element +// reading "Offline") rather than any text matching /offline/i, which also matched the test +// account's own name ("Offline Tester") and so always looked offline. +const { chromium, newLoggedInContext, warmup, shot, writeResult } = require('./helpers') -// The seeded test account's own username ("offline-tester") literally contains -// "offline" and renders on the page regardless of connectivity (header/sidebar/hover -// cards showing the signed-in user's name) — exclude an exact match on it so it can't -// masquerade as the real connectivity indicator below. -const USERNAME = EMAIL.split('@')[0] - -async function findOfflineIndicator(page, excludeExact) { - return page.evaluate((exclude) => { - const re = /offline|you.?re offline|showing saved|no connection|reconnect/i - const candidates = [] - const all = document.querySelectorAll('body *') - for (const el of all) { - // Only leaf-ish elements with direct text, to avoid matching giant containers. - const text = el.textContent?.trim() || '' - if (!text || text.length > 200) continue - if (re.test(text)) { - const ownText = Array.from(el.childNodes) - .filter((n) => n.nodeType === Node.TEXT_NODE) - .map((n) => n.textContent) - .join('') - .trim() - if (ownText && re.test(ownText) && ownText.toLowerCase() !== exclude.toLowerCase()) { - const rect = el.getBoundingClientRect() - candidates.push({ - tag: el.tagName, - class: el.className?.toString?.() || '', - text: ownText.slice(0, 200), - visible: rect.width > 0 && rect.height > 0, - role: el.getAttribute('role'), - }) - } - } - } - return candidates - }, excludeExact) +function findOfflineBanner(page) { + return page.evaluate(() => + [...document.querySelectorAll('[role="status"]')] + .filter((el) => /^offline$/i.test(el.textContent.trim())) + .map((el) => { + const rect = el.getBoundingClientRect() + return { text: el.textContent.trim(), visible: rect.width > 0 && rect.height > 0 } + }), + ) } async function run() { @@ -58,7 +24,7 @@ async function run() { result.warmup = await warmup(page) // Baseline (online): no offline indicator should be present. - const onlineCandidates = await findOfflineIndicator(page, USERNAME) + const onlineCandidates = await findOfflineBanner(page) result.onlineBaseline = { candidates: onlineCandidates } await context.setOffline(true) @@ -70,7 +36,7 @@ async function run() { await page.evaluate(() => window.dispatchEvent(new Event('offline'))) await page.waitForTimeout(1000) - const offlineCandidates = await findOfflineIndicator(page, USERNAME) + const offlineCandidates = await findOfflineBanner(page) const offlineShot = await shot(page, 'us3-offline-indicator-search') const visibleOfflineCandidates = offlineCandidates.filter((c) => c.visible) @@ -81,15 +47,15 @@ async function run() { pass: visibleOfflineCandidates.length > 0, } check1.symptom = check1.pass - ? `found ${visibleOfflineCandidates.length} visible offline-related element(s)` - : 'no offline indicator UI exists anywhere in the DOM (searched all elements for /offline|reconnect|no connection/i text)' + ? 'offline banner shown' + : 'no visible offline banner (role=status reading "Offline")' result.checks.push(check1) // Go back online and check the indicator clears (only meaningful if one appeared). await context.setOffline(false) await page.evaluate(() => window.dispatchEvent(new Event('online'))) await page.waitForTimeout(3000) - const afterOnlineCandidates = await findOfflineIndicator(page, USERNAME) + const afterOnlineCandidates = await findOfflineBanner(page) const stillVisible = afterOnlineCandidates.filter((c) => c.visible) const afterOnlineShot = await shot(page, 'us3-after-reconnect') diff --git a/frontend/tests/offline/us4.js b/frontend/tests/offline/us4.js index 44db5060c..c6f5d72b1 100644 --- a/frontend/tests/offline/us4.js +++ b/frontend/tests/offline/us4.js @@ -1,5 +1,6 @@ -// US4 — Don't lose my words: offline comment submit should fail gracefully (error -// surfaced, text preserved in the editor), then succeed once back online. +// US4 — Don't lose my words: while offline, Submit is disabled and tapping it says why +// (instead of failing the request), the typed text stays in the editor, and it submits +// once back online. const { chromium, URLS, @@ -41,21 +42,6 @@ async function bodyTextSnapshot(page) { } } -function findNewErrorText(before, after) { - const re = /(fail|error|offline|network|could not|try again|unable|no internet|not connected)/i - const beforeLines = new Set( - before - .split('\n') - .map((l) => l.trim()) - .filter(Boolean), - ) - const afterLines = after - .split('\n') - .map((l) => l.trim()) - .filter(Boolean) - return afterLines.filter((l) => re.test(l) && !beforeLines.has(l)) -} - async function run() { const browser = await chromium.launch({ headless: true }) const { context, page, consoleErrors, pageErrors } = await newLoggedInContext(browser) @@ -71,34 +57,34 @@ async function run() { await context.setOffline(true) const editor = await getEditor(page) - let check1 = { name: 'offline submit fails gracefully, text preserved' } + let check1 = { name: 'offline: Submit is disabled, tapping it says why, text is kept' } try { await editor.click({ timeout: 8000 }) await page.keyboard.type(DISTINCTIVE_TEXT, { delay: 10 }) await page.waitForTimeout(300) - const beforeSubmitText = await bodyTextSnapshot(page) const submitBtn = await getVisibleSubmitButton(page) - await submitBtn.click({ timeout: 8000 }).catch((e) => { + const disabled = await submitBtn.isDisabled() + // force: a disabled button takes no clicks, but the tap still reaches the page. + await submitBtn.click({ force: true, timeout: 8000 }).catch((e) => { check1.clickError = String(e) }) - await page.waitForTimeout(2000) - const afterSubmitText = await bodyTextSnapshot(page) - - const newErrorLines = findNewErrorText(beforeSubmitText, afterSubmitText) + await page.waitForTimeout(1000) + const toldOffline = (await bodyTextSnapshot(page)).includes("You're offline") const editorTextAfter = await editor.innerText().catch(() => '') const textPreserved = editorTextAfter.includes(DISTINCTIVE_TEXT) - check1.newErrorLines = newErrorLines - check1.textPreserved = textPreserved + Object.assign(check1, { disabled, toldOffline, textPreserved }) check1.editorTextAfter = editorTextAfter.slice(0, 300) check1.screenshot = await shot(page, 'us4-offline-submit-attempt') - check1.pass = newErrorLines.length > 0 && textPreserved + check1.pass = disabled && toldOffline && textPreserved check1.symptom = check1.pass - ? `error shown (${newErrorLines[0]}) and text preserved` + ? 'Submit disabled, offline toast shown, text kept' : !textPreserved - ? 'typed text was lost from the editor after failed submit (silent data loss)' - : 'no visible error/toast surfaced after offline submit attempt' + ? 'typed text was lost from the editor' + : !disabled + ? 'Submit stayed enabled while offline' + : 'no "You\'re offline" toast after tapping the disabled Submit' } catch (e) { check1.pass = false check1.symptom = `threw: ${e.message}` From 748f387966b6e96954b9231675f7d0a17e72b72b Mon Sep 17 00:00:00 2001 From: ebrahimgamdiwala Date: Fri, 18 Sep 2026 08:10:40 +0000 Subject: [PATCH 40/68] fix(sw): fetch the build manifest only when the build changes Every full page load re-downloaded the offline asset manifest with the HTTP cache bypassed, one request per load that develop never makes. The page's own build files are renamed by each build, so the worker now fetches the manifest only when one of them isn't cached yet (a new build or a first visit), or when it has no stored copy of the manifest. Measured: five reloads went from five manifest requests to none; a new build still fetches it once. Co-Authored-By: Claude Opus 5 --- gameplan/www/gameplan-sw.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/gameplan/www/gameplan-sw.js b/gameplan/www/gameplan-sw.js index 369351d97..edb01429f 100644 --- a/gameplan/www/gameplan-sw.js +++ b/gameplan/www/gameplan-sw.js @@ -182,8 +182,18 @@ async function networkFirstNavigation(request) { async function cacheShellAssets(response) { const urls = getShellAssetUrls(await response.text()); + const [assets, shell] = await Promise.all([ + caches.open(ASSET_CACHE), + caches.open(SHELL_CACHE), + ]); + // The page's own build files are renamed by every build, so all of them being cached means + // this build was seen before and its manifest already fetched. Fetching it again on every + // page load was a request develop never made. + const cached = await Promise.all(urls.map((url) => assets.match(url))); + const newBuild = + cached.some((hit) => !hit) || !(await shell.match(OFFLINE_ASSET_MANIFEST_URL)); await cacheUrls(urls); - await cacheOfflineAssetManifest(); + if (newBuild) await cacheOfflineAssetManifest(); } function getShellAssetUrls(html) { @@ -276,6 +286,8 @@ async function cacheOfflineAssetManifest(response) { if (Array.isArray(urls) && urls.length) { await cacheUrls(urls); await deleteOldBuildAssets(urls); + const shell = await caches.open(SHELL_CACHE); + await shell.put(OFFLINE_ASSET_MANIFEST_URL, manifestResponse); } } catch { // Older builds do not have the manifest; route assets will still be cached as they load. From 3034f5f732383d80d07d4766edb1f6ca30eaa699 Mon Sep 17 00:00:00 2001 From: ebrahimgamdiwala Date: Fri, 18 Sep 2026 08:15:58 +0000 Subject: [PATCH 41/68] feat(offline): offline settings on mobile Mobile has no settings dialog, so Settings > Offline was unreachable there. The tab's content moves into OfflineSettingsPanel, shared by the desktop tab and a new mobile Offline page, opened from a row under Settings on the More page that shows the current choice. The first-offline toast opens that page on mobile. The Sync now and Remove downloads buttons sit under the status line, which a phone-width row had no room for beside it. Co-Authored-By: Claude Opus 5 --- frontend/src/components/MobileMoreMenu.vue | 12 ++ .../components/Settings/OfflineSettings.vue | 164 +----------------- .../Settings/OfflineSettingsPanel.vue | 163 +++++++++++++++++ frontend/src/data/offlineDownloads.ts | 9 +- frontend/src/pages/OfflineSettingsPage.vue | 17 ++ frontend/src/router.ts | 6 + frontend/src/utils/useIsMobile.ts | 5 + 7 files changed, 213 insertions(+), 163 deletions(-) create mode 100644 frontend/src/components/Settings/OfflineSettingsPanel.vue create mode 100644 frontend/src/pages/OfflineSettingsPage.vue diff --git a/frontend/src/components/MobileMoreMenu.vue b/frontend/src/components/MobileMoreMenu.vue index a0312adde..131b17c94 100644 --- a/frontend/src/components/MobileMoreMenu.vue +++ b/frontend/src/components/MobileMoreMenu.vue @@ -76,6 +76,7 @@ import { useSessionUser } from '@/data/users' import { session } from '@/data/session' import { isOnline } from '@/data/online' import { useTheme, type Theme } from '@/utils/useTheme' +import { WINDOW_OPTIONS, offlineWindow, policy } from '@/data/offlineDownloads' interface MoreItem { label: string @@ -114,6 +115,11 @@ const avatarStyle = computed(() => ({ backgroundColor: sessionUser.image_background_color || undefined, })) const userBio = computed(() => sessionUser.bio?.trim()) +const offlineLabel = computed(() => + policy.enabled + ? WINDOW_OPTIONS.find((option) => option.value === offlineWindow.value)?.label + : 'Off', +) const itemGroups = computed(() => { const workspaceItems: MoreItem[] = [ @@ -139,6 +145,12 @@ const itemGroups = computed(() => { onClick: cycleTheme, value: THEME_META[currentTheme.value].label, }, + { + label: 'Offline', + icon: 'lucide-cloud-download', + route: { name: 'OfflineSettings' }, + value: offlineLabel.value, + }, { label: 'Log out', icon: 'lucide-log-out', diff --git a/frontend/src/components/Settings/OfflineSettings.vue b/frontend/src/components/Settings/OfflineSettings.vue index 66452ac11..10c8a25d7 100644 --- a/frontend/src/components/Settings/OfflineSettings.vue +++ b/frontend/src/components/Settings/OfflineSettings.vue @@ -4,169 +4,11 @@ -
-
-

- Offline downloads are turned off for this site. Discussions you open are still kept for - offline reading. -

-
- - - -
-
-
+
diff --git a/frontend/src/components/Settings/OfflineSettingsPanel.vue b/frontend/src/components/Settings/OfflineSettingsPanel.vue new file mode 100644 index 000000000..17227c192 --- /dev/null +++ b/frontend/src/components/Settings/OfflineSettingsPanel.vue @@ -0,0 +1,163 @@ + + + diff --git a/frontend/src/data/offlineDownloads.ts b/frontend/src/data/offlineDownloads.ts index 50e9df49e..979c8b740 100644 --- a/frontend/src/data/offlineDownloads.ts +++ b/frontend/src/data/offlineDownloads.ts @@ -5,6 +5,7 @@ import { delMany, get, getMany, keys, set, setMany } from 'idb-keyval' import { isOnline, onReconnect } from './online' import { session } from './session' import { customEmojis } from './customEmojis' +import { isMobileViewport } from '@/utils/useIsMobile' import { ACTIVITY_FIELDS, COMMENT_FIELDS, @@ -424,6 +425,10 @@ function offerDownload() { } function openOfflineSettings() { - // Imported on demand: the settings module reaches the router, which reaches this module. - import('@/components/Settings').then(({ showSettingsDialog }) => showSettingsDialog('Offline')) + // Imported on demand: the settings module and the router both reach this module. + if (isMobileViewport()) { + import('@/router').then(({ default: router }) => router.push({ name: 'OfflineSettings' })) + } else { + import('@/components/Settings').then(({ showSettingsDialog }) => showSettingsDialog('Offline')) + } } diff --git a/frontend/src/pages/OfflineSettingsPage.vue b/frontend/src/pages/OfflineSettingsPage.vue new file mode 100644 index 000000000..c7c53953b --- /dev/null +++ b/frontend/src/pages/OfflineSettingsPage.vue @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/router.ts b/frontend/src/router.ts index c85cc0de5..d8bfffd45 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -431,6 +431,12 @@ const routes: RouteRecordRaw[] = [ name: 'More', component: () => import('@/pages/MoreMenu.vue'), }, + { + // Mobile's way into Settings > Offline (desktop uses the settings dialog). + path: '/offline', + name: 'OfflineSettings', + component: () => import('@/pages/OfflineSettingsPage.vue'), + }, // Keep old shared space links working while moving canonical URLs under `/community/:communityId/...`. { path: '/space/:spaceId', diff --git a/frontend/src/utils/useIsMobile.ts b/frontend/src/utils/useIsMobile.ts index b63afe94a..e80ed1c9a 100644 --- a/frontend/src/utils/useIsMobile.ts +++ b/frontend/src/utils/useIsMobile.ts @@ -16,3 +16,8 @@ const MOBILE_QUERY = '(max-width: 639.98px)' export function useIsMobile(): Ref { return useMediaQuery(MOBILE_QUERY) } + +/** The same check read once, for code that runs outside a component (useMediaQuery needs one). */ +export function isMobileViewport(): boolean { + return window.matchMedia(MOBILE_QUERY).matches +} From 5cc503622860f84b5e858cc9d58716394ed01b34 Mon Sep 17 00:00:00 2001 From: ebrahimgamdiwala Date: Fri, 18 Sep 2026 08:35:07 +0000 Subject: [PATCH 42/68] refactor(offline): trim comments, drop dead router checks, share failure copy - Comments that retold review history ("Round-4 finding", "Greptile P1", "review finding from PR #516") are rewritten as short explanations of why, or removed; that history lives in the commits and the PR. offline.ts goes from 135 comment lines out of 404 to 42 out of 311. - router.ts: isRouteValidationUnavailable() only called isNetworkUnreliable(), a second offline check could never change the outcome, and an if/else returned the same way on both branches. - loadFailure.ts now owns the offline and error copy (loadFailureCopy) and the offline check (isOfflineError). The discussion page, profile page and the offline space page use them instead of their own wording, so every "not available offline" state says the same thing. Co-Authored-By: Claude Opus 5 --- frontend/src/components/CommentsArea.vue | 8 +- frontend/src/components/CommentsList.vue | 4 +- frontend/src/components/DiscussionView.vue | 7 +- frontend/src/components/LastPostReminder.vue | 3 +- .../src/components/OfflineContentFallback.vue | 6 +- .../ProfileBento/profileBentoSource.ts | 37 +---- frontend/src/components/TaskList.vue | 4 +- frontend/src/data/communities.ts | 3 +- frontend/src/data/communitySpaces.ts | 3 +- frontend/src/data/discussionTimeline.ts | 4 +- frontend/src/data/discussions.ts | 4 +- frontend/src/data/draftStore.ts | 8 +- frontend/src/data/loadFailure.ts | 29 ++-- frontend/src/data/notifications.ts | 3 +- frontend/src/data/online.ts | 7 +- frontend/src/data/people.ts | 10 +- frontend/src/data/session.ts | 20 +-- frontend/src/data/spaces.ts | 3 +- frontend/src/data/users.ts | 9 +- frontend/src/offline.ts | 147 ++++-------------- .../pages/Configure/useCommunitySpaceData.ts | 4 +- frontend/src/pages/Notifications.vue | 3 +- frontend/src/pages/OfflineUnavailable.vue | 10 +- frontend/src/pages/PageGrid.vue | 4 +- frontend/src/pages/PersonProfile.vue | 41 ++--- frontend/src/router.ts | 34 ++-- .../gp_user_profile/gp_user_profile.py | 10 +- gameplan/www/gameplan-sw.js | 30 +--- 28 files changed, 113 insertions(+), 342 deletions(-) diff --git a/frontend/src/components/CommentsArea.vue b/frontend/src/components/CommentsArea.vue index b3c2a61c0..9685ba8e7 100644 --- a/frontend/src/components/CommentsArea.vue +++ b/frontend/src/components/CommentsArea.vue @@ -471,12 +471,8 @@ const activities = useList({ // The parent bumps `activityVersion` with the doc's `modified` on every such action, // so reload the timeline when it changes (skipping the initial undefined -> value // transition on first load, when the list has already fetched on mount). -// -// Both this watch and the `new_activity` socket handler (see onMounted) can fire -// for the same underlying action. Calling `activities.reload()` from both re-enters -// the list's in-flight fetch, which aborts it — and with `staleOnError` that abort -// can leave the timeline stuck showing the cached (stale) snapshot instead of ever -// settling on the fresh one. Debouncing to a single reload avoids the double-fetch. +// This and the `new_activity` socket event can fire for the same action; one debounced +// reload keeps the second from aborting the first and leaving the cached timeline up. const reloadActivities = useDebounceFn(() => activities.reload(), 100) watch( diff --git a/frontend/src/components/CommentsList.vue b/frontend/src/components/CommentsList.vue index 077965cae..31a4956cd 100644 --- a/frontend/src/components/CommentsList.vue +++ b/frontend/src/components/CommentsList.vue @@ -180,9 +180,7 @@ const comments = useList< > >({ doctype: 'GP Comment', - // Scoped to the session user: a discussion's comments can live in a private space, - // so a second account on the same browser must not see them cached offline before - // its own permission-checked fetch resolves (review finding from PR #516). + // Per user, so another account on this browser can't read it offline. cacheKey: ['Comments', props.doctype, props.name, session.user], staleOnError: true, fields: [ diff --git a/frontend/src/components/DiscussionView.vue b/frontend/src/components/DiscussionView.vue index 8dca85f7c..812c0be2d 100644 --- a/frontend/src/components/DiscussionView.vue +++ b/frontend/src/components/DiscussionView.vue @@ -300,8 +300,7 @@
-