diff --git a/apps/shell/package.json b/apps/shell/package.json index 7f0845f..0ce80aa 100644 --- a/apps/shell/package.json +++ b/apps/shell/package.json @@ -14,14 +14,16 @@ }, "dependencies": { "@unself/contracts": "workspace:*", - "vue": "^3.5.0", - "vue-router": "^4.5.0", "@unself/ui": "workspace:*", - "lucide-vue-next": "^0.544.0" + "lucide-vue-next": "^0.544.0", + "vue": "^3.5.0", + "vue-router": "^4.5.0" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", "@vitejs/plugin-vue": "^6.0.0", + "@vue/test-utils": "^2.5.0", + "jsdom": "^27.4.0", "tailwindcss": "^4.0.0", "typescript": "^5.9.0", "vite": "^7.0.0", diff --git a/apps/shell/src/App.test.ts b/apps/shell/src/App.test.ts new file mode 100644 index 0000000..cc43903 --- /dev/null +++ b/apps/shell/src/App.test.ts @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// @vitest-environment jsdom +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import App from './App.vue' +import { fetchMe } from './lib/session-api' +import { fetchEnabledModules } from './lib/registry-api' +import { fetchModuleToken } from './lib/token-api' + +const { TOKEN, MODULE, REJECTED } = vi.hoisted(() => { + const TOKEN = { + token: 'tok-1', + expiresIn: 600, + claims: { + iss: 'unself', + sub: 'u1', + aud: 'hello', + iat: 1_700_000_000, + exp: 1_700_000_600, + }, + } + /** enabled 模块:manifest.entry 同域路径(§5.3 单域名路径制)。 */ + const MODULE = { + id: 'hello', + enabled: true, + version: '0.1.0', + manifest: { + id: 'hello', + route: '/m/hello/', + entry: '/m/hello/', + runtime: 'vue', + version: '0.1.0', + }, + } + const REJECTED = Object.assign(new Error('登录已过期,请重新登录'), { status: 401 }) + return { TOKEN, MODULE, REJECTED } +}) + +vi.mock('vue-router', () => ({ + useRoute: () => ({ query: {} }), + useRouter: () => ({ push: vi.fn() }), +})) + +vi.mock('./lib/session-api', () => ({ + fetchMe: vi.fn().mockResolvedValue({ + authenticated: true, + user: { id: 'u1', name: '黄一', issuer: 'unself', sub: 'u1' }, + }), + loginUrl: (next?: string) => `/api/auth/login${next ? `?next=${encodeURIComponent(next)}` : ''}`, + logout: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('./lib/registry-api', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + fetchEnabledModules: vi.fn().mockResolvedValue([MODULE]), + } +}) + +vi.mock('./lib/token-api', () => ({ + fetchModuleToken: vi.fn(), +})) + +beforeEach(() => { + vi.clearAllMocks() +}) + +/** 冲刷挂载 → 拉注册表 → 选模块 → post-flush 挂桥整条异步链。 */ +async function settle() { + await flushPromises() + await nextTick() + await flushPromises() + await nextTick() + await flushPromises() +} + +/** 模拟 iframe 内 SDK 发 ready(真实 MessageEvent,走真实桥的消息监听)。 */ +function dispatchReady(iframe: HTMLIFrameElement) { + const ev = new MessageEvent('message', { + data: { type: 'ready' }, + origin: window.location.origin, + source: iframe.contentWindow, + }) + window.dispatchEvent(ev) +} + +// 注意:jsdom 中未挂入 document 的 iframe 共享同一 contentWindow, +// 因此每个用例必须 wrapper.unmount()(触发 onBeforeUnmount 拆桥), +// 否则上一个用例的桥监听器会截获下一个用例的 ready 事件。 +describe('App.vue 模块桥挂载时机(#71 根因 2)', () => { + it('桥在 iframe 挂载后 attach:ready → token → frameState ready(不误判配置无效)', async () => { + vi.mocked(fetchModuleToken).mockResolvedValue(TOKEN) + vi.mocked(fetchMe).mockResolvedValue({ + authenticated: true, + user: { id: 'u1', name: '黄一', issuer: 'unself', sub: 'u1' }, + }) + vi.mocked(fetchEnabledModules).mockResolvedValue([MODULE]) + + const wrapper = mount(App) + await settle() + + const iframeEl = wrapper.find('iframe') + expect(iframeEl.exists()).toBe(true) + // 落地规则:直访 / 落第一个启用模块 → hello 进入握手期(骨架可见、帧隐藏) + expect(iframeEl.attributes('src')).toBe('/m/hello/') + expect(iframeEl.classes()).toContain('shell-frame-hidden') + expect(wrapper.text()).not.toContain('模块入口配置无效') + + dispatchReady(iframeEl.element as HTMLIFrameElement) + await settle() + + // 行为断言:真实桥收到了 ready → 以 'hello' 请求 token → 下发 → 帧就位 + expect(fetchModuleToken).toHaveBeenCalledTimes(1) + expect(fetchModuleToken).toHaveBeenCalledWith('hello') + expect(wrapper.find('iframe').classes()).not.toContain('shell-frame-hidden') + expect(wrapper.text()).not.toContain('模块加载失败') + expect(wrapper.text()).not.toContain('模块入口配置无效') + + wrapper.unmount() + }) + + it('retry 强制 iframe 重挂(key 变化 → DOM 替换)后对新帧重新挂桥', async () => { + vi.mocked(fetchModuleToken) + .mockRejectedValueOnce(REJECTED) + .mockResolvedValue(TOKEN) + vi.mocked(fetchMe).mockResolvedValue({ + authenticated: true, + user: { id: 'u1', name: '黄一', issuer: 'unself', sub: 'u1' }, + }) + vi.mocked(fetchEnabledModules).mockResolvedValue([MODULE]) + + const wrapper = mount(App) + await settle() + + const before = wrapper.find('iframe').element as HTMLIFrameElement + expect(before).toBeTruthy() + + // 首次 ready → 领 token 失败(401)→ 失败卡 + dispatchReady(before) + await settle() + expect(fetchModuleToken).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('模块加载失败') + const retry = wrapper.find('button.u-error-retry') + expect(retry.text()).toBe('重新加载') + + // retry:frameReload 自增 → iframe 按 key 重挂(DOM 元素被替换) + await retry.trigger('click') + await settle() + + const after = wrapper.find('iframe').element as HTMLIFrameElement + expect(after).not.toBe(before) + expect(after.getAttribute('src')).toBe('/m/hello/') + + // 新 iframe 重新发 ready → 真实桥对新帧再次取 token → 就位 + dispatchReady(after) + await settle() + expect(fetchModuleToken).toHaveBeenCalledTimes(2) + expect(fetchModuleToken).toHaveBeenNthCalledWith(2, 'hello') + expect(wrapper.find('iframe').classes()).not.toContain('shell-frame-hidden') + expect(wrapper.text()).not.toContain('模块加载失败') + + wrapper.unmount() + }) +}) diff --git a/apps/shell/src/App.vue b/apps/shell/src/App.vue index 1236118..f018576 100644 --- a/apps/shell/src/App.vue +++ b/apps/shell/src/App.vue @@ -1,6 +1,6 @@