diff --git a/packages/junior-dashboard/src/nitro.ts b/packages/junior-dashboard/src/nitro.ts index 8540f12fd..4ae0008d8 100644 --- a/packages/junior-dashboard/src/nitro.ts +++ b/packages/junior-dashboard/src/nitro.ts @@ -1,3 +1,6 @@ +import { cpSync, existsSync, mkdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import type { Nitro } from "nitro/types"; export interface JuniorDashboardNitroOptions { @@ -12,6 +15,7 @@ export interface JuniorDashboardNitroOptions { } type NitroRouteConfig = NonNullable; +const dashboardAssetNames = ["client.js", "tailwind.css"] as const; function normalizePath(path: string | undefined, fallback: string): string { const value = path?.trim() || fallback; @@ -31,6 +35,38 @@ function routeEntry(handler: string): { handler: string } { return { handler }; } +function dashboardAssetPath(fileName: string): string { + const candidates = [ + fileURLToPath(new URL(`./${fileName}`, import.meta.url)), + fileURLToPath(new URL(`../dist/${fileName}`, import.meta.url)), + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + + throw new Error( + `Junior dashboard asset ${fileName} was not built; run pnpm --filter @sentry/junior-dashboard build before building Nitro`, + ); +} + +function copyDashboardAssets(serverDir: string): void { + const targetDir = path.join( + serverDir, + "node_modules", + "@sentry", + "junior-dashboard", + "dist", + ); + mkdirSync(targetDir, { recursive: true }); + + for (const fileName of dashboardAssetNames) { + cpSync(dashboardAssetPath(fileName), path.join(targetDir, fileName)); + } +} + function virtualHandler(config: Record): string { return `import { defineHandler } from "nitro"; import { createDashboardApp } from "@sentry/junior-dashboard"; @@ -92,6 +128,9 @@ export function juniorDashboardNitro(options: JuniorDashboardNitroOptions): { nitro.options.virtual[handler] = virtualHandler(dashboardConfig); nitro.options.virtual["#junior-dashboard/config"] = `export const dashboard = ${JSON.stringify(dashboardConfig)};`; + nitro.hooks.hook("compiled", () => { + copyDashboardAssets(nitro.options.output.serverDir); + }); const dashboardRoutes: NitroRouteConfig = { ...dashboardPageRoutes(basePath, handler), diff --git a/packages/junior-dashboard/tests/dashboard-output.test.ts b/packages/junior-dashboard/tests/dashboard-output.test.ts new file mode 100644 index 000000000..147b19284 --- /dev/null +++ b/packages/junior-dashboard/tests/dashboard-output.test.ts @@ -0,0 +1,118 @@ +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; + +const packageRoot = path.resolve(import.meta.dirname, ".."); +const nitroBin = path.join(packageRoot, "node_modules", ".bin", "nitro"); + +function linkPackage(root: string, name: string, target: string): void { + const linkPath = path.join(root, "node_modules", ...name.split("/")); + fs.mkdirSync(path.dirname(linkPath), { recursive: true }); + fs.symlinkSync(target, linkPath, "dir"); +} + +function dashboardOutputAsset(functionDir: string, fileName: string): string { + return path.join( + functionDir, + "node_modules", + "@sentry", + "junior-dashboard", + "dist", + fileName, + ); +} + +function writeFixture(root: string): void { + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify( + { + private: true, + type: "module", + }, + null, + 2, + ), + ); + fs.writeFileSync( + path.join(root, "nitro.config.ts"), + `import { defineConfig } from "nitro"; +import { juniorDashboardNitro } from "@sentry/junior-dashboard/nitro"; + +export default defineConfig({ + preset: "vercel", + modules: [ + juniorDashboardNitro({ + authRequired: false, + allowedGoogleDomains: ["sentry.io"], + }), + ], +}); +`, + ); + + linkPackage(root, "nitro", path.join(packageRoot, "node_modules", "nitro")); + linkPackage(root, "@sentry/junior-dashboard", packageRoot); +} + +describe.sequential("dashboard Nitro production output", () => { + it("serves dashboard assets from the Vercel function cwd", async () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), "junior-dashboard-output-"), + ); + const originalCwd = process.cwd(); + + try { + writeFixture(root); + execFileSync(nitroBin, ["build"], { + cwd: root, + env: { ...process.env, CI: "true" }, + stdio: "pipe", + }); + + const functionDir = path.join( + root, + ".vercel", + "output", + "functions", + "__server.func", + ); + expect( + fs.existsSync(dashboardOutputAsset(functionDir, "client.js")), + ).toBe(true); + const css = fs.readFileSync( + dashboardOutputAsset(functionDir, "tailwind.css"), + "utf8", + ); + expect(css.length).toBeGreaterThan(0); + + process.chdir(functionDir); + const app = ( + await import( + `${pathToFileURL(path.join(functionDir, "index.mjs")).href}?t=${Date.now()}` + ) + ).default as { + fetch(request: Request, context?: unknown): Promise; + }; + + const client = await app.fetch( + new Request("http://localhost/api/dashboard/client.js"), + {}, + ); + expect(client.status).toBe(200); + expect(client.headers.get("content-type")).toContain( + "application/javascript", + ); + + const page = await app.fetch(new Request("http://localhost/"), {}); + expect(page.status).toBe(200); + expect(await page.text()).toContain(css.slice(0, 80)); + } finally { + process.chdir(originalCwd); + fs.rmSync(root, { force: true, recursive: true }); + } + }, 60_000); +}); diff --git a/packages/junior-dashboard/tests/dashboard-routes.test.ts b/packages/junior-dashboard/tests/dashboard-routes.test.ts index af51fe696..de7c8736b 100644 --- a/packages/junior-dashboard/tests/dashboard-routes.test.ts +++ b/packages/junior-dashboard/tests/dashboard-routes.test.ts @@ -1,3 +1,6 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createApp } from "@sentry/junior"; import type { JuniorReporting } from "@sentry/junior/reporting"; @@ -29,6 +32,35 @@ const dashboardEnvNames = [ "SENTRY_ORG_SLUG", ] as const; +function nitroFixture(routes: Record = {}) { + const compiledHooks: Array<() => void> = []; + const serverDir = fs.mkdtempSync( + path.join(os.tmpdir(), "junior-dashboard-nitro-"), + ); + + return { + compiledHooks, + serverDir, + nitro: { + hooks: { + hook(name: string, hook: () => void) { + if (name === "compiled") { + compiledHooks.push(hook); + } + }, + }, + options: { + output: { serverDir }, + routes, + virtual: {} as Record, + }, + }, + [Symbol.dispose]() { + fs.rmSync(serverDir, { force: true, recursive: true }); + }, + }; +} + function reporting(): JuniorReporting { return { async getHealth() { @@ -609,21 +641,16 @@ describe("dashboard routes", () => { }); it("registers dashboard Nitro routes before an existing catch-all route", () => { - const nitro = { - options: { - routes: { - "/**": { handler: "./server.ts" }, - }, - virtual: {} as Record, - }, - }; + using fixture = nitroFixture({ + "/**": { handler: "./server.ts" }, + }); juniorDashboardNitro({ allowedGoogleDomains: ["sentry.io"], trustedOrigins: ["https://junior.example.com"], - }).nitro.setup(nitro); + }).nitro.setup(fixture.nitro); - expect(Object.keys(nitro.options.routes).slice(0, 8)).toEqual([ + expect(Object.keys(fixture.nitro.options.routes).slice(0, 8)).toEqual([ "/", "/conversations", "/conversations/**", @@ -633,13 +660,39 @@ describe("dashboard routes", () => { "/api/auth", "/api/auth/**", ]); - expect(nitro.options.routes["/**"]).toEqual({ handler: "./server.ts" }); - expect(nitro.options.virtual["#junior-dashboard/config"]).toContain( - "sentry.io", - ); - expect(nitro.options.virtual["#junior-dashboard/handler"]).toContain( + expect(fixture.nitro.options.routes["/**"]).toEqual({ + handler: "./server.ts", + }); + expect(fixture.nitro.options.virtual["#junior-dashboard/config"]).toContain( "sentry.io", ); + expect( + fixture.nitro.options.virtual["#junior-dashboard/handler"], + ).toContain("sentry.io"); + }); + + it("copies dashboard assets into Nitro server output", () => { + using fixture = nitroFixture(); + + juniorDashboardNitro({ + allowedGoogleDomains: ["sentry.io"], + trustedOrigins: ["https://junior.example.com"], + }).nitro.setup(fixture.nitro); + + expect(fixture.compiledHooks).toHaveLength(1); + fixture.compiledHooks[0](); + + for (const fileName of ["client.js", "tailwind.css"]) { + const outputPath = path.join( + fixture.serverDir, + "node_modules", + "@sentry", + "junior-dashboard", + "dist", + fileName, + ); + expect(fs.statSync(outputPath).size).toBeGreaterThan(0); + } }); it("resolves auth policy from env when Nitro virtual config is unavailable", async () => {