diff --git a/apps/desktop/src/main/protocol.ts b/apps/desktop/src/main/protocol.ts index b0a8b618a..c63f3589c 100644 --- a/apps/desktop/src/main/protocol.ts +++ b/apps/desktop/src/main/protocol.ts @@ -51,6 +51,14 @@ export function registerRendererScheme(): void { ]); } +async function isRegularFile(filePath: string): Promise { + try { + return (await stat(filePath)).isFile(); + } catch { + return false; + } +} + const DEFAULT_RENDERER_BASE = `${RENDERER_SCHEME}://${RENDERER_HOST}/index.html`; export function rendererUrl( @@ -114,17 +122,24 @@ export async function handleRendererRequest( if (!filePath.startsWith(root)) { return new Response('forbidden', { status: 403 }); } - try { - const info = await stat(filePath); - if (!info.isFile()) { + // SPA fallback (mirrors kap-server's webAssets): the renderer's history + // routing pushes extensionless navigation URLs (`/sessions/`, `/login`) + // that have no file in desktop-dist — serve index.html so a native reload + // (cmd+r) lands back in the app. Asset misses with an extension keep their + // 404 instead of being fed HTML. + let target = filePath; + if (!(await isRegularFile(target))) { + if (extname(decodedPathname) !== '') { + return new Response('not found', { status: 404 }); + } + target = join(root, 'index.html'); + if (!(await isRegularFile(target))) { return new Response('not found', { status: 404 }); } - } catch { - return new Response('not found', { status: 404 }); } - const stream = createReadStream(filePath); + const stream = createReadStream(target); return new Response(stream as unknown as ResponseBody, { - headers: { 'content-type': mimeFor(filePath) }, + headers: { 'content-type': mimeFor(target) }, }); } diff --git a/apps/desktop/tests/main/protocol.test.ts b/apps/desktop/tests/main/protocol.test.ts index f7e98453c..d3c709152 100644 --- a/apps/desktop/tests/main/protocol.test.ts +++ b/apps/desktop/tests/main/protocol.test.ts @@ -87,6 +87,39 @@ describe('handleRendererRequest', () => { expect(res.status).toBe(404); }); + // SPA fallback: the renderer's history routing pushes paths like + // `/sessions/` (and `/login` from the auth gate) onto the URL; a native + // reload re-requests that path, which has no file in desktop-dist. + it('serves index.html for SPA session routes (reload on a deep link)', async () => { + const root = await makeRoot(); + const res = await handleRendererRequest( + { url: 'app://renderer/sessions/abc' } as any, + () => root, + ); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8'); + expect(await res.text()).toBe('

ok

'); + }); + + it('serves index.html for the auth-gate /login path', async () => { + const root = await makeRoot(); + const res = await handleRendererRequest( + { url: 'app://renderer/login' } as any, + () => root, + ); + expect(res.status).toBe(200); + expect(await res.text()).toBe('

ok

'); + }); + + it('404s an extensionless path when index.html itself is missing', async () => { + const root = await mkdtemp(join(tmpdir(), 'kimi-renderer-')); + const res = await handleRendererRequest( + { url: 'app://renderer/sessions/abc' } as any, + () => root, + ); + expect(res.status).toBe(404); + }); + it('serves files from the configured desktop-dist root (app://renderer/)', async () => { const root = await makeRoot(); await writeFile(join(root, 'assets', 'marker.js'), 'desktop-dist-root'); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 000000000..1e159ae04 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,20 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; +import { kimiRendererViteConfig } from '@moonshot-ai/vite-preset'; + +// Web tests import src code, which pulls in unplugin-icons virtual modules +// (`~icons/*`) and `?raw` SVGs. Mirror the web build's plugin set (Vue + the +// `kimi` custom icon collection) so those imports resolve under vitest too — +// same preset as vite.config.ts, but only the plugins (root/build stay +// test-scoped). +const preset = kimiRendererViteConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + iconsDir: fileURLToPath(new URL('./src/icons/kimi', import.meta.url)), +}); + +export default defineConfig({ + plugins: preset.plugins, + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/package.json b/package.json index 1c6f644d7..255639092 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "test": "vitest run" }, "devDependencies": { + "@iconify-json/ri": "^1.2.10", + "@iconify-json/tabler": "^1.2.35", "@types/node": "^22.15.3", "oxlint": "1.59.0", "oxlint-tsgolint": "0.20.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdfaf857a..bdecc3ff1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,12 @@ importers: .: devDependencies: + '@iconify-json/ri': + specifier: ^1.2.10 + version: 1.2.10 + '@iconify-json/tabler': + specifier: ^1.2.35 + version: 1.2.35 '@types/node': specifier: ^22.15.3 version: 22.20.1 diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 000000000..7ef965580 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config'; + +// Root test entry (`pnpm test`). Runs only code-app's own packages as vitest +// projects, each loading its own config (plugin pipelines included). The +// `kimi-code/` submodule is intentionally left out: its tests run in its own +// repo/CI and need its own environment. +export default defineConfig({ + test: { + projects: [ + // apps/* each carry a vitest.config.ts with their renderer plugin set + // (Vue + unplugin-icons), so `~icons/*` and `?raw` imports resolve. + 'apps/*', + // packages/* have no build-time plugin needs; run their tests with the + // default pipeline from their own roots. + { + test: { + name: 'packages', + include: ['packages/{web-core,web-i18n,web-markdown,web-ui,vite-preset}/{src,test}/**/*.test.ts'], + }, + }, + ], + }, +});