From aa4916c1a5ed2c04e3e898074120334aace0b71b Mon Sep 17 00:00:00 2001 From: cevheri Date: Thu, 30 Jul 2026 22:01:45 +0300 Subject: [PATCH] fix(editor): serve Monaco from our own origin so the workspace works air-gapped @monaco-editor/react fetches Monaco from cdn.jsdelivr.net unless its loader is told otherwise, and nothing told it otherwise (#247). On a machine without internet the editor pane was a spinner that never resolved while the rest of the app looked healthy - connections, schema explorer and the results grid all rendered - so studio appeared fine while being unusable for its main purpose. The same dependency flaked the Channel E2E gate: 3 of the last 40 Docker builds failed waiting for .monaco-editor, every one of them a jsdelivr timeout on a change that had nothing to do with the editor. scripts/copy-monaco.mjs stages node_modules/monaco-editor/min/vs into public/monaco/vs, and src/lib/editor/monaco-loader.ts points the AMD loader at that path. monaco-editor was already a direct dependency, so the assets were on disk at build time all along - they were simply never served. Wired into the dev and build scripts, and explicitly into the Dockerfile, which calls next build directly and therefore inherits nothing from package.json. That one is what feeds the flaky gate. The tarballs, .deb/.rpm, snap, AppImage, Flatpak and npx payloads all derive from bun run build, so they inherit it. NEXT_PUBLIC_MONACO_VS_PATH overrides the path for deployments that serve the bundle elsewhere. libredb-platform needs it or its own copy step: the npm package ships only dist and bin, so an embedded studio resolves /monaco/vs against platform's origin. Recorded in the platform-integration rule, since nothing in CI can catch that from this repo. ESLint and oxlint now skip public/monaco: 16 MB of minified vendor JS in the working tree exhausts ESLint's heap outright, and because the directory only exists after something has built, a clean lint before the first build says nothing about the next one. Biome needs no change - its includes are an allowlist of first-party trees. e2e/offline-editor.spec.ts is the regression guard the existing specs could not be: they pass because the CDN is reachable, which is the wrong thing to assert for a self-hosted product. It blocks every off-origin request, then asserts the editor renders, a query against the SQLite sample returns its rows, and that nothing off-origin was attempted at all. Verified against both a production build and bun dev, and confirmed the spec fails without the loader configuration - zero .monaco-editor elements, the exact symptom from the issue. Two inert cdn.jsdelivr.net strings do survive in .next/static (the loader library's own default config literal and snapdom's font-host allowlist); no request is made to either, which the offline spec pins. --- .claude/rules/platform-integration.md | 14 ++++ .env.example | 8 ++ .gitignore | 3 + .oxlintrc.json | 2 +- Dockerfile | 4 +- docs/editor/README.md | 13 ++++ e2e/offline-editor.spec.ts | 68 +++++++++++++++++ eslint.config.mjs | 6 +- package.json | 4 +- scripts/copy-monaco.mjs | 64 ++++++++++++++++ src/components/QueryEditor.tsx | 5 ++ src/lib/editor/monaco-loader.ts | 31 ++++++++ tests/isolated/monaco-loader-wiring.test.ts | 36 +++++++++ tests/run-components.sh | 10 ++- tests/unit/copy-monaco.test.ts | 84 +++++++++++++++++++++ tests/unit/editor/monaco-loader.test.ts | 61 +++++++++++++++ tests/unit/packaging-monaco-assets.test.ts | 58 ++++++++++++++ 17 files changed, 465 insertions(+), 6 deletions(-) create mode 100644 e2e/offline-editor.spec.ts create mode 100644 scripts/copy-monaco.mjs create mode 100644 src/lib/editor/monaco-loader.ts create mode 100644 tests/isolated/monaco-loader-wiring.test.ts create mode 100644 tests/unit/copy-monaco.test.ts create mode 100644 tests/unit/editor/monaco-loader.test.ts create mode 100644 tests/unit/packaging-monaco-assets.test.ts diff --git a/.claude/rules/platform-integration.md b/.claude/rules/platform-integration.md index cb65f313ab..7578c3fb5e 100644 --- a/.claude/rules/platform-integration.md +++ b/.claude/rules/platform-integration.md @@ -43,6 +43,20 @@ Platform's `globals.css` must scan ALL studio dist files (tsup creates chunks): ``` Without chunk scanning, responsive/utility classes in chunked components won't generate CSS. +**Monaco assets are NOT shipped either** (same `files` restriction). Standalone studio stages +`node_modules/monaco-editor/min/vs` into `public/monaco/vs` at build time (`scripts/copy-monaco.mjs`, +issue #247) and points the loader at `/monaco/vs`; embedded studio resolves that path against +*platform's* origin, so platform must serve it too: + +```bash +# platform build step — monaco-editor arrives transitively with @libredb/studio +cp -r node_modules/monaco-editor/min/vs public/monaco/vs +``` + +Serving them from a different path instead is fine — set `NEXT_PUBLIC_MONACO_VS_PATH` in platform. +Skipping both leaves the editor pane a spinner that never resolves (404s on `/monaco/vs/loader.js`), +in embedded mode only. + **Studio's `globals.css` is NOT shipped** (`package.json` `files` is `["dist","bin"]`), so anything expressed as a global CSS rule instead of a utility class exists in standalone studio only. Platform must replicate those rules in its own `globals.css`. Currently that means the cursor-affordance block diff --git a/.env.example b/.env.example index be919d6d78..3173f51e25 100644 --- a/.env.example +++ b/.env.example @@ -233,3 +233,11 @@ LLM_MODEL=gemini-2.5-flash # SEED_CACHE_TTL_MS=60000 # Cache TTL in ms (default: 60s) # Credential env vars referenced in seed config (e.g., ${MY_DB_PASSWORD}): # MY_DB_PASSWORD=secret + +# ─── Monaco Editor Assets ──────────────────────────────────────────────────── +# The SQL editor is served from this origin: `bun run build` stages the Monaco AMD +# bundle from node_modules into public/monaco/vs, so no CDN is contacted at runtime +# and the app works air-gapped. Override only when the assets live elsewhere — +# a sub-path mount, a CDN of your own, or libredb-platform embedding the npm +# package (see .claude/rules/platform-integration.md). +# NEXT_PUBLIC_MONACO_VS_PATH=/monaco/vs diff --git a/.gitignore b/.gitignore index 85be46293c..383c802822 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ /out/ .next +# Monaco AMD bundle staged from node_modules by scripts/copy-monaco.mjs (issue #247) +/public/monaco/ + # production /build dist diff --git a/.oxlintrc.json b/.oxlintrc.json index 79dac434d3..9d59242e8e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -56,5 +56,5 @@ } } ], - "ignorePatterns": ["dist/**", ".next/**", "out/**", "build/**", "coverage/**", "node_modules/**", "snap-payload/**", "desktop/src-tauri/payload/**", "desktop/src-tauri/target/**", "desktop/src-tauri/bin/**", ".claude/**", "next-env.d.ts"] + "ignorePatterns": ["dist/**", ".next/**", "public/monaco/**", "out/**", "build/**", "coverage/**", "node_modules/**", "snap-payload/**", "desktop/src-tauri/payload/**", "desktop/src-tauri/target/**", "desktop/src-tauri/bin/**", ".claude/**", "next-env.d.ts"] } diff --git a/Dockerfile b/Dockerfile index f2d01e1b35..b9c5e1213b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,9 @@ ENV JWT_SECRET=$JWT_SECRET_BUILD ENV ADMIN_PASSWORD=$ADMIN_PASSWORD_BUILD ENV USER_PASSWORD=$USER_PASSWORD_BUILD -RUN npx next build +# Stage Monaco from node_modules into public/ so the editor is served from our own +# origin (issue #247). Explicit here: this bypasses the package.json build script. +RUN node scripts/copy-monaco.mjs && npx next build # Production image - use Node.js slim for lower memory footprint # trixie-slim: glibc must match the stage where native modules were built (see builder). diff --git a/docs/editor/README.md b/docs/editor/README.md index 6cb673174f..1cf8eba99f 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -9,11 +9,24 @@ execution pipeline behind it. | [SQL Alias Completion](sql-alias-completion.md) | Context-aware autocompletion with table-alias resolution (`FROM`/`JOIN`/CTE), the completion provider, and the alias extractor | | [Query Optimization](query-optimization.md) | Query pagination, silent auto-limiting, Load More, background `EXPLAIN`, and performance insights | +## Asset loading + +Monaco is served from studio's own origin, never a CDN — `bun run dev` / `bun run build` stage +`node_modules/monaco-editor/min/vs` into `public/monaco/vs` (`scripts/copy-monaco.mjs`) and +`src/lib/editor/monaco-loader.ts` points `@monaco-editor/react`'s AMD loader at that path, +replacing its `cdn.jsdelivr.net` default. The workspace therefore issues zero off-origin +requests and works air-gapped (`e2e/offline-editor.spec.ts` asserts exactly that). + +Deployments that serve the bundle elsewhere — a sub-path mount, or platform embedding the npm +package, which does not ship `public/` — set `NEXT_PUBLIC_MONACO_VS_PATH`. + ## Source map | Area | Source | |------|--------| | Editor component | `src/components/QueryEditor.tsx` | +| Monaco loader config | `src/lib/editor/monaco-loader.ts` | +| Asset staging | `scripts/copy-monaco.mjs` | | Completion provider | `src/lib/editor/sql-completions.ts` | | Alias extraction | `src/lib/sql/alias-extractor.ts` | | Query limiting | `src/lib/db/utils/query-limiter.ts` | diff --git a/e2e/offline-editor.spec.ts b/e2e/offline-editor.spec.ts new file mode 100644 index 0000000000..ea0f0953ca --- /dev/null +++ b/e2e/offline-editor.spec.ts @@ -0,0 +1,68 @@ +/** + * Air-gapped workspace (issue #247): with every off-origin request blocked, the SQL + * editor must still render and run a query. + * + * Before Monaco was self-hosted, @monaco-editor/react fetched it from cdn.jsdelivr.net: + * the editor pane stayed a spinner forever on an isolated network, while the rest of the + * app looked healthy — and the Channel E2E gate flaked whenever the runner could not + * reach the CDN. The existing specs pass *because* the CDN is reachable, which is the + * wrong thing to assert for a self-hosted product, so this one asserts the opposite: + * nothing the workspace needs comes from another host. + */ +import { expect, test, type Page } from "@playwright/test"; + +async function loginAsUser(page: Page): Promise { + await page.goto("/login"); + await page.locator('input[type="email"]').fill("user@libredb.org"); + await page.locator('input[type="password"]').fill("test-user"); + await page.getByRole("button", { name: "Sign In" }).click(); + await page.waitForURL("/"); +} + +test.describe("Workspace with all off-origin requests blocked", () => { + // Login (15s) + async SQLite seed (45s) + monaco (20s) + grid (20s) exceeds the default budget. + test.describe.configure({ timeout: 120_000 }); + + test("renders the editor and runs a query without reaching any external host", async ({ page, baseURL }) => { + const appOrigin = new URL(baseURL as string).origin; + const blocked: string[] = []; + + await page.route("**/*", async (route) => { + const url = route.request().url(); + if (url.startsWith(appOrigin) || url.startsWith("data:") || url.startsWith("blob:")) { + return route.continue(); + } + blocked.push(url); + return route.abort(); + }); + + await loginAsUser(page); + + // The editor itself: this is what the CDN dependency used to break. + await expect(page.locator(".monaco-editor").first()).toBeVisible({ timeout: 20_000 }); + + const sample = page.locator("text=Sample (Employees)").first(); + await expect(sample).toBeVisible({ timeout: 45_000 }); + await sample.click(); + + await page.waitForFunction( + () => + ((window as unknown as { monaco?: { editor: { getEditors(): unknown[] } } }).monaco?.editor.getEditors() + .length ?? 0) > 0, + ); + await page.evaluate(() => { + const monaco = (window as unknown as { monaco?: { editor: { getEditors(): { setValue(v: string): void }[] } } }) + .monaco; + if (!monaco) throw new Error("monaco global not found"); + monaco.editor.getEditors()[0].setValue("SELECT COUNT(*) AS employee_count FROM employee"); + }); + await page.getByRole("button", { name: "RUN" }).click(); + + await expect(page.locator("text=employee_count").first()).toBeVisible({ timeout: 20_000 }); + await expect(page.locator("text=1000").first()).toBeVisible({ timeout: 5_000 }); + + // Zero off-origin traffic: anything here is a new external dependency that would + // break air-gapped installs, whether or not this run had internet access. + expect(blocked).toEqual([]); + }); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index cb1755b47a..5a115648f6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,9 +11,13 @@ const eslintConfig = defineConfig([ // desktop AppImage build (scripts/build-desktop-appimage.sh stages the whole // standalone payload, node_modules included, in there); // .claude/ holds agent worktrees whose checkouts (and .next build output) - // must not be linted from this checkout + // must not be linted from this checkout; + // public/monaco/** is the Monaco AMD bundle staged from node_modules by + // scripts/copy-monaco.mjs (issue #247) — 16 MB of minified vendor JS that + // exhausts ESLint's heap, and it only appears once something has built globalIgnores([ ".next/**", + "public/monaco/**", "out/**", "build/**", "dist/**", diff --git a/package.json b/package.json index 7e77578ab9..366e5fc8d4 100644 --- a/package.json +++ b/package.json @@ -80,8 +80,8 @@ }, "packageManager": "bun@1.3.14", "scripts": { - "dev": "next dev", - "build": "next build", + "dev": "node scripts/copy-monaco.mjs && next dev", + "build": "node scripts/copy-monaco.mjs && next build", "build:lib": "tsup", "attw": "rm -rf .attw && bun pm pack --quiet --destination .attw && attw .attw/*.tgz --profile node16", "prepublishOnly": "tsup && bun run attw", diff --git a/scripts/copy-monaco.mjs b/scripts/copy-monaco.mjs new file mode 100644 index 0000000000..08e49c30d9 --- /dev/null +++ b/scripts/copy-monaco.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Monaco asset staging (issue #247). + * + * `@monaco-editor/react` fetches Monaco from cdn.jsdelivr.net unless its loader is + * pointed elsewhere, which leaves the SQL editor broken on air-gapped installs and + * makes CI depend on a third-party CDN. This step copies the AMD bundle that ships + * with the `monaco-editor` dependency into `public/monaco/vs`, so Next serves it from + * our own origin; `src/lib/editor/monaco-loader.ts` is the matching client-side half. + * + * Wired into the `dev` and `build` scripts in package.json, and into the Dockerfile, + * which calls `next build` directly and so does not inherit the npm script. Every other + * artifact (standalone tarballs, .deb/.rpm, snap, AppImage, Flatpak, npx) is derived from + * `bun run build`, so they inherit it. Unit tested in tests/unit/copy-monaco.test.ts. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const SOURCE_SUBPATH = path.join("node_modules", "monaco-editor", "min", "vs"); +const TARGET_SUBPATH = path.join("public", "monaco", "vs"); + +/** + * Copies the Monaco AMD bundle into the served `public/` tree. + * + * @param {string} root Repository root to stage within. + * @returns {{ source: string, target: string, version: string }} + */ +export function stageMonacoAssets(root) { + const source = path.join(root, SOURCE_SUBPATH); + const target = path.join(root, TARGET_SUBPATH); + const manifest = path.join(root, "node_modules", "monaco-editor", "package.json"); + + if (!fs.existsSync(source)) { + throw new Error( + `Cannot stage the editor: ${SOURCE_SUBPATH} is missing. Install dependencies (bun install) so the monaco-editor package is on disk.`, + ); + } + + // Remove first: a plain overwrite would leave files behind that a newer + // monaco-editor no longer ships, and stale AMD chunks break the loader. + fs.rmSync(target, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.cpSync(source, target, { recursive: true }); + + const { version } = JSON.parse(fs.readFileSync(manifest, "utf8")); + return { source, target, version }; +} + +function main() { + try { + const { target, version } = stageMonacoAssets(process.cwd()); + console.log(`Staged monaco-editor ${version} into ${path.relative(process.cwd(), target)}`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +// CLI entry only when executed directly (the unit test imports this module). +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/src/components/QueryEditor.tsx b/src/components/QueryEditor.tsx index e6285f794e..7c3b5564de 100644 --- a/src/components/QueryEditor.tsx +++ b/src/components/QueryEditor.tsx @@ -12,8 +12,13 @@ import { registerSQLCompletionProvider } from "@/lib/editor/sql-completions"; import type { SchemaCompletionCache, SchemaColumnItem } from "@/lib/editor/sql-completions"; import { registerMongoDBCompletionProvider } from "@/lib/editor/mongodb-completions"; import { registerLibreDBLanguage } from "@/lib/editor/libredb-language"; +import { configureMonacoLoader } from "@/lib/editor/monaco-loader"; import { useAiChat } from "@/hooks/use-ai-chat"; +// Serve Monaco from our own origin rather than @monaco-editor/react's jsdelivr default. +// Runs at module load so it is in place before the first mounts. +configureMonacoLoader(); + export interface QueryEditorRef { getSelectedText: () => string; getEffectiveQuery: () => string; diff --git a/src/lib/editor/monaco-loader.ts b/src/lib/editor/monaco-loader.ts new file mode 100644 index 0000000000..d6a84c1b54 --- /dev/null +++ b/src/lib/editor/monaco-loader.ts @@ -0,0 +1,31 @@ +import { loader } from "@monaco-editor/react"; + +/** + * Where the self-hosted Monaco AMD bundle is served from. + * + * `@monaco-editor/react` defaults to `https://cdn.jsdelivr.net/npm/monaco-editor@/min/vs`, + * which leaves the editor broken on air-gapped installs and makes CI depend on a third-party + * CDN. `scripts/copy-monaco.mjs` stages `node_modules/monaco-editor/min/vs` into + * `public/monaco/vs` at build time, so this path is served from our own origin. + */ +export const DEFAULT_MONACO_VS_PATH = "/monaco/vs"; + +interface MonacoLoaderLike { + config: (config: { paths?: { vs?: string } }) => void; +} + +/** + * Resolves the base path Monaco's AMD loader should fetch from. Deployments that serve the + * assets elsewhere (a sub-path mount, or platform embedding the npm package) set + * `NEXT_PUBLIC_MONACO_VS_PATH`. + */ +export function resolveMonacoVsPath(override: string | undefined): string { + const trimmed = override?.trim(); + if (!trimmed) return DEFAULT_MONACO_VS_PATH; + return trimmed.replace(/\/+$/, ""); +} + +/** Points Monaco's AMD loader at our own origin. Must run before the first `` mounts. */ +export function configureMonacoLoader(monacoLoader: MonacoLoaderLike = loader): void { + monacoLoader.config({ paths: { vs: resolveMonacoVsPath(process.env.NEXT_PUBLIC_MONACO_VS_PATH) } }); +} diff --git a/tests/isolated/monaco-loader-wiring.test.ts b/tests/isolated/monaco-loader-wiring.test.ts new file mode 100644 index 0000000000..ffaad95b3a --- /dev/null +++ b/tests/isolated/monaco-loader-wiring.test.ts @@ -0,0 +1,36 @@ +/** + * Isolated: verifies that importing QueryEditor points Monaco's AMD loader at our own + * origin. Must own its process — it mocks @monaco-editor/react and pulls the whole + * QueryEditor module chain, and the assertion depends on observing the loader call made + * at module-evaluation time (hence the dynamic import after the mock is registered). + * + * Without this, @monaco-editor/react silently fetches Monaco from cdn.jsdelivr.net: + * no editor on air-gapped installs, and CI flakes whenever the CDN is unreachable. + */ +import { expect, mock, test } from "bun:test"; + +const configCalls: Array<{ paths?: { vs?: string } }> = []; + +mock.module("@monaco-editor/react", () => ({ + default: () => null, + loader: { + init: () => Promise.resolve(), + config: (config: { paths?: { vs?: string } }) => { + configCalls.push(config); + }, + }, + useMonaco: () => null, +})); + +// Imported after the mock so the module-scope loader configuration is observable. +await import("@/components/QueryEditor"); + +test("importing QueryEditor configures Monaco to load from our own origin", () => { + expect(configCalls).toEqual([{ paths: { vs: "/monaco/vs" } }]); +}); + +test("no configured Monaco path points at an external host", () => { + for (const call of configCalls) { + expect(call.paths?.vs?.startsWith("/")).toBe(true); + } +}); diff --git a/tests/run-components.sh b/tests/run-components.sh index e3a64a9f97..0ac5e35c7a 100755 --- a/tests/run-components.sh +++ b/tests/run-components.sh @@ -23,7 +23,7 @@ set -e PASS=0 FAIL=0 -TOTAL_GROUPS=20 +TOTAL_GROUPS=21 EXTRA_BUN_ARGS=("$@") GROUP_INDEX=0 COVERAGE_MODE=0 @@ -92,6 +92,14 @@ run_group "Group 0b: Factory singleton" \ run_group "Group 0c: Exports shim" --nocov \ tests/isolated/exports-shim.test.ts +# Group 0d: Monaco loader wiring (isolated — mocks @monaco-editor/react and must observe +# the loader call QueryEditor makes at module-evaluation time, so it dynamic-imports the +# component after the mock is registered). +# --nocov: same load-only concern as Group 0c — importing QueryEditor pulls its whole +# module chain without rendering it. +run_group "Group 0d: Monaco loader wiring" --nocov \ + tests/isolated/monaco-loader-wiring.test.ts + # Group 1: Studio (isolated — mocks almost every child component) run_group "Group 1/6: Studio" \ tests/components/Studio.test.tsx diff --git a/tests/unit/copy-monaco.test.ts b/tests/unit/copy-monaco.test.ts new file mode 100644 index 0000000000..d9c23b81e9 --- /dev/null +++ b/tests/unit/copy-monaco.test.ts @@ -0,0 +1,84 @@ +/** + * Unit tests for the Monaco asset staging step (scripts/copy-monaco.mjs). + * `stageMonacoAssets` is exercised against throwaway temp-dir fixtures; the CLI + * describe block runs the real script as a subprocess so the exit codes the build + * depends on are covered too. + */ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stageMonacoAssets } from "../../scripts/copy-monaco.mjs"; + +const tempDirs: string[] = []; + +function makeFixture({ withMonaco = true }: { withMonaco?: boolean } = {}): string { + const root = mkdtempSync(join(tmpdir(), "monaco-stage-")); + tempDirs.push(root); + if (withMonaco) { + const vsDir = join(root, "node_modules", "monaco-editor", "min", "vs"); + mkdirSync(join(vsDir, "editor"), { recursive: true }); + writeFileSync(join(root, "node_modules", "monaco-editor", "package.json"), JSON.stringify({ version: "0.55.1" })); + writeFileSync(join(vsDir, "loader.js"), "// amd loader"); + writeFileSync(join(vsDir, "editor", "editor.main.js"), "// editor"); + } + return root; +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop() as string, { recursive: true, force: true }); + } +}); + +describe("stageMonacoAssets", () => { + test("stages the AMD bundle into public/monaco/vs", () => { + const root = makeFixture(); + + const result = stageMonacoAssets(root); + + expect(readFileSync(join(root, "public", "monaco", "vs", "loader.js"), "utf8")).toBe("// amd loader"); + expect(existsSync(join(root, "public", "monaco", "vs", "editor", "editor.main.js"))).toBe(true); + expect(result.target).toBe(join(root, "public", "monaco", "vs")); + expect(result.version).toBe("0.55.1"); + }); + + test("replaces a previously staged copy so an upgrade cannot serve stale assets", () => { + const root = makeFixture(); + const staged = join(root, "public", "monaco", "vs"); + mkdirSync(staged, { recursive: true }); + writeFileSync(join(staged, "loader.js"), "// stale loader from an older monaco"); + + stageMonacoAssets(root); + + expect(readFileSync(join(staged, "loader.js"), "utf8")).toBe("// amd loader"); + }); + + test("fails loudly when monaco-editor is not installed", () => { + const root = makeFixture({ withMonaco: false }); + + expect(() => stageMonacoAssets(root)).toThrow(/monaco-editor/); + }); +}); + +describe("copy-monaco CLI", () => { + const script = join(import.meta.dir, "..", "..", "scripts", "copy-monaco.mjs"); + + test("exits 0 and reports the staged path", () => { + const root = makeFixture(); + + const proc = Bun.spawnSync(["node", script], { cwd: root }); + + expect(proc.exitCode).toBe(0); + expect(proc.stdout.toString()).toContain("public/monaco/vs"); + }); + + test("exits 1 with an actionable message when the dependency is missing", () => { + const root = makeFixture({ withMonaco: false }); + + const proc = Bun.spawnSync(["node", script], { cwd: root }); + + expect(proc.exitCode).toBe(1); + expect(proc.stderr.toString()).toContain("monaco-editor"); + }); +}); diff --git a/tests/unit/editor/monaco-loader.test.ts b/tests/unit/editor/monaco-loader.test.ts new file mode 100644 index 0000000000..47ca8ab562 --- /dev/null +++ b/tests/unit/editor/monaco-loader.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { configureMonacoLoader, DEFAULT_MONACO_VS_PATH, resolveMonacoVsPath } from "@/lib/editor/monaco-loader"; + +// --------------------------------------------------------------------------- +// Fake loader — stands in for @monaco-editor/react's module-level loader, +// recording the config it is handed instead of fetching Monaco. +// --------------------------------------------------------------------------- + +function createFakeLoader() { + const calls: Array<{ paths?: { vs?: string } }> = []; + return { + calls, + config: (config: { paths?: { vs?: string } }) => { + calls.push(config); + }, + }; +} + +const ENV_KEY = "NEXT_PUBLIC_MONACO_VS_PATH"; + +afterEach(() => { + delete process.env[ENV_KEY]; +}); + +describe("resolveMonacoVsPath", () => { + test("defaults to the self-hosted /monaco/vs path", () => { + expect(resolveMonacoVsPath(undefined)).toBe("/monaco/vs"); + expect(DEFAULT_MONACO_VS_PATH).toBe("/monaco/vs"); + }); + + test("uses an explicit override path", () => { + expect(resolveMonacoVsPath("/studio-assets/monaco/vs")).toBe("/studio-assets/monaco/vs"); + }); + + test("strips trailing slashes from an override", () => { + expect(resolveMonacoVsPath("/assets/monaco/vs//")).toBe("/assets/monaco/vs"); + }); + + test("falls back to the default when the override is blank", () => { + expect(resolveMonacoVsPath(" ")).toBe(DEFAULT_MONACO_VS_PATH); + }); +}); + +describe("configureMonacoLoader", () => { + test("points the loader at the self-hosted path instead of a CDN", () => { + const fakeLoader = createFakeLoader(); + + configureMonacoLoader(fakeLoader); + + expect(fakeLoader.calls).toEqual([{ paths: { vs: "/monaco/vs" } }]); + }); + + test("honours a NEXT_PUBLIC_MONACO_VS_PATH override", () => { + process.env[ENV_KEY] = "/embedded/monaco/vs"; + const fakeLoader = createFakeLoader(); + + configureMonacoLoader(fakeLoader); + + expect(fakeLoader.calls).toEqual([{ paths: { vs: "/embedded/monaco/vs" } }]); + }); +}); diff --git a/tests/unit/packaging-monaco-assets.test.ts b/tests/unit/packaging-monaco-assets.test.ts new file mode 100644 index 0000000000..db43556a86 --- /dev/null +++ b/tests/unit/packaging-monaco-assets.test.ts @@ -0,0 +1,58 @@ +/** + * Unit test for the Monaco asset wiring (issue #247). Self-hosting only holds if + * EVERY path that builds the app stages `public/monaco/vs` first: the package.json + * scripts (dev, build - the latter also feeds the standalone payload and therefore the + * tarballs, .deb/.rpm, snap, AppImage and Flatpak) and the Dockerfile, which calls + * `next build` directly and so does not inherit the package.json script. + * + * Asserted as text because a real build in a unit test is not viable; the runtime proof + * is e2e/offline-editor.spec.ts. + */ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = join(import.meta.dir, "..", ".."); +const STAGE_STEP = "scripts/copy-monaco.mjs"; + +function readRepoFile(relativePath: string): string { + return readFileSync(join(ROOT, relativePath), "utf8"); +} + +describe("Monaco asset staging wiring", () => { + const pkg = JSON.parse(readRepoFile("package.json")) as { scripts: Record }; + + test.each(["dev", "build"])("the %s script stages Monaco before Next runs", (scriptName) => { + const script = pkg.scripts[scriptName]; + + expect(script).toContain(STAGE_STEP); + expect(script.indexOf(STAGE_STEP)).toBeLessThan(script.indexOf("next")); + }); + + test("the Dockerfile stages Monaco before its direct next build", () => { + const dockerfile = readRepoFile("Dockerfile"); + + expect(dockerfile).toContain(STAGE_STEP); + expect(dockerfile.indexOf(STAGE_STEP)).toBeLessThan(dockerfile.indexOf("next build")); + }); + + test("the staged copy is generated, not committed", () => { + expect(readRepoFile(".gitignore")).toMatch(/^\/?public\/monaco\/?$/m); + }); + + // 16 MB of minified vendor JS lands in the working tree once anything has built. + // ESLint OOMs on it (Babel deoptimises, heap exhausted), so the lint gate has to + // skip it — and it only breaks *after* a build, which is easy to miss locally. + test.each([ + [".oxlintrc.json", "ignorePatterns"], + ["eslint.config.mjs", "globalIgnores"], + ])("%s excludes the staged assets from linting", (configFile) => { + expect(readRepoFile(configFile)).toContain("public/monaco/**"); + }); + + test("the formatter only scans first-party trees, never public/", () => { + const biome = JSON.parse(readRepoFile("biome.json")) as { files: { includes: string[] } }; + + expect(biome.files.includes.some((glob) => glob.startsWith("public"))).toBe(false); + }); +});