diff --git a/frontend/.env.web b/frontend/.env.web new file mode 100644 index 0000000..ee69b3f --- /dev/null +++ b/frontend/.env.web @@ -0,0 +1,7 @@ +# Vite mode `web` (`vite build --mode web`, ticket #74 lot F1): the bundle the +# embedded server serves to a browser talks HTTP+WS instead of Tauri IPC. +# +# This lives in a mode env file rather than an inline `VITE_TRANSPORT=http vite +# build` prefix because the npm scripts must also run on Windows (NSIS bundle), +# where that shell syntax does not exist. +VITE_TRANSPORT=http diff --git a/frontend/package.json b/frontend/package.json index 5251e7f..527ddfa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,8 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", + "build:web": "tsc --noEmit && vite build --mode web --outDir dist-web --emptyOutDir", + "build:bundle": "npm run typecheck && vite build --outDir dist --emptyOutDir && vite build --mode web --outDir dist-web --emptyOutDir", "typecheck": "tsc --noEmit", "preview": "vite preview", "test": "vitest run", diff --git a/frontend/src/app/di.test.tsx b/frontend/src/app/di.test.tsx index c5392fb..1454f9c 100644 --- a/frontend/src/app/di.test.tsx +++ b/frontend/src/app/di.test.tsx @@ -16,6 +16,7 @@ import { shouldUseMock, shouldUseHttp, } from "./di"; +import { transportFromEnv } from "./transport"; afterEach(() => { vi.unstubAllEnvs(); @@ -66,6 +67,28 @@ describe("resolveTransport / web (HTTP+WS) selection (ticket #13, F1)", () => { }); }); +describe("transportFromEnv (ticket #74, F1)", () => { + // The build config derives `__IDEA_TRANSPORT__` from this same predicate, so + // pinning it here pins both the runtime transport and the build constant. + it("selects http only for the exact opt-in value", () => { + expect(transportFromEnv("http")).toBe("http"); + }); + + it.each([undefined, "", "tauri", "HTTP", "http ", "web"])( + "falls back to tauri for %o", + (value) => { + expect(transportFromEnv(value)).toBe("tauri"); + }, + ); + + it("agrees with shouldUseHttp for the value the build reads", () => { + vi.stubEnv("VITE_TRANSPORT", "http"); + expect(shouldUseHttp()).toBe(transportFromEnv("http") === "http"); + vi.stubEnv("VITE_TRANSPORT", ""); + expect(shouldUseHttp()).toBe(transportFromEnv("") === "http"); + }); +}); + describe("DIProvider / useGateways", () => { it("provides explicit gateways to consumers", () => { const gateways = resolveGatewaysMock(); diff --git a/frontend/src/app/di.tsx b/frontend/src/app/di.tsx index a11a729..9b5eab7 100644 --- a/frontend/src/app/di.tsx +++ b/frontend/src/app/di.tsx @@ -18,11 +18,11 @@ import type { Gateways } from "@/ports"; import { createTauriGateways } from "@/adapters"; import { createMockGateways } from "@/adapters/mock"; import { createHttpWsGateways } from "@/adapters/http"; +import { transportFromEnv, type Transport } from "./transport"; const GatewaysContext = createContext(null); -/** The selected transport backing the gateways. */ -export type Transport = "mock" | "http" | "tauri"; +export type { Transport }; /** Whether the mock adapters should be used (env-driven, overridable in tests). */ export function shouldUseMock(): boolean { @@ -36,14 +36,13 @@ export function shouldUseMock(): boolean { * and is never affected (ticket #13, lot F1). */ export function shouldUseHttp(): boolean { - return import.meta.env.VITE_TRANSPORT === "http"; + return transportFromEnv(import.meta.env.VITE_TRANSPORT) === "http"; } /** Resolves which transport to use. Mock wins, then explicit web, else Tauri. */ export function resolveTransport(): Transport { if (shouldUseMock()) return "mock"; - if (shouldUseHttp()) return "http"; - return "tauri"; + return transportFromEnv(import.meta.env.VITE_TRANSPORT); } /** Resolves the gateway set for the current environment. */ diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index 3cb59f7..d2985d8 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -19,6 +19,14 @@ if (!root) { // follows the main window's focused project; otherwise the full app. const viewParams = parseViewWindowParams(window.location.search); +// Ticket #74, F1: publish the transport this bundle was built for. `define` +// inlines it as a literal, and assigning it is a side effect, so it survives +// minification and tree-shaking — a built bundle can be identified (by QA, or +// from devtools) without grepping for a minified symbol. Both adapter sets ship +// in either bundle, so their mere presence proves nothing. +(window as unknown as { __IDEA_TRANSPORT__?: string }).__IDEA_TRANSPORT__ = + __IDEA_TRANSPORT__; + // Ticket #13, F2: in web (HTTP) transport mode the browser client renders the // pairing-gated, read-only `WebApp`. Desktop (Tauri) is unchanged — it renders // the full `App` (or a detached view window). Detached windows are desktop-only. diff --git a/frontend/src/app/transport.ts b/frontend/src/app/transport.ts new file mode 100644 index 0000000..4725ee5 --- /dev/null +++ b/frontend/src/app/transport.ts @@ -0,0 +1,19 @@ +/** + * The transport signal, shared by the app and the Vite build config. + * + * `vite.config.ts` imports {@link transportFromEnv} to compute the + * `__IDEA_TRANSPORT__` build constant from the very same `VITE_TRANSPORT` + * variable that {@link shouldUseHttp} reads (ticket #74, F1). One predicate, + * one variable: the greppable constant and the resolved transport cannot drift. + */ + +/** The selected transport backing the gateways. */ +export type Transport = "mock" | "http" | "tauri"; + +/** + * The transport a raw `VITE_TRANSPORT` value selects, mock aside. Web is opt-in + * (`"http"`) so the desktop path stays the default and is never affected. + */ +export function transportFromEnv(value: string | undefined): "http" | "tauri" { + return value === "http" ? "http" : "tauri"; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index d0a501f..1e5bb66 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -9,3 +9,15 @@ interface ImportMetaEnv { interface ImportMeta { readonly env: ImportMetaEnv; } + +/** + * The transport this bundle was built for, inlined as a literal by Vite + * `define` (ticket #74, F1). It is derived in `vite.config.ts` from the same + * `VITE_TRANSPORT` variable and the same `transportFromEnv()` predicate that + * `resolveTransport()` uses, so it always agrees with the adapters actually + * wired in — mock aside, which is a separate `VITE_USE_MOCK` switch. + * + * `main.tsx` publishes it on `window`, which makes it survive minification and + * lets a built bundle be identified without grepping for a minified symbol. + */ +declare const __IDEA_TRANSPORT__: "http" | "tauri"; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index bd67990..fe047a2 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,23 +1,41 @@ -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { fileURLToPath, URL } from "node:url"; +import { transportFromEnv } from "./src/app/transport"; + +const root = fileURLToPath(new URL(".", import.meta.url)); + // Vite config tuned for Tauri v2 (fixed dev port, no clearing the terminal). -export default defineConfig({ - plugins: [react(), tailwindcss()], - resolve: { - alias: { - "@": fileURLToPath(new URL("./src", import.meta.url)), +// +// Two build artefacts (ticket #74): the default mode emits the desktop bundle +// (`dist`, Tauri IPC transport), and `--mode web` reads `.env.web` to emit the +// browser bundle served by the embedded server (`dist-web`, HTTP+WS transport). +export default defineConfig(({ mode }) => { + // Same env file resolution Vite applies to `import.meta.env`, so the + // `__IDEA_TRANSPORT__` constant below is computed from the exact variable + // `resolveTransport()` reads, through the exact same predicate (#74, F1). + const env = loadEnv(mode, root); + + return { + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, }, - }, - clearScreen: false, - server: { - port: 5173, - strictPort: true, - }, - build: { - target: "es2021", - outDir: "dist", - }, + define: { + __IDEA_TRANSPORT__: JSON.stringify(transportFromEnv(env.VITE_TRANSPORT)), + }, + clearScreen: false, + server: { + port: 5173, + strictPort: true, + }, + build: { + target: "es2021", + outDir: "dist", + }, + }; }); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 0cab189..4263a85 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import { fileURLToPath, URL } from "node:url"; +import { transportFromEnv } from "./src/app/transport"; + // Vitest config for the frontend hexagonal layers (domain/ports/adapters/app). // jsdom is used so React-Testing-Library can render the DI provider. export default defineConfig({ @@ -12,6 +14,13 @@ export default defineConfig({ "@": fileURLToPath(new URL("./src", import.meta.url)), }, }, + // Mirrors the build constant (#74, F1) through the same predicate, so code + // reading `__IDEA_TRANSPORT__` stays testable. + define: { + __IDEA_TRANSPORT__: JSON.stringify( + transportFromEnv(process.env.VITE_TRANSPORT), + ), + }, test: { environment: "jsdom", globals: true,