{
  "config": {
    "argv": [
      "/opt/hostedtoolcache/node/20.20.2/x64/bin/node",
      "/home/runner/work/dr-card-web/dr-card-web/node_modules/.bin/playwright",
      "test",
      "tests/e2e/test-gate.spec.ts"
    ],
    "configFile": "/home/runner/work/dr-card-web/dr-card-web/playwright.config.ts",
    "rootDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
    "failOnFlakyTests": false,
    "forbidOnly": true,
    "fullyParallel": true,
    "globalSetup": null,
    "globalTeardown": null,
    "globalTimeout": 0,
    "grep": {},
    "grepInvert": null,
    "maxFailures": 0,
    "metadata": {
      "ci": {
        "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/0237efeebb37de54e5b96d7accc64f704203fa60",
        "commitHash": "0237efeebb37de54e5b96d7accc64f704203fa60",
        "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/272",
        "prTitle": "release(test): #270 OSS 静态入口 React Router 零闪屏直达",
        "prBaseHash": "d417941636fd4c1676eb1380efbf64bcc781d723",
        "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/41164730049"
      },
      "gitCommit": {
        "shortHash": "0237efe",
        "hash": "0237efeebb37de54e5b96d7accc64f704203fa60",
        "subject": "Merge 36f84d12d7f56aa5b5e4d35fd867019615ba0a54 into d417941636fd4c1676eb1380efbf64bcc781d723",
        "body": "Merge 36f84d12d7f56aa5b5e4d35fd867019615ba0a54 into d417941636fd4c1676eb1380efbf64bcc781d723\n",
        "author": {
          "name": "Tomz Dang",
          "email": "dangjingtao@163.com",
          "time": 1793417480000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1793417480000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/docs/engineering/native-oss-redirect-entry.md b/docs/engineering/native-oss-redirect-entry.md\nnew file mode 100644\nindex 0000000..929a9ff\n--- /dev/null\n+++ b/docs/engineering/native-oss-redirect-entry.md\n@@ -0,0 +1,19 @@\n+# 原生 WebView · OSS 静态入口直达协议\n+\n+卡博士 H5 在 OSS 上以固定静态对象 `index.html` 作为 WebView 初次加载入口；深层 BrowserRouter 路由不是 OSS 对象，不能直接作为网络初始 URL。\n+\n+## 入口示例\n+\n+- 生产人工客服：`https://kbs.3cgroup.cn/kbs-web/prod/last/index.html?redirect=%2Fservice%2Fchat%2Fhuman`\n+- 生产普通首页：`https://kbs.3cgroup.cn/kbs-web/prod/last/index.html`\n+- 其它环境：将 `/kbs-web/prod/last/` 替换为同环境的 `/kbs-web/test/`、`/kbs-web/dev/` 等前缀。\n+\n+## 规则\n+\n+- 参数名为 `redirect`（准确拼写），值为 URL 编码的 **H5 内部绝对业务路由路径**，如 `/service/chat/human`，可附带该业务页的查询参数和 hash。\n+- 不带参数时，`/index.html` 是正常首页入口，不执行任何导航；传统根路径 `/` 同样不受影响。\n+- 有有效参数时，根路径和 `/index.html` 在 React Router 初始 loader 阶段执行 replace。首页组件不挂载，也不增加返回历史记录。\n+- 拒绝完整外部 URL、跨环境前缀、根目录、未知路由、Native 参考、延期页面和 Debug 页面。无效参数不跳转。\n+- `test/prod` 的 Native WebView 宿主门禁在 Router 启动之前执行，不会被参数绕过。\n+- Router 跳转后地址为 Browser History 真实业务路径，页面刷新、进程恢复重新请求深层路径时，仍要求 CDN/网关按环境配置 SPA rewrite（见 #80/#171）；原生每次新开 WebView 应使用静态入口 URL。\n+- 入口跳转只保证 H5 路由可达，不代表人工客服的真实 Socket/API 已完成业务验收。\ndiff --git a/src/App.tsx b/src/App.tsx\nindex 220570b..fab6c4f 100644\n--- a/src/App.tsx\n+++ b/src/App.tsx\n@@ -1,12 +1,16 @@\n-import { useEffect, useRef, useState } from 'react'\n+import { useEffect, useRef, useState, useSyncExternalStore } from 'react'\n import { RouterProvider } from 'react-router-dom'\n import { router } from './app/router'\n+import { initialEntryRedirect } from './app/router/entryRedirect'\n import NewcomerDialog from './components/mobile/NewcomerDialog'\n import { getAuthSession, type NewUserPopup } from './services/auth/session'\n \n export default function App() {\n   const [newUserPopup, setNewUserPopup] = useState<NewUserPopup | null>(null)\n   const popupHandledThisDocument = useRef(false)\n+  // Hide the new-user popup only until the initial router replace is resolved.\n+  const routerInitialized = useSyncExternalStore(router.subscribe, () => router.state.initialized)\n+  const entryRedirectPending = initialEntryRedirect !== null && !routerInitialized\n \n   useEffect(() => {\n     const handleSessionChanged = () => {\n@@ -31,7 +35,7 @@ export default function App() {\n   return (\n     <>\n       <RouterProvider router={router} />\n-      <NewcomerDialog open={newUserPopup !== null} title={newUserPopup?.title ?? ''}\n+      <NewcomerDialog open={newUserPopup !== null && !entryRedirectPending} title={newUserPopup?.title ?? ''}\n         description={newUserPopup?.description ?? ''} points={newUserPopup?.points ?? ''}\n         onComplete={dismissPopup} onDismiss={dismissPopup} />\n     </>\ndiff --git a/src/app/router/entryRedirect.test.ts b/src/app/router/entryRedirect.test.ts\nnew file mode 100644\nindex 0000000..9e24004\n--- /dev/null\n+++ b/src/app/router/entryRedirect.test.ts\n@@ -0,0 +1,66 @@\n+import { describe, expect, it } from 'vitest'\n+import { waitFor } from '@testing-library/react'\n+import { createMemoryRouter } from 'react-router-dom'\n+import { entryRedirectLoader, resolveEntryRedirect } from './entryRedirect'\n+import type { LoaderFunctionArgs } from 'react-router-dom'\n+\n+describe('OSS index.html redirect entry', () => {\n+  it('accepts active, formal H5 pages and preserves query/hash', () => {\n+    expect(resolveEntryRedirect('/service/chat/human')).toBe('/service/chat/human')\n+    expect(resolveEntryRedirect('/notifications/42?view=detail#top')).toBe('/notifications/42?view=detail#top')\n+  })\n+\n+  it.each([\n+    null, '', '/', '/index.html', '//external.example/x',\n+    'https://external.example/', 'javascript:alert(1)', '/unknown-route',\n+    '/legacy-home', '/device/shower', '/mall/cart', '/__debug/bridge-lab',\n+    '/kbs-web/test/service/chat/human', '/service/../profile',\n+    '/service/%2e%2e/profile', '/notifications/1%2f2', '/service\\\\chat',\n+  ])('rejects unsafe or nonformal target %s', (target) => {\n+    expect(resolveEntryRedirect(target)).toBeNull()\n+  })\n+\n+  it('returns a replace redirect response, never a client-side Home render', () => {\n+    const request = new Request(\n+      'https://example.test/index.html?redirect=%2Fservice%2Fchat%2Fhuman',\n+    )\n+    const result = entryRedirectLoader({ request, params: {}, context: {} } as LoaderFunctionArgs)\n+    expect(result).toBeInstanceOf(Response)\n+    if (!(result instanceof Response)) throw new Error('Expected response')\n+    expect(result.headers.get('Location')).toBe('/service/chat/human')\n+    expect(result.headers.get('X-Remix-Replace')).toBe('true')\n+  })\n+\n+  it('does nothing when redirect is missing or rejected', () => {\n+    for (const url of [\n+      'https://example.test/index.html',\n+      'https://example.test/?redirect=https%3A%2F%2Fevil.example',\n+    ]) {\n+      const request = new Request(url)\n+      expect(entryRedirectLoader({ request, params: {}, context: {} } as LoaderFunctionArgs)).toBeNull()\n+    }\n+  })\n+\n+  it.each(['/kbs-web/dev', '/kbs-web/test', '/kbs-web/prod/last'])(\n+    'honors an OSS router basename (%s) on initial replace',\n+    async (basename) => {\n+      const router = createMemoryRouter([\n+        { path: '/index.html', loader: entryRedirectLoader },\n+        { path: '/service/chat/human', element: null },\n+      ], {\n+        basename,\n+        initialEntries: [\n+          basename + '/index.html?redirect=%2Fservice%2Fchat%2Fhuman',\n+        ],\n+      })\n+      try {\n+        await waitFor(() => {\n+          expect(router.state.location.pathname).toBe(basename + '/service/chat/human')\n+          expect(router.state.errors).toBeNull()\n+        })\n+      } finally {\n+        router.dispose()\n+      }\n+    },\n+  )\n+})\ndiff --git a/src/app/router/entryRedirect.ts b/src/app/router/entryRedirect.ts\nnew file mode 100644\nindex 0000000..dc3c646\n--- /dev/null\n+++ b/src/app/router/entryRedirect.ts\n@@ -0,0 +1,56 @@\n+import { matchPath, replace, type LoaderFunctionArgs } from 'react-router-dom'\n+import { ROUTES } from './routes'\n+import { isActiveFormalH5Route } from './routeScope'\n+\n+const ENTRY_ORIGIN = 'https://h5-entry.invalid'\n+\n+/**\n+ * Accept only active formal H5 routes from the existing registry.  Never feed an\n+ * untrusted URL directly into React Router's redirect response.\n+ */\n+export function resolveEntryRedirect(raw: string | null): string | null {\n+  if (!raw || raw.length > 2048 || !raw.startsWith('/') || raw.startsWith('//') ||\n+      raw.includes('\\\\') || /[\\u0000-\\u001f\\u007f]/.test(raw)) return null\n+\n+  let target: URL\n+  try {\n+    target = new URL(raw, ENTRY_ORIGIN)\n+  } catch {\n+    return null\n+  }\n+  if (target.origin !== ENTRY_ORIGIN || target.username || target.password) return null\n+\n+  // URL normalizes ../ and encoded dot segments. Reject them instead of\n+  // silently routing a different path from what the host requested.\n+  const requestedPath = raw.split(/[?#]/, 1)[0]\n+  if (target.pathname !== requestedPath || /%(?:2f|5c|2e)/i.test(requestedPath)) return null\n+  if (requestedPath === '/' || requestedPath === '/index.html') return null\n+\n+  const route = ROUTES.find((candidate) =>\n+    matchPath({ path: candidate.path, end: true }, target.pathname),\n+  )\n+  if (!isActiveFormalH5Route(route)) return null\n+\n+  return target.pathname + target.search + target.hash\n+}\n+\n+/** Snapshot before the initial loader replaces the OSS document URL. */\n+export const initialEntryRedirect = (() => {\n+  if (typeof window === 'undefined') return null\n+  const basename = import.meta.env.VITE_ROUTER_BASENAME?.replace(/\\/$/, '') || ''\n+  const pathname = window.location.pathname\n+  const relativePath = basename && pathname.startsWith(basename + '/')\n+    ? pathname.slice(basename.length)\n+    : pathname\n+  if (relativePath !== '/' && relativePath !== '/index.html') return null\n+  return resolveEntryRedirect(new URLSearchParams(window.location.search).get('redirect'))\n+})()\n+\n+/**\n+ * Data-router loader runs before the matched element mounts, so Home and its\n+ * effects never render for a valid deep-link entry.\n+ */\n+export function entryRedirectLoader({ request }: LoaderFunctionArgs) {\n+  const target = resolveEntryRedirect(new URL(request.url).searchParams.get('redirect'))\n+  return target ? replace(target) : null\n+}\ndiff --git a/src/app/router/index.tsx b/src/app/router/index.tsx\nindex 6a0d21b..6d5e56e 100644\n--- a/src/app/router/index.tsx\n+++ b/src/app/router/index.tsx\n@@ -94,6 +94,7 @@ import { ROUTES } from './routes'\n import type { RouteMeta } from './routes'\n import type { ReactElement } from 'react'\n import HomeAuthGate from '../auth/HomeAuthGate'\n+import { entryRedirectLoader } from './entryRedirect'\n \n /**\n  * H022 shared route implementations.\n@@ -277,8 +278,10 @@ export const router = createBrowserRouter([\n         else if (route.implementationKey) element = sharedRouteImplementations[route.implementationKey](route)\n         else if (customPages[route.path]) element = customPages[route.path]\n         else if (route.boundary === 'webview') element = <WebViewBoundary />\n-        return { path: route.path, element }\n+        return { path: route.path, ...(route.path === '/' ? { loader: entryRedirectLoader } : {}), element }\n       }),\n+      // OSS static index.html is a valid document URL and shares the Home route.\n+      { path: '/index.html', loader: entryRedirectLoader, element: customPages['/'] },\n       /* T002 品牌 Token 展示页（工程参照页，不属于业务节点，不进入业务导航） */\n       { path: '/tokens', element: <Tokens /> },\n       { path: '/draw-success', element: <Navigate to=\"/luck/result\" replace /> },\ndiff --git a/src/app/router/routeScope.ts b/src/app/router/routeScope.ts\nindex 95b2a08..f8eae60 100644\n--- a/src/app/router/routeScope.ts\n+++ b/src/app/router/routeScope.ts\n@@ -181,5 +181,5 @@ export function isNativeReferenceRoute(route: RouteMeta | undefined): route is R\n \n /** Exact tab match: child/detail routes do not display the bottom navigation. */\n export function isFormalH5TabPath(pathname: string): boolean {\n-  return FORMAL_H5_TAB_ROUTES.some((route) => route.path === pathname)\n+  return FORMAL_H5_TAB_ROUTES.some((route) => route.path === (pathname === '/index.html' ? '/' : pathname))\n }\ndiff --git a/src/app/router/routes.ts b/src/app/router/routes.ts\nindex cf5fe61..818587c 100644\n--- a/src/app/router/routes.ts\n+++ b/src/app/router/routes.ts\n@@ -1199,12 +1199,13 @@ export function isTabPath(pathname: string): boolean {\n \n /** 精确匹配；无则尝试 `:param` 动态路由匹配 */\n export function findRouteByPathname(pathname: string): RouteMeta | undefined {\n-  const exact = ROUTES.find((route) => route.path === pathname)\n+  const normalizedPath = pathname === '/index.html' ? '/' : pathname\n+  const exact = ROUTES.find((route) => route.path === normalizedPath)\n   if (exact) return exact\n   return ROUTES.find((route) => {\n     if (!route.path.includes(':')) return false\n     const segs = route.path.split('/')\n-    const parts = pathname.split('/')\n+    const parts = normalizedPath.split('/')\n     if (segs.length !== parts.length) return false\n     return segs.every((seg, i) => seg.startsWith(':') || seg === parts[i])\n   })\ndiff --git a/tests/e2e/formal-h5.spec.ts b/tests/e2e/formal-h5.spec.ts\nindex b2e88a1..101a784 100644\n--- a/tests/e2e/formal-h5.spec.ts\n+++ b/tests/e2e/formal-h5.spec.ts\n@@ -450,3 +450,37 @@ test('@formal-h5 H023 shared empty-state visual is stable across data pages', as\n \n   expect(runtimeErrors, runtimeErrors.join('\\n')).toEqual([])\n })\n+\n+test('@formal-h5 OSS index redirect enters the target without mounting Home', async ({ page }) => {\n+  await page.addInitScript(() => {\n+    const observed = window as Window & { __homeEverRendered?: boolean; __entryHistoryLength?: number }\n+    observed.__homeEverRendered = false\n+    observed.__entryHistoryLength = history.length\n+    const observer = new MutationObserver(() => {\n+      if (document.querySelector('[data-dearseed-avatar]')) observed.__homeEverRendered = true\n+    })\n+    observer.observe(document, { childList: true, subtree: true })\n+  })\n+\n+  await page.goto('/index.html?redirect=%2Fservice%2Fchat%2Fhuman', { waitUntil: 'domcontentloaded' })\n+  await expect(page).toHaveURL(/\\/service\\/chat\\/human$/)\n+  await expect(page.locator('[data-h5-route-active=\"true\"]')).toBeVisible()\n+  await expect(page.locator('[data-dearseed-avatar]')).toHaveCount(0)\n+  expect(await page.evaluate(() => (window as Window & { __homeEverRendered?: boolean }).__homeEverRendered)).toBe(false)\n+  expect(await page.evaluate(() => history.length)).toBe(\n+    await page.evaluate(() => (window as Window & { __entryHistoryLength?: number }).__entryHistoryLength),\n+  )\n+})\n+\n+test('@formal-h5 explicit OSS index without redirect remains on its entry document', async ({ page }) => {\n+  await page.goto('/index.html', { waitUntil: 'domcontentloaded' })\n+  await expect(page).toHaveURL(/\\/index\\.html$/)\n+  await expect(page.locator('[data-dearseed-avatar]')).toBeVisible()\n+  await expect(page.locator('[data-title-bar]')).toContainText('极地种子品牌专栏')\n+})\n+\n+test('@formal-h5 invalid OSS redirect does not leave the app', async ({ page }) => {\n+  await page.goto('/index.html?redirect=https%3A%2F%2Fevil.example', { waitUntil: 'domcontentloaded' })\n+  await expect(page).toHaveURL(/\\/index\\.html\\?redirect=/)\n+  await expect(page.locator('[data-dearseed-avatar]')).toBeVisible()\n+})",
      "actualWorkers": 2
    },
    "preserveOutput": "always",
    "projects": [
      {
        "outputDir": "/home/runner/work/dr-card-web/dr-card-web/test-results/artifacts",
        "repeatEach": 1,
        "retries": 1,
        "metadata": {
          "ci": {
            "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/0237efeebb37de54e5b96d7accc64f704203fa60",
            "commitHash": "0237efeebb37de54e5b96d7accc64f704203fa60",
            "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/272",
            "prTitle": "release(test): #270 OSS 静态入口 React Router 零闪屏直达",
            "prBaseHash": "d417941636fd4c1676eb1380efbf64bcc781d723",
            "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/41164730049"
          },
          "gitCommit": {
            "shortHash": "0237efe",
            "hash": "0237efeebb37de54e5b96d7accc64f704203fa60",
            "subject": "Merge 36f84d12d7f56aa5b5e4d35fd867019615ba0a54 into d417941636fd4c1676eb1380efbf64bcc781d723",
            "body": "Merge 36f84d12d7f56aa5b5e4d35fd867019615ba0a54 into d417941636fd4c1676eb1380efbf64bcc781d723\n",
            "author": {
              "name": "Tomz Dang",
              "email": "dangjingtao@163.com",
              "time": 1793417480000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1793417480000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/docs/engineering/native-oss-redirect-entry.md b/docs/engineering/native-oss-redirect-entry.md\nnew file mode 100644\nindex 0000000..929a9ff\n--- /dev/null\n+++ b/docs/engineering/native-oss-redirect-entry.md\n@@ -0,0 +1,19 @@\n+# 原生 WebView · OSS 静态入口直达协议\n+\n+卡博士 H5 在 OSS 上以固定静态对象 `index.html` 作为 WebView 初次加载入口；深层 BrowserRouter 路由不是 OSS 对象，不能直接作为网络初始 URL。\n+\n+## 入口示例\n+\n+- 生产人工客服：`https://kbs.3cgroup.cn/kbs-web/prod/last/index.html?redirect=%2Fservice%2Fchat%2Fhuman`\n+- 生产普通首页：`https://kbs.3cgroup.cn/kbs-web/prod/last/index.html`\n+- 其它环境：将 `/kbs-web/prod/last/` 替换为同环境的 `/kbs-web/test/`、`/kbs-web/dev/` 等前缀。\n+\n+## 规则\n+\n+- 参数名为 `redirect`（准确拼写），值为 URL 编码的 **H5 内部绝对业务路由路径**，如 `/service/chat/human`，可附带该业务页的查询参数和 hash。\n+- 不带参数时，`/index.html` 是正常首页入口，不执行任何导航；传统根路径 `/` 同样不受影响。\n+- 有有效参数时，根路径和 `/index.html` 在 React Router 初始 loader 阶段执行 replace。首页组件不挂载，也不增加返回历史记录。\n+- 拒绝完整外部 URL、跨环境前缀、根目录、未知路由、Native 参考、延期页面和 Debug 页面。无效参数不跳转。\n+- `test/prod` 的 Native WebView 宿主门禁在 Router 启动之前执行，不会被参数绕过。\n+- Router 跳转后地址为 Browser History 真实业务路径，页面刷新、进程恢复重新请求深层路径时，仍要求 CDN/网关按环境配置 SPA rewrite（见 #80/#171）；原生每次新开 WebView 应使用静态入口 URL。\n+- 入口跳转只保证 H5 路由可达，不代表人工客服的真实 Socket/API 已完成业务验收。\ndiff --git a/src/App.tsx b/src/App.tsx\nindex 220570b..fab6c4f 100644\n--- a/src/App.tsx\n+++ b/src/App.tsx\n@@ -1,12 +1,16 @@\n-import { useEffect, useRef, useState } from 'react'\n+import { useEffect, useRef, useState, useSyncExternalStore } from 'react'\n import { RouterProvider } from 'react-router-dom'\n import { router } from './app/router'\n+import { initialEntryRedirect } from './app/router/entryRedirect'\n import NewcomerDialog from './components/mobile/NewcomerDialog'\n import { getAuthSession, type NewUserPopup } from './services/auth/session'\n \n export default function App() {\n   const [newUserPopup, setNewUserPopup] = useState<NewUserPopup | null>(null)\n   const popupHandledThisDocument = useRef(false)\n+  // Hide the new-user popup only until the initial router replace is resolved.\n+  const routerInitialized = useSyncExternalStore(router.subscribe, () => router.state.initialized)\n+  const entryRedirectPending = initialEntryRedirect !== null && !routerInitialized\n \n   useEffect(() => {\n     const handleSessionChanged = () => {\n@@ -31,7 +35,7 @@ export default function App() {\n   return (\n     <>\n       <RouterProvider router={router} />\n-      <NewcomerDialog open={newUserPopup !== null} title={newUserPopup?.title ?? ''}\n+      <NewcomerDialog open={newUserPopup !== null && !entryRedirectPending} title={newUserPopup?.title ?? ''}\n         description={newUserPopup?.description ?? ''} points={newUserPopup?.points ?? ''}\n         onComplete={dismissPopup} onDismiss={dismissPopup} />\n     </>\ndiff --git a/src/app/router/entryRedirect.test.ts b/src/app/router/entryRedirect.test.ts\nnew file mode 100644\nindex 0000000..9e24004\n--- /dev/null\n+++ b/src/app/router/entryRedirect.test.ts\n@@ -0,0 +1,66 @@\n+import { describe, expect, it } from 'vitest'\n+import { waitFor } from '@testing-library/react'\n+import { createMemoryRouter } from 'react-router-dom'\n+import { entryRedirectLoader, resolveEntryRedirect } from './entryRedirect'\n+import type { LoaderFunctionArgs } from 'react-router-dom'\n+\n+describe('OSS index.html redirect entry', () => {\n+  it('accepts active, formal H5 pages and preserves query/hash', () => {\n+    expect(resolveEntryRedirect('/service/chat/human')).toBe('/service/chat/human')\n+    expect(resolveEntryRedirect('/notifications/42?view=detail#top')).toBe('/notifications/42?view=detail#top')\n+  })\n+\n+  it.each([\n+    null, '', '/', '/index.html', '//external.example/x',\n+    'https://external.example/', 'javascript:alert(1)', '/unknown-route',\n+    '/legacy-home', '/device/shower', '/mall/cart', '/__debug/bridge-lab',\n+    '/kbs-web/test/service/chat/human', '/service/../profile',\n+    '/service/%2e%2e/profile', '/notifications/1%2f2', '/service\\\\chat',\n+  ])('rejects unsafe or nonformal target %s', (target) => {\n+    expect(resolveEntryRedirect(target)).toBeNull()\n+  })\n+\n+  it('returns a replace redirect response, never a client-side Home render', () => {\n+    const request = new Request(\n+      'https://example.test/index.html?redirect=%2Fservice%2Fchat%2Fhuman',\n+    )\n+    const result = entryRedirectLoader({ request, params: {}, context: {} } as LoaderFunctionArgs)\n+    expect(result).toBeInstanceOf(Response)\n+    if (!(result instanceof Response)) throw new Error('Expected response')\n+    expect(result.headers.get('Location')).toBe('/service/chat/human')\n+    expect(result.headers.get('X-Remix-Replace')).toBe('true')\n+  })\n+\n+  it('does nothing when redirect is missing or rejected', () => {\n+    for (const url of [\n+      'https://example.test/index.html',\n+      'https://example.test/?redirect=https%3A%2F%2Fevil.example',\n+    ]) {\n+      const request = new Request(url)\n+      expect(entryRedirectLoader({ request, params: {}, context: {} } as LoaderFunctionArgs)).toBeNull()\n+    }\n+  })\n+\n+  it.each(['/kbs-web/dev', '/kbs-web/test', '/kbs-web/prod/last'])(\n+    'honors an OSS router basename (%s) on initial replace',\n+    async (basename) => {\n+      const router = createMemoryRouter([\n+        { path: '/index.html', loader: entryRedirectLoader },\n+        { path: '/service/chat/human', element: null },\n+      ], {\n+        basename,\n+        initialEntries: [\n+          basename + '/index.html?redirect=%2Fservice%2Fchat%2Fhuman',\n+        ],\n+      })\n+      try {\n+        await waitFor(() => {\n+          expect(router.state.location.pathname).toBe(basename + '/service/chat/human')\n+          expect(router.state.errors).toBeNull()\n+        })\n+      } finally {\n+        router.dispose()\n+      }\n+    },\n+  )\n+})\ndiff --git a/src/app/router/entryRedirect.ts b/src/app/router/entryRedirect.ts\nnew file mode 100644\nindex 0000000..dc3c646\n--- /dev/null\n+++ b/src/app/router/entryRedirect.ts\n@@ -0,0 +1,56 @@\n+import { matchPath, replace, type LoaderFunctionArgs } from 'react-router-dom'\n+import { ROUTES } from './routes'\n+import { isActiveFormalH5Route } from './routeScope'\n+\n+const ENTRY_ORIGIN = 'https://h5-entry.invalid'\n+\n+/**\n+ * Accept only active formal H5 routes from the existing registry.  Never feed an\n+ * untrusted URL directly into React Router's redirect response.\n+ */\n+export function resolveEntryRedirect(raw: string | null): string | null {\n+  if (!raw || raw.length > 2048 || !raw.startsWith('/') || raw.startsWith('//') ||\n+      raw.includes('\\\\') || /[\\u0000-\\u001f\\u007f]/.test(raw)) return null\n+\n+  let target: URL\n+  try {\n+    target = new URL(raw, ENTRY_ORIGIN)\n+  } catch {\n+    return null\n+  }\n+  if (target.origin !== ENTRY_ORIGIN || target.username || target.password) return null\n+\n+  // URL normalizes ../ and encoded dot segments. Reject them instead of\n+  // silently routing a different path from what the host requested.\n+  const requestedPath = raw.split(/[?#]/, 1)[0]\n+  if (target.pathname !== requestedPath || /%(?:2f|5c|2e)/i.test(requestedPath)) return null\n+  if (requestedPath === '/' || requestedPath === '/index.html') return null\n+\n+  const route = ROUTES.find((candidate) =>\n+    matchPath({ path: candidate.path, end: true }, target.pathname),\n+  )\n+  if (!isActiveFormalH5Route(route)) return null\n+\n+  return target.pathname + target.search + target.hash\n+}\n+\n+/** Snapshot before the initial loader replaces the OSS document URL. */\n+export const initialEntryRedirect = (() => {\n+  if (typeof window === 'undefined') return null\n+  const basename = import.meta.env.VITE_ROUTER_BASENAME?.replace(/\\/$/, '') || ''\n+  const pathname = window.location.pathname\n+  const relativePath = basename && pathname.startsWith(basename + '/')\n+    ? pathname.slice(basename.length)\n+    : pathname\n+  if (relativePath !== '/' && relativePath !== '/index.html') return null\n+  return resolveEntryRedirect(new URLSearchParams(window.location.search).get('redirect'))\n+})()\n+\n+/**\n+ * Data-router loader runs before the matched element mounts, so Home and its\n+ * effects never render for a valid deep-link entry.\n+ */\n+export function entryRedirectLoader({ request }: LoaderFunctionArgs) {\n+  const target = resolveEntryRedirect(new URL(request.url).searchParams.get('redirect'))\n+  return target ? replace(target) : null\n+}\ndiff --git a/src/app/router/index.tsx b/src/app/router/index.tsx\nindex 6a0d21b..6d5e56e 100644\n--- a/src/app/router/index.tsx\n+++ b/src/app/router/index.tsx\n@@ -94,6 +94,7 @@ import { ROUTES } from './routes'\n import type { RouteMeta } from './routes'\n import type { ReactElement } from 'react'\n import HomeAuthGate from '../auth/HomeAuthGate'\n+import { entryRedirectLoader } from './entryRedirect'\n \n /**\n  * H022 shared route implementations.\n@@ -277,8 +278,10 @@ export const router = createBrowserRouter([\n         else if (route.implementationKey) element = sharedRouteImplementations[route.implementationKey](route)\n         else if (customPages[route.path]) element = customPages[route.path]\n         else if (route.boundary === 'webview') element = <WebViewBoundary />\n-        return { path: route.path, element }\n+        return { path: route.path, ...(route.path === '/' ? { loader: entryRedirectLoader } : {}), element }\n       }),\n+      // OSS static index.html is a valid document URL and shares the Home route.\n+      { path: '/index.html', loader: entryRedirectLoader, element: customPages['/'] },\n       /* T002 品牌 Token 展示页（工程参照页，不属于业务节点，不进入业务导航） */\n       { path: '/tokens', element: <Tokens /> },\n       { path: '/draw-success', element: <Navigate to=\"/luck/result\" replace /> },\ndiff --git a/src/app/router/routeScope.ts b/src/app/router/routeScope.ts\nindex 95b2a08..f8eae60 100644\n--- a/src/app/router/routeScope.ts\n+++ b/src/app/router/routeScope.ts\n@@ -181,5 +181,5 @@ export function isNativeReferenceRoute(route: RouteMeta | undefined): route is R\n \n /** Exact tab match: child/detail routes do not display the bottom navigation. */\n export function isFormalH5TabPath(pathname: string): boolean {\n-  return FORMAL_H5_TAB_ROUTES.some((route) => route.path === pathname)\n+  return FORMAL_H5_TAB_ROUTES.some((route) => route.path === (pathname === '/index.html' ? '/' : pathname))\n }\ndiff --git a/src/app/router/routes.ts b/src/app/router/routes.ts\nindex cf5fe61..818587c 100644\n--- a/src/app/router/routes.ts\n+++ b/src/app/router/routes.ts\n@@ -1199,12 +1199,13 @@ export function isTabPath(pathname: string): boolean {\n \n /** 精确匹配；无则尝试 `:param` 动态路由匹配 */\n export function findRouteByPathname(pathname: string): RouteMeta | undefined {\n-  const exact = ROUTES.find((route) => route.path === pathname)\n+  const normalizedPath = pathname === '/index.html' ? '/' : pathname\n+  const exact = ROUTES.find((route) => route.path === normalizedPath)\n   if (exact) return exact\n   return ROUTES.find((route) => {\n     if (!route.path.includes(':')) return false\n     const segs = route.path.split('/')\n-    const parts = pathname.split('/')\n+    const parts = normalizedPath.split('/')\n     if (segs.length !== parts.length) return false\n     return segs.every((seg, i) => seg.startsWith(':') || seg === parts[i])\n   })\ndiff --git a/tests/e2e/formal-h5.spec.ts b/tests/e2e/formal-h5.spec.ts\nindex b2e88a1..101a784 100644\n--- a/tests/e2e/formal-h5.spec.ts\n+++ b/tests/e2e/formal-h5.spec.ts\n@@ -450,3 +450,37 @@ test('@formal-h5 H023 shared empty-state visual is stable across data pages', as\n \n   expect(runtimeErrors, runtimeErrors.join('\\n')).toEqual([])\n })\n+\n+test('@formal-h5 OSS index redirect enters the target without mounting Home', async ({ page }) => {\n+  await page.addInitScript(() => {\n+    const observed = window as Window & { __homeEverRendered?: boolean; __entryHistoryLength?: number }\n+    observed.__homeEverRendered = false\n+    observed.__entryHistoryLength = history.length\n+    const observer = new MutationObserver(() => {\n+      if (document.querySelector('[data-dearseed-avatar]')) observed.__homeEverRendered = true\n+    })\n+    observer.observe(document, { childList: true, subtree: true })\n+  })\n+\n+  await page.goto('/index.html?redirect=%2Fservice%2Fchat%2Fhuman', { waitUntil: 'domcontentloaded' })\n+  await expect(page).toHaveURL(/\\/service\\/chat\\/human$/)\n+  await expect(page.locator('[data-h5-route-active=\"true\"]')).toBeVisible()\n+  await expect(page.locator('[data-dearseed-avatar]')).toHaveCount(0)\n+  expect(await page.evaluate(() => (window as Window & { __homeEverRendered?: boolean }).__homeEverRendered)).toBe(false)\n+  expect(await page.evaluate(() => history.length)).toBe(\n+    await page.evaluate(() => (window as Window & { __entryHistoryLength?: number }).__entryHistoryLength),\n+  )\n+})\n+\n+test('@formal-h5 explicit OSS index without redirect remains on its entry document', async ({ page }) => {\n+  await page.goto('/index.html', { waitUntil: 'domcontentloaded' })\n+  await expect(page).toHaveURL(/\\/index\\.html$/)\n+  await expect(page.locator('[data-dearseed-avatar]')).toBeVisible()\n+  await expect(page.locator('[data-title-bar]')).toContainText('极地种子品牌专栏')\n+})\n+\n+test('@formal-h5 invalid OSS redirect does not leave the app', async ({ page }) => {\n+  await page.goto('/index.html?redirect=https%3A%2F%2Fevil.example', { waitUntil: 'domcontentloaded' })\n+  await expect(page).toHaveURL(/\\/index\\.html\\?redirect=/)\n+  await expect(page.locator('[data-dearseed-avatar]')).toBeVisible()\n+})",
          "actualWorkers": 2
        },
        "id": "chromium-mobile",
        "name": "chromium-mobile",
        "testDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
        "testIgnore": [],
        "testMatch": [
          "**/*.@(spec|test).?(c|m)[jt]s?(x)"
        ],
        "timeout": 30000
      }
    ],
    "quiet": false,
    "reporter": [
      [
        "line",
        null
      ],
      [
        "html",
        {
          "outputFolder": "playwright-report",
          "open": "never"
        }
      ],
      [
        "json",
        {
          "outputFile": "test-results/playwright-results.json"
        }
      ]
    ],
    "reportSlowTests": {
      "max": 5,
      "threshold": 300000
    },
    "shard": null,
    "tags": [],
    "updateSnapshots": "missing",
    "updateSourceMethod": "patch",
    "version": "1.63.0",
    "workers": 2,
    "webServer": null
  },
  "suites": [
    {
      "title": "test-gate.spec.ts",
      "file": "test-gate.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "@test-gate build identity matches the production-like test policy",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 70,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-31T03:32:24.769Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-c3b6e0ce5d8c5d8734e5",
          "file": "test-gate.spec.ts",
          "line": 31,
          "column": 1
        },
        {
          "title": "@test-gate browser entry does not start the app router",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1263,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-31T03:32:24.788Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8ed37c8041402a2a6b34",
          "file": "test-gate.spec.ts",
          "line": 40,
          "column": 1
        },
        {
          "title": "@test-gate / shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1287,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-31T03:32:24.899Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-b030d57053896c93a550",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /profile shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1056,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-31T03:32:26.496Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8605535a2807e17fcccc",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /settings shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1099,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-31T03:32:26.500Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-4a2fdc9884c1725de4f8",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate gated browser entry issues no business API request",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1055,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-31T03:32:27.573Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-09f38a2b8ed735e192d8",
          "file": "test-gate.spec.ts",
          "line": 65,
          "column": 1
        },
        {
          "title": "@test-gate injected native host bypasses the notice and starts the app",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1120,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-31T03:32:27.616Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-10-31T03:32:23.802Z",
    "duration": 4996.957,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}