Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/junior-dashboard/src/nitro.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -12,6 +15,7 @@ export interface JuniorDashboardNitroOptions {
}

type NitroRouteConfig = NonNullable<Nitro["options"]["routes"]>;
const dashboardAssetNames = ["client.js", "tailwind.css"] as const;

function normalizePath(path: string | undefined, fallback: string): string {
const value = path?.trim() || fallback;
Expand All @@ -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, unknown>): string {
return `import { defineHandler } from "nitro";
import { createDashboardApp } from "@sentry/junior-dashboard";
Expand Down Expand Up @@ -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),
Expand Down
118 changes: 118 additions & 0 deletions packages/junior-dashboard/tests/dashboard-output.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response>;
};

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);
});
83 changes: 68 additions & 15 deletions packages/junior-dashboard/tests/dashboard-routes.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -29,6 +32,35 @@ const dashboardEnvNames = [
"SENTRY_ORG_SLUG",
] as const;

function nitroFixture(routes: Record<string, { handler: string }> = {}) {
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<string, string>,
},
},
[Symbol.dispose]() {
fs.rmSync(serverDir, { force: true, recursive: true });
},
};
}

function reporting(): JuniorReporting {
return {
async getHealth() {
Expand Down Expand Up @@ -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<string, string>,
},
};
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/**",
Expand All @@ -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 () => {
Expand Down
Loading