diff --git a/desktop/src/shared/profiling/harness.test.mjs b/desktop/src/shared/profiling/harness.test.mjs index ffa42ffcd..97e9496d6 100644 --- a/desktop/src/shared/profiling/harness.test.mjs +++ b/desktop/src/shared/profiling/harness.test.mjs @@ -23,6 +23,7 @@ import { wrapInvoke, } from "@/shared/profiling/ipc.ts"; import { ProfileRecorder, RING_CAPACITY } from "@/shared/profiling/recorder.ts"; +import { resolveTauriCore } from "@/shared/profiling/viteTauriCoreProxy.ts"; function fixedClock() { let t = 1000; @@ -200,6 +201,70 @@ describe("wrapInvoke / observer registry", () => { }); }); +describe("resolveTauriCore", () => { + const PROXY = "/abs/src/shared/profiling/tauriCoreProxy.ts"; + const REAL = "/abs/node_modules/@tauri-apps/api/core.js"; + + it("redirects the bare core specifier to the proxy", () => { + // App code and external plugins (`plugin-opener`, …) import this form. + assert.equal( + resolveTauriCore("@tauri-apps/api/core", "/abs/src/App.tsx", PROXY, REAL), + PROXY, + ); + }); + + it("redirects a relative ./core.js from inside @tauri-apps/api to the proxy", () => { + // The bypass Thufir found: Tauri's own submodules (event/app/window/webview) + // reach core via the relative import, which a bare alias never sees. + assert.equal( + resolveTauriCore( + "./core.js", + "/abs/node_modules/@tauri-apps/api/event.js", + PROXY, + REAL, + ), + PROXY, + ); + }); + + it("maps the proxy's escape specifier to the real core, not itself", () => { + // The proxy re-exports through @tauri-core-impl; it must reach the real + // module so the redirect never loops. + assert.equal( + resolveTauriCore("@tauri-core-impl", PROXY, PROXY, REAL), + REAL, + ); + }); + + it("leaves the proxy's other imports untouched", () => { + // e.g. the proxy importing the observer registry must not be redirected. + assert.equal( + resolveTauriCore("@/shared/profiling/ipc", PROXY, PROXY, REAL), + null, + ); + }); + + it("ignores a relative ./core.js from outside @tauri-apps/api", () => { + // A same-named file elsewhere must not be captured. + assert.equal( + resolveTauriCore( + "./core.js", + "/abs/src/shared/lib/thing.ts", + PROXY, + REAL, + ), + null, + ); + }); + + it("ignores unrelated specifiers", () => { + assert.equal( + resolveTauriCore("react", "/abs/src/App.tsx", PROXY, REAL), + null, + ); + }); +}); + describe("ProfileRecorder ring buffer", () => { it("bounds the buffer at RING_CAPACITY, dropping oldest", async () => { const rec = new ProfileRecorder("session-1", async () => {}, fixedClock()); diff --git a/desktop/src/shared/profiling/viteResolve.test.mjs b/desktop/src/shared/profiling/viteResolve.test.mjs new file mode 100644 index 000000000..030f31e92 --- /dev/null +++ b/desktop/src/shared/profiling/viteResolve.test.mjs @@ -0,0 +1,71 @@ +/** + * Real Vite resolve-graph test for the temporary renderer profiling branch + * (never merges). The unit suite for `resolveTauriCore` proves the decision in + * isolation, but the coverage contract depends on how Vite actually resolves + * imports through the shipped config — the exact gap a bare-specifier alias + * hid. This drives Vite's own plugin container against `vite.config.ts` so the + * three call shapes are proven end-to-end, not modeled. + */ + +import assert from "node:assert/strict"; +import path from "node:path"; +import { after, before, describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +const desktopDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../..", +); +const abs = (p) => path.resolve(desktopDir, p); +const PROXY = abs("src/shared/profiling/tauriCoreProxy.ts"); +const REAL_CORE = abs("node_modules/@tauri-apps/api/core.js"); + +describe("Vite resolves every Tauri core import through the proxy", () => { + let server; + const resolve = (source, importer) => + server.pluginContainer + .resolveId(source, importer) + .then((r) => r?.id ?? null); + + before(async () => { + const { createServer } = await import("vite"); + server = await createServer({ + configFile: abs("vite.config.ts"), + server: { middlewareMode: true }, + logLevel: "silent", + }); + }); + + after(async () => { + await server?.close(); + }); + + it("routes a raw @tauri-apps/api/core import to the proxy", async () => { + assert.equal( + await resolve("@tauri-apps/api/core", abs("src/App.tsx")), + PROXY, + ); + }); + + it("routes a built-in submodule's relative ./core.js to the proxy", async () => { + // event.js/app.js/window.js import core relatively — the bypass being fixed. + assert.equal( + await resolve("./core.js", abs("node_modules/@tauri-apps/api/event.js")), + PROXY, + ); + }); + + it("routes an external plugin's core import to the proxy", async () => { + assert.equal( + await resolve( + "@tauri-apps/api/core", + abs("node_modules/@tauri-apps/plugin-opener/dist-js/index.js"), + ), + PROXY, + ); + }); + + it("resolves the proxy's escape import to the real core (no loop)", async () => { + assert.equal(await resolve("@tauri-core-impl", PROXY), REAL_CORE); + }); +}); diff --git a/desktop/src/shared/profiling/viteTauriCoreProxy.ts b/desktop/src/shared/profiling/viteTauriCoreProxy.ts new file mode 100644 index 000000000..1280fb137 --- /dev/null +++ b/desktop/src/shared/profiling/viteTauriCoreProxy.ts @@ -0,0 +1,47 @@ +// Temporary renderer profiling harness (never merges) — pure resolver decision +// for routing every `@tauri-apps/api` core import through tauriCoreProxy.ts. +// +// A bare-specifier alias only catches `import ... from "@tauri-apps/api/core"`. +// Tauri's own submodules (`event`, `app`, `window`, `webview`, …) reach core +// via the RELATIVE import `import { invoke } from "./core.js"`, which an alias +// never sees — so their `plugin:event|*`, `plugin:window|*`, … traffic would +// bypass the wrapper and go unrecorded. A Vite `resolveId` hook can see the +// relative form (via the importer) and redirect it too, making the module seam +// canonical for the resolved core module regardless of how it is imported. +// +// This module holds only the pure decision so it is unit-testable without a +// Vite/Node type dependency (importing `vite` here would pull Node's global +// typings into the DOM-typed src program). The thin `Plugin` wrapper that calls +// it lives in vite.config.ts. + +/** Specifier the proxy uses to reach the real core implementation. */ +export const REAL_CORE_SPECIFIER = "@tauri-core-impl"; + +/** + * Decide where a module specifier should resolve so that every Tauri core + * import lands on the profiling proxy, while the proxy's own escape import + * reaches the real core without looping. + * + * Returns `proxyId` to redirect through the proxy, `realCoreId` for the escape + * hatch, or `null` to fall through to Vite's normal resolution. + */ +export function resolveTauriCore( + source: string, + importer: string | undefined, + proxyId: string, + realCoreId: string, +): string | null { + // Escape hatch: the proxy asks for the real implementation. Checked first so + // the proxy's own re-export never loops back into itself. + if (source === REAL_CORE_SPECIFIER) return realCoreId; + // Any other import originating in the proxy passes through untouched. + if (importer === proxyId) return null; + // Bare specifier — app code and external plugins (`plugin-opener`, …). + if (source === "@tauri-apps/api/core") return proxyId; + // Relative import from inside Tauri's own API package (`event`, `app`, + // `window`, `webview`, …), which resolve core as `./core.js`. + if (source === "./core.js" && importer?.includes("/@tauri-apps/api/")) { + return proxyId; + } + return null; +} diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts index 180593b39..da33a9522 100644 --- a/desktop/vite.config.ts +++ b/desktop/vite.config.ts @@ -2,12 +2,41 @@ import path from "node:path"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import { resolveTauriCore } from "./src/shared/profiling/viteTauriCoreProxy"; const host = process.env.TAURI_DEV_HOST; +// Temporary profiling harness (never merges): route every Tauri core `invoke` +// through a proxy that wraps it. A resolver (not a bare alias) is required +// because Tauri's own submodules (`event`, `app`, `window`, `webview`, …) reach +// core via the relative import `./core.js`, which an alias cannot see; the hook +// redirects both the bare specifier and that relative form to the proxy, while +// the proxy's `@tauri-core-impl` import escapes to the real module. +const tauriCoreProxyId = path.resolve( + __dirname, + "./src/shared/profiling/tauriCoreProxy.ts", +); +const tauriRealCoreId = path.resolve( + __dirname, + "./node_modules/@tauri-apps/api/core.js", +); +const tauriCoreProfilingProxy = { + name: "tauri-core-profiling-proxy", + enforce: "pre" as const, + resolveId(source: string, importer: string | undefined) { + return resolveTauriCore( + source, + importer, + tauriCoreProxyId, + tauriRealCoreId, + ); + }, +}; + // https://vite.dev/config/ export default defineConfig(async () => ({ plugins: [ + tauriCoreProfilingProxy, tanstackRouter({ target: "react", routesDirectory: "./src/app/routes", @@ -23,19 +52,6 @@ export default defineConfig(async () => ({ ], resolve: { alias: { - // Temporary profiling harness (never merges): route every - // `@tauri-apps/api/core` importer (raw consumers, invokeTauri, and every - // bundled Tauri plugin) through a proxy that wraps `invoke`. The proxy - // reaches the real module via `@tauri-core-impl` so this alias does not - // re-fire. Exact string match — plugin subpaths are unaffected. - "@tauri-apps/api/core": path.resolve( - __dirname, - "./src/shared/profiling/tauriCoreProxy.ts", - ), - "@tauri-core-impl": path.resolve( - __dirname, - "./node_modules/@tauri-apps/api/core.js", - ), "@": "/src", "@features-manifest": path.resolve(__dirname, "../preview-features.json"), "@model-capabilities-manifest": path.resolve(