mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
fix(pwa): isolate service worker development mode (#568)
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
"bin": "dist/service.js",
|
"bin": "dist/service.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
|
"dev:pwa": "vite --host --port 5174",
|
||||||
"prebuild": "npm run build:icons",
|
"prebuild": "npm run build:icons",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --port 5050",
|
"preview": "vite preview --port 5050",
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
|
const PWA_DEVELOPMENT_SCRIPT = 'dev:pwa'
|
||||||
|
export const DEV_SW_CLEANUP_PATH = '/__moviepilot_dev_sw_cleanup__'
|
||||||
|
|
||||||
|
const devEntryScriptTag = '<script type="module" src="/src/main.ts"></script>'
|
||||||
|
const devEntryScriptUrl = '/src/main.ts'
|
||||||
|
const moviePilotWorkerScripts = ['dev-sw.js?dev-sw', 'service-worker.js']
|
||||||
|
const moviePilotIdentityMessage = 'GET_UNREAD_COUNT'
|
||||||
|
const moviePilotIdentityTimeoutMs = 1500
|
||||||
|
const moviePilotIdentityAttempts = 2
|
||||||
|
|
||||||
|
/** 普通 development mode 仅在显式 dev:pwa 脚本中启用开发 Service Worker。 */
|
||||||
|
export const isPwaDevelopmentEnabled = (mode: string, lifecycleEvent?: string) =>
|
||||||
|
mode === 'development' && lifecycleEvent === PWA_DEVELOPMENT_SCRIPT
|
||||||
|
|
||||||
|
/** 普通开发服务器才执行历史 Service Worker 清理,production preview 保持构建产物行为。 */
|
||||||
|
export const shouldEnableDevServiceWorkerCleanup = (
|
||||||
|
command: 'build' | 'serve',
|
||||||
|
mode: string,
|
||||||
|
isPreview: boolean | undefined,
|
||||||
|
lifecycleEvent?: string,
|
||||||
|
) =>
|
||||||
|
command === 'serve' && mode === 'development' && isPreview !== true && !isPwaDevelopmentEnabled(mode, lifecycleEvent)
|
||||||
|
|
||||||
|
/** 解析当前页面所属的应用根目录,兼容根路径和子路径部署。 */
|
||||||
|
export function resolveDevAppScope(pageUrl: string): URL {
|
||||||
|
return new URL('./', new URL(pageUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Worker 的 scope 和脚本 URL 都必须精确落在当前应用根目录。 */
|
||||||
|
export function isManagedServiceWorkerRegistration(
|
||||||
|
scriptUrl: string,
|
||||||
|
registrationScope: string,
|
||||||
|
pageUrl: string,
|
||||||
|
): boolean {
|
||||||
|
const appScope = resolveDevAppScope(pageUrl)
|
||||||
|
const scope = new URL(registrationScope)
|
||||||
|
const script = new URL(scriptUrl)
|
||||||
|
|
||||||
|
return (
|
||||||
|
scope.href === appScope.href &&
|
||||||
|
moviePilotWorkerScripts.some(workerScript => script.href === new URL(workerScript, appScope).href)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复用现有轻量消息协议确认 Worker 确由 MoviePilot 提供。 */
|
||||||
|
export function isMoviePilotServiceWorkerIdentityResponse(value: unknown): boolean {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
|
||||||
|
return typeof (value as { count?: unknown }).count === 'number'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 身份探测允许有限次重试;持续失败时保持现有注册,避免误清理未知 Worker。 */
|
||||||
|
export async function retryMoviePilotIdentityVerification(
|
||||||
|
verifyOnce: () => Promise<boolean>,
|
||||||
|
attempts: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||||
|
if (await verifyOnce()) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理完成后只允许返回当前 origin,避免开发中间页形成开放重定向。 */
|
||||||
|
export function resolveDevCleanupReturnUrl(requested: string | null, origin: string): URL {
|
||||||
|
if (!requested) return new URL('/', origin)
|
||||||
|
const target = new URL(requested, origin)
|
||||||
|
return target.origin === origin ? target : new URL('/', origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 普通开发服务器不应被历史 Service Worker 控制;受控页面先进入独立清理页,避免旧缓存模块抢先执行。
|
||||||
|
*/
|
||||||
|
export function createDevServiceWorkerCleanupPlugin(): Plugin {
|
||||||
|
const workerScripts = JSON.stringify(moviePilotWorkerScripts)
|
||||||
|
const identityMessage = JSON.stringify(moviePilotIdentityMessage)
|
||||||
|
const identityTimeoutMs = JSON.stringify(moviePilotIdentityTimeoutMs)
|
||||||
|
const identityAttempts = JSON.stringify(moviePilotIdentityAttempts)
|
||||||
|
const retryIdentityVerification = retryMoviePilotIdentityVerification.toString()
|
||||||
|
const entryScriptUrl = JSON.stringify(devEntryScriptUrl)
|
||||||
|
const cleanupPath = JSON.stringify(DEV_SW_CLEANUP_PATH)
|
||||||
|
const cleanupAttemptKeyPrefix = JSON.stringify('moviepilot:dev-sw-cleanup')
|
||||||
|
|
||||||
|
const redirectScript = `
|
||||||
|
(() => {
|
||||||
|
const entryScriptUrl = ${entryScriptUrl}
|
||||||
|
let appStarted = false
|
||||||
|
const startApp = () => {
|
||||||
|
if (appStarted) return
|
||||||
|
appStarted = true
|
||||||
|
const entry = document.createElement('script')
|
||||||
|
entry.type = 'module'
|
||||||
|
entry.src = entryScriptUrl
|
||||||
|
document.head.appendChild(entry)
|
||||||
|
}
|
||||||
|
if (!('serviceWorker' in navigator)) {
|
||||||
|
startApp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const workerScripts = ${workerScripts}
|
||||||
|
const identityMessage = ${identityMessage}
|
||||||
|
const identityTimeoutMs = ${identityTimeoutMs}
|
||||||
|
const identityAttempts = ${identityAttempts}
|
||||||
|
const retryIdentityVerification = ${retryIdentityVerification}
|
||||||
|
const cleanupPath = ${cleanupPath}
|
||||||
|
const appScope = new URL('./', location.href)
|
||||||
|
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
|
||||||
|
const cleanupState = sessionStorage.getItem(cleanupAttemptKey)
|
||||||
|
const getCandidateWorker = registration => {
|
||||||
|
if (new URL(registration.scope).href !== appScope.href) return null
|
||||||
|
return [registration.active, registration.waiting, registration.installing].find(worker => {
|
||||||
|
if (!worker) return false
|
||||||
|
const scriptUrl = new URL(worker.scriptURL).href
|
||||||
|
return workerScripts.some(workerScript => scriptUrl === new URL(workerScript, appScope).href)
|
||||||
|
}) || null
|
||||||
|
}
|
||||||
|
const verifyMoviePilotWorkerOnce = worker => new Promise(resolve => {
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
const finish = result => {
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
channel.port1.close()
|
||||||
|
resolve(result)
|
||||||
|
}
|
||||||
|
const timeout = window.setTimeout(() => finish(false), identityTimeoutMs)
|
||||||
|
channel.port1.onmessage = event => finish(typeof event.data?.count === 'number')
|
||||||
|
try {
|
||||||
|
worker.postMessage({ type: identityMessage }, [channel.port2])
|
||||||
|
} catch {
|
||||||
|
finish(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const verifyMoviePilotWorker = worker =>
|
||||||
|
retryIdentityVerification(() => verifyMoviePilotWorkerOnce(worker), identityAttempts)
|
||||||
|
const hasVerifiedRegistration = async () => {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||||
|
for (const registration of registrations) {
|
||||||
|
const worker = getCandidateWorker(registration)
|
||||||
|
if (worker && await verifyMoviePilotWorker(worker)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const redirectToCleanup = () => {
|
||||||
|
sessionStorage.setItem(cleanupAttemptKey, 'pending')
|
||||||
|
const target = new URL(cleanupPath.slice(1), appScope)
|
||||||
|
target.searchParams.set('return', location.href)
|
||||||
|
location.replace(target.href)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unregister 不会立即解除当前 document 的 controller;应用模块加载前需再导航一次以脱离旧 Worker。
|
||||||
|
if (cleanupState === 'complete') {
|
||||||
|
sessionStorage.removeItem(cleanupAttemptKey)
|
||||||
|
location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void hasVerifiedRegistration().then(hasRegistration => {
|
||||||
|
if (hasRegistration) {
|
||||||
|
redirectToCleanup()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionStorage.removeItem(cleanupAttemptKey)
|
||||||
|
startApp()
|
||||||
|
}).catch(error => {
|
||||||
|
console.warn('[PWA] Failed to inspect historical Service Worker state', error)
|
||||||
|
startApp()
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
`
|
||||||
|
|
||||||
|
const cleanupDocument = `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head><meta charset="UTF-8"><title>MoviePilot Dev Cleanup</title></head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const workerScripts = ${workerScripts}
|
||||||
|
const identityMessage = ${identityMessage}
|
||||||
|
const identityTimeoutMs = ${identityTimeoutMs}
|
||||||
|
const identityAttempts = ${identityAttempts}
|
||||||
|
const retryIdentityVerification = ${retryIdentityVerification}
|
||||||
|
const appScope = new URL('./', location.href)
|
||||||
|
const cleanupAttemptKey = ${cleanupAttemptKeyPrefix} + ':' + encodeURIComponent(appScope.pathname)
|
||||||
|
const resolveReturnUrl = () => {
|
||||||
|
const requested = new URLSearchParams(location.search).get('return')
|
||||||
|
if (!requested) return appScope
|
||||||
|
const target = new URL(requested, appScope)
|
||||||
|
return target.href.startsWith(appScope.href) ? target : appScope
|
||||||
|
}
|
||||||
|
const returnUrl = resolveReturnUrl()
|
||||||
|
const getCandidateWorker = registration => {
|
||||||
|
if (new URL(registration.scope).href !== appScope.href) return null
|
||||||
|
return [registration.active, registration.waiting, registration.installing].find(worker => {
|
||||||
|
if (!worker) return false
|
||||||
|
const scriptUrl = new URL(worker.scriptURL).href
|
||||||
|
return workerScripts.some(workerScript => scriptUrl === new URL(workerScript, appScope).href)
|
||||||
|
}) || null
|
||||||
|
}
|
||||||
|
const verifyMoviePilotWorkerOnce = worker => new Promise(resolve => {
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
const finish = result => {
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
channel.port1.close()
|
||||||
|
resolve(result)
|
||||||
|
}
|
||||||
|
const timeout = window.setTimeout(() => finish(false), identityTimeoutMs)
|
||||||
|
channel.port1.onmessage = event => finish(typeof event.data?.count === 'number')
|
||||||
|
try {
|
||||||
|
worker.postMessage({ type: identityMessage }, [channel.port2])
|
||||||
|
} catch {
|
||||||
|
finish(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const verifyMoviePilotWorker = worker =>
|
||||||
|
retryIdentityVerification(() => verifyMoviePilotWorkerOnce(worker), identityAttempts)
|
||||||
|
const cleanup = async () => {
|
||||||
|
if (sessionStorage.getItem(cleanupAttemptKey) !== 'pending') {
|
||||||
|
throw new Error('Missing Service Worker cleanup context for current application scope')
|
||||||
|
}
|
||||||
|
const registrations = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistrations() : []
|
||||||
|
const managedRegistrations = []
|
||||||
|
for (const registration of registrations) {
|
||||||
|
const worker = getCandidateWorker(registration)
|
||||||
|
if (worker && await verifyMoviePilotWorker(worker)) managedRegistrations.push(registration)
|
||||||
|
}
|
||||||
|
if (!managedRegistrations.length) throw new Error('MoviePilot Service Worker identity verification failed')
|
||||||
|
await Promise.allSettled(managedRegistrations.map(registration => registration.unregister()))
|
||||||
|
|
||||||
|
// 回跳入口后由 head-prepend 脚本完成第二次导航,避免同一 client 继续复用旧模块响应。
|
||||||
|
sessionStorage.setItem(cleanupAttemptKey, 'complete')
|
||||||
|
location.replace(returnUrl.href)
|
||||||
|
}
|
||||||
|
|
||||||
|
void cleanup().catch(error => {
|
||||||
|
console.error('[PWA] Failed to clean stale development Service Worker state', error)
|
||||||
|
document.body.textContent = 'Failed to clean stale development Service Worker state. Reload to retry.'
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'moviepilot:dev-service-worker-cleanup',
|
||||||
|
apply: 'serve',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((request, response, next) => {
|
||||||
|
const pathname = new URL(request.url || '/', 'http://localhost').pathname
|
||||||
|
if (!pathname.endsWith(DEV_SW_CLEANUP_PATH)) {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.statusCode = 200
|
||||||
|
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||||
|
response.setHeader('Cache-Control', 'no-store')
|
||||||
|
response.end(cleanupDocument)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
transformIndexHtml: {
|
||||||
|
order: 'pre',
|
||||||
|
handler(html) {
|
||||||
|
if (!html.includes(devEntryScriptTag)) {
|
||||||
|
throw new Error(`Expected development entry tag: ${devEntryScriptTag}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
html: html.replace(devEntryScriptTag, ''),
|
||||||
|
tags: [{ tag: 'script', children: redirectScript, injectTo: 'head-prepend' }],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import type { IndexHtmlTransformContext, IndexHtmlTransformResult, ViteDevServer } from 'vite'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
createDevServiceWorkerCleanupPlugin,
|
||||||
|
DEV_SW_CLEANUP_PATH,
|
||||||
|
isManagedServiceWorkerRegistration,
|
||||||
|
isMoviePilotServiceWorkerIdentityResponse,
|
||||||
|
isPwaDevelopmentEnabled,
|
||||||
|
retryMoviePilotIdentityVerification,
|
||||||
|
resolveDevAppScope,
|
||||||
|
resolveDevCleanupReturnUrl,
|
||||||
|
shouldEnableDevServiceWorkerCleanup,
|
||||||
|
} from '../../scripts/pwa-development'
|
||||||
|
|
||||||
|
describe('PWA 开发模式', () => {
|
||||||
|
it('仅显式 dev:pwa 脚本启用开发 Service Worker', () => {
|
||||||
|
expect(isPwaDevelopmentEnabled('development', 'dev')).toBe(false)
|
||||||
|
expect(isPwaDevelopmentEnabled('development', 'dev:pwa')).toBe(true)
|
||||||
|
expect(isPwaDevelopmentEnabled('production', 'dev:pwa')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('仅普通 development server 启用历史 Service Worker 清理', () => {
|
||||||
|
expect(shouldEnableDevServiceWorkerCleanup('serve', 'development', false, 'dev')).toBe(true)
|
||||||
|
expect(shouldEnableDevServiceWorkerCleanup('serve', 'development', false, 'dev:pwa')).toBe(false)
|
||||||
|
expect(shouldEnableDevServiceWorkerCleanup('serve', 'production', true, 'preview')).toBe(false)
|
||||||
|
expect(shouldEnableDevServiceWorkerCleanup('build', 'production', false, 'build')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只匹配当前应用 scope 下精确的 MoviePilot Worker URL', () => {
|
||||||
|
const pageUrl = 'http://localhost:5173/moviepilot/#/dashboard'
|
||||||
|
const scope = 'http://localhost:5173/moviepilot/'
|
||||||
|
|
||||||
|
expect(resolveDevAppScope(pageUrl).href).toBe(scope)
|
||||||
|
expect(isManagedServiceWorkerRegistration(`${scope}dev-sw.js?dev-sw`, scope, pageUrl)).toBe(true)
|
||||||
|
expect(isManagedServiceWorkerRegistration(`${scope}service-worker.js`, scope, pageUrl)).toBe(true)
|
||||||
|
expect(isManagedServiceWorkerRegistration(`${scope}unrelated-sw.js`, scope, pageUrl)).toBe(false)
|
||||||
|
expect(
|
||||||
|
isManagedServiceWorkerRegistration(
|
||||||
|
'http://localhost:5173/another-app/service-worker.js',
|
||||||
|
'http://localhost:5173/another-app/',
|
||||||
|
pageUrl,
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
isManagedServiceWorkerRegistration(
|
||||||
|
'http://localhost:5174/moviepilot/dev-sw.js?dev-sw',
|
||||||
|
'http://localhost:5174/moviepilot/',
|
||||||
|
pageUrl,
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只有现有 MoviePilot 消息协议响应才能通过身份确认', () => {
|
||||||
|
expect(isMoviePilotServiceWorkerIdentityResponse({ count: 0 })).toBe(true)
|
||||||
|
expect(isMoviePilotServiceWorkerIdentityResponse({ count: 3 })).toBe(true)
|
||||||
|
expect(isMoviePilotServiceWorkerIdentityResponse({ success: true })).toBe(false)
|
||||||
|
expect(isMoviePilotServiceWorkerIdentityResponse(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('身份确认首次失败后重试,持续失败时保持 fail-closed', async () => {
|
||||||
|
const succeedsOnRetry = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
|
||||||
|
const alwaysFails = vi.fn().mockResolvedValue(false)
|
||||||
|
|
||||||
|
await expect(retryMoviePilotIdentityVerification(succeedsOnRetry, 2)).resolves.toBe(true)
|
||||||
|
expect(succeedsOnRetry).toHaveBeenCalledTimes(2)
|
||||||
|
await expect(retryMoviePilotIdentityVerification(alwaysFails, 2)).resolves.toBe(false)
|
||||||
|
expect(alwaysFails).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('清理完成后只返回当前 origin', () => {
|
||||||
|
const origin = 'http://localhost:5173'
|
||||||
|
|
||||||
|
expect(resolveDevCleanupReturnUrl('/#/dashboard', origin).href).toBe(`${origin}/#/dashboard`)
|
||||||
|
expect(resolveDevCleanupReturnUrl('https://example.com/path', origin).href).toBe(`${origin}/`)
|
||||||
|
expect(resolveDevCleanupReturnUrl(null, origin).href).toBe(`${origin}/`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('在应用入口前检查历史 MoviePilot Service Worker', async () => {
|
||||||
|
const plugin = createDevServiceWorkerCleanupPlugin()
|
||||||
|
const transform = plugin.transformIndexHtml
|
||||||
|
|
||||||
|
expect(transform).toBeTypeOf('object')
|
||||||
|
const entryTag = '<script type="module" src="/src/main.ts"></script>'
|
||||||
|
const result = await (
|
||||||
|
transform as {
|
||||||
|
handler: (html: string, context: IndexHtmlTransformContext) => IndexHtmlTransformResult
|
||||||
|
}
|
||||||
|
).handler(`<main>loading</main>${entryTag}`, {} as IndexHtmlTransformContext)
|
||||||
|
if (!result || Array.isArray(result) || typeof result === 'string') {
|
||||||
|
throw new Error('Expected transformIndexHtml to gate the development entry')
|
||||||
|
}
|
||||||
|
const [script] = result.tags ?? []
|
||||||
|
|
||||||
|
expect(result.html).toBe('<main>loading</main>')
|
||||||
|
expect(script.injectTo).toBe('head-prepend')
|
||||||
|
if (typeof script.children !== 'string') throw new TypeError('Expected an inline cleanup script')
|
||||||
|
const scriptContent = script.children
|
||||||
|
|
||||||
|
expect(scriptContent).toContain('cleanupPath.slice(1)')
|
||||||
|
expect(scriptContent).not.toContain("replace(/^//, '')")
|
||||||
|
expect(scriptContent).toContain('dev-sw.js?dev-sw')
|
||||||
|
expect(scriptContent).toContain('service-worker.js')
|
||||||
|
expect(scriptContent).toContain(DEV_SW_CLEANUP_PATH)
|
||||||
|
expect(scriptContent).toContain('GET_UNREAD_COUNT')
|
||||||
|
expect(scriptContent).toContain('const identityTimeoutMs = 1500')
|
||||||
|
expect(scriptContent).toContain('const identityAttempts = 2')
|
||||||
|
expect(scriptContent).toContain('retryMoviePilotIdentityVerification')
|
||||||
|
expect(scriptContent).toContain('entry.src = entryScriptUrl')
|
||||||
|
expect(scriptContent).toContain('startApp()')
|
||||||
|
expect(scriptContent).toContain('verifyMoviePilotWorkerOnce')
|
||||||
|
expect(scriptContent).toContain('verifyMoviePilotWorker')
|
||||||
|
expect(scriptContent).toContain("cleanupState === 'complete'")
|
||||||
|
expect(scriptContent).toContain("sessionStorage.setItem(cleanupAttemptKey, 'pending')")
|
||||||
|
expect(scriptContent).toContain('encodeURIComponent(appScope.pathname)')
|
||||||
|
expect(scriptContent).toContain('location.reload()')
|
||||||
|
expect(scriptContent).not.toContain('caches.delete')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('开发入口标签缺失时立即失败,避免普通 dev 静默白屏', () => {
|
||||||
|
const transform = createDevServiceWorkerCleanupPlugin().transformIndexHtml
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
(
|
||||||
|
transform as {
|
||||||
|
handler: (html: string, context: IndexHtmlTransformContext) => IndexHtmlTransformResult
|
||||||
|
}
|
||||||
|
).handler('<main>missing entry</main>', {} as IndexHtmlTransformContext),
|
||||||
|
).toThrow('Expected development entry tag')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('提供不缓存的清理页面并只清理 MoviePilot 管理的浏览器状态', () => {
|
||||||
|
const plugin = createDevServiceWorkerCleanupPlugin()
|
||||||
|
let middleware: ((request: { url?: string }, response: ResponseStub, next: () => void) => void) | undefined
|
||||||
|
const use = vi.fn(handler => {
|
||||||
|
middleware = handler
|
||||||
|
})
|
||||||
|
const response: ResponseStub = {
|
||||||
|
statusCode: 0,
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
end: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const configureServer = plugin.configureServer as (server: Pick<ViteDevServer, 'middlewares'>) => void
|
||||||
|
configureServer({ middlewares: { use } } as unknown as Pick<ViteDevServer, 'middlewares'>)
|
||||||
|
middleware?.({ url: DEV_SW_CLEANUP_PATH }, response, vi.fn())
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200)
|
||||||
|
expect(response.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store')
|
||||||
|
expect(response.end).toHaveBeenCalledWith(expect.stringContaining('registration.unregister()'))
|
||||||
|
expect(response.end).toHaveBeenCalledWith(expect.stringContaining('verifyMoviePilotWorker'))
|
||||||
|
expect(response.end).toHaveBeenCalledWith(expect.stringContaining('const identityTimeoutMs = 1500'))
|
||||||
|
expect(response.end).toHaveBeenCalledWith(expect.stringContaining('const identityAttempts = 2'))
|
||||||
|
expect(response.end).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("sessionStorage.getItem(cleanupAttemptKey) !== 'pending'"),
|
||||||
|
)
|
||||||
|
expect(response.end).toHaveBeenCalledWith(expect.stringContaining("const appScope = new URL('./', location.href)"))
|
||||||
|
expect(response.end).not.toHaveBeenCalledWith(expect.stringContaining("new URL('./', returnUrl)"))
|
||||||
|
expect(response.end).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("sessionStorage.setItem(cleanupAttemptKey, 'complete')"),
|
||||||
|
)
|
||||||
|
expect(response.end).not.toHaveBeenCalledWith(expect.stringContaining('caches.delete'))
|
||||||
|
expect(response.end).not.toHaveBeenCalledWith(expect.stringContaining('localStorage.clear'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
interface ResponseStub {
|
||||||
|
statusCode: number
|
||||||
|
setHeader: (name: string, value: string) => void
|
||||||
|
end: (body: string) => void
|
||||||
|
}
|
||||||
+9
-2
@@ -14,6 +14,11 @@ import federation from '@originjs/vite-plugin-federation'
|
|||||||
import topLevelAwait from 'vite-plugin-top-level-await'
|
import topLevelAwait from 'vite-plugin-top-level-await'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { responsiveInputCoreComponentNames } from './src/plugins/vuetify/responsiveInputNames'
|
import { responsiveInputCoreComponentNames } from './src/plugins/vuetify/responsiveInputNames'
|
||||||
|
import {
|
||||||
|
createDevServiceWorkerCleanupPlugin,
|
||||||
|
isPwaDevelopmentEnabled,
|
||||||
|
shouldEnableDevServiceWorkerCleanup,
|
||||||
|
} from './scripts/pwa-development'
|
||||||
|
|
||||||
// 读取 package.json 获取版本号
|
// 读取 package.json 获取版本号
|
||||||
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||||
@@ -21,9 +26,11 @@ const buildTime = new Date().getTime().toString()
|
|||||||
const isTestMode = (mode: string) => mode === 'test' || process.env.VITEST === 'true'
|
const isTestMode = (mode: string) => mode === 'test' || process.env.VITEST === 'true'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(({ mode }) => ({
|
export default defineConfig(({ command, mode, isPreview }) => ({
|
||||||
base: './',
|
base: './',
|
||||||
plugins: [
|
plugins: [
|
||||||
|
shouldEnableDevServiceWorkerCleanup(command, mode, isPreview, process.env.npm_lifecycle_event) &&
|
||||||
|
createDevServiceWorkerCleanupPlugin(),
|
||||||
vue(),
|
vue(),
|
||||||
vueJsx(),
|
vueJsx(),
|
||||||
vuetify({
|
vuetify({
|
||||||
@@ -74,7 +81,7 @@ export default defineConfig(({ mode }) => ({
|
|||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'],
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'],
|
||||||
},
|
},
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true,
|
enabled: isPwaDevelopmentEnabled(mode, process.env.npm_lifecycle_event),
|
||||||
type: 'module',
|
type: 'module',
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
|
|||||||
Reference in New Issue
Block a user