fix: workover of the monaco update

This commit is contained in:
2026-07-02 14:14:31 +02:00
parent ef6c970654
commit c07ba9a4d8
27 changed files with 69078 additions and 245377 deletions

View File

@@ -158,8 +158,9 @@ mix assets.build
## Monaco Editor
Monaco is bundled via esbuild using its ESM entry point (`monaco-editor/esm/vs/editor/editor.api.js`). The
bundle is built as part of `mix assets.build` and lives at `priv/static/assets/monaco.js` and `monaco.css`.
Monaco is bundled via esbuild using its ESM entry point (`monaco-editor/esm/vs/editor/editor.main.js`). The
main bundle is built as part of `mix assets.build` and lives at `priv/static/assets/monaco.js` and
`monaco.css`; ESM worker bundles live under `priv/static/assets/monaco/`.
```bash
mix monaco.version # show current monaco-editor version

View File

@@ -1,35 +0,0 @@
// App-wide error reporter — catches JS errors and displays them as an overlay.
// Enables debugging in WKWebView where devtools are unavailable.
(function () {
const overlay = document.createElement("div");
overlay.id = "error-reporter";
overlay.style.cssText =
"position:fixed;bottom:0;left:0;right:0;max-height:200px;overflow:auto;background:#1a1a2e;color:#f0f0f0;font:12px monospace;padding:8px 12px;border-top:2px solid #e74c3c;z-index:99999;display:none;";
overlay.innerHTML = '<strong style="color:#e74c3c">⚠ JS Errors</strong><pre id="error-reporter-log"></pre>';
document.body.appendChild(overlay);
const log = document.getElementById("error-reporter-log");
const append = (msg) => {
overlay.style.display = "block";
log.textContent += msg + "\n";
overlay.scrollTop = overlay.scrollHeight;
};
window.addEventListener("error", (e) => {
const src = e.filename || "";
const loc = e.lineno !== undefined ? `:${e.lineno}` : "";
append(`[${new Date().toLocaleTimeString()}] ${e.message || e.error}\n at ${src}${loc}`);
});
window.addEventListener("unhandledrejection", (e) => {
append(`[${new Date().toLocaleTimeString()}] Unhandled rejection: ${e.reason}`);
});
// Also wrap console.error to catch Monaco errors
const origError = console.error;
console.error = (...args) => {
origError.apply(console, args);
append(`[${new Date().toLocaleTimeString()}] ${args.join(" ")}`);
};
})();

View File

@@ -4,7 +4,51 @@ import { registerLiquidLanguage, registerMarkdownWithMacrosLanguage } from "./la
let monacoLoaderPromise;
const monacoEditors = new Map();
const monacoWorkerUrls = {
editor: "/assets/monaco/editor.worker.js",
css: "/assets/monaco/css.worker.js",
html: "/assets/monaco/html.worker.js",
json: "/assets/monaco/json.worker.js",
ts: "/assets/monaco/ts.worker.js"
};
const workerNameForLanguage = (label) => {
switch (label) {
case "css":
case "scss":
case "less":
return "css";
case "html":
case "handlebars":
case "razor":
return "html";
case "json":
return "json";
case "typescript":
case "javascript":
return "ts";
default:
return "editor";
}
};
const ensureMonacoEnvironment = () => {
if (globalThis.MonacoEnvironment?.getWorker) {
return;
}
globalThis.MonacoEnvironment = {
...globalThis.MonacoEnvironment,
getWorker(_workerId, label) {
const workerName = workerNameForLanguage(label);
return new Worker(monacoWorkerUrls[workerName], { name: label, type: "module" });
}
};
};
export const loadMonaco = () => {
ensureMonacoEnvironment();
if (window.monaco?.editor) {
ensureMonacoTheme(window.monaco);
registerLiquidLanguage(window.monaco);

View File

@@ -0,0 +1 @@
import "monaco-editor/esm/vs/language/css/css.worker.js";

View File

@@ -0,0 +1 @@
import "monaco-editor/esm/vs/editor/editor.worker.js";

View File

@@ -0,0 +1 @@
import "monaco-editor/esm/vs/language/html/html.worker.js";

View File

@@ -0,0 +1 @@
import "monaco-editor/esm/vs/language/json/json.worker.js";

View File

@@ -0,0 +1 @@
import "monaco-editor/esm/vs/language/typescript/ts.worker.js";

View File

@@ -67,6 +67,22 @@ config :esbuild,
--loader:.ttf=dataurl
),
env: %{"NODE_PATH" => Path.expand("../node_modules", __DIR__)}
],
monaco_workers: [
cd: Path.expand("../assets", __DIR__),
args: ~w(
js/monaco_workers/editor.worker.js
js/monaco_workers/css.worker.js
js/monaco_workers/html.worker.js
js/monaco_workers/json.worker.js
js/monaco_workers/ts.worker.js
--bundle
--target=es2022
--format=esm
--outdir=../priv/static/assets/monaco
--loader:.ttf=dataurl
),
env: %{"NODE_PATH" => Path.expand("../node_modules", __DIR__)}
]
config :bds, :scripting,

View File

@@ -17,7 +17,7 @@ defmodule BDS.Desktop.Endpoint do
plug(Plug.Static,
at: "/assets",
from: {:bds, "priv/static/assets"},
only: ["app.css", "app.js", "monaco.css", "monaco.js"]
only: ["app.css", "app.js", "monaco.css", "monaco.js", "monaco"]
)
plug(BDS.Desktop.Router)

View File

@@ -9,8 +9,8 @@ defmodule Mix.Tasks.Monaco.Update do
This task:
1. Runs `npm install monaco-editor@<version> --save-dev`
2. Removes the old `priv/static/assets/monaco.js` and `monaco.css`
3. Rebuilds the Monaco bundle via esbuild (both dev and minified)
2. Removes the old Monaco bundle assets
3. Rebuilds the Monaco main and worker bundles via esbuild (both dev and minified)
Use `mix monaco.version` to check the result.
@@ -27,14 +27,14 @@ defmodule Mix.Tasks.Monaco.Update do
Mix.shell().error("Missing version argument.")
Mix.shell().info("Usage: mix monaco.update <version>")
Mix.shell().info("Example: mix monaco.update 0.55.1")
System.at_exit(fn _ -> System.halt(1) end)
Mix.raise("missing Monaco version argument")
[version] ->
do_update(version)
_ ->
Mix.shell().error("Too many arguments. Usage: mix monaco.update <version>")
System.at_exit(fn _ -> System.halt(1) end)
Mix.raise("too many arguments for mix monaco.update")
end
end
@@ -49,24 +49,31 @@ defmodule Mix.Tasks.Monaco.Update do
# 2. Clean old bundle
monaco_js = Path.join(File.cwd!(), "priv/static/assets/monaco.js")
monaco_css = Path.join(File.cwd!(), "priv/static/assets/monaco.css")
monaco_workers = Path.join(File.cwd!(), "priv/static/assets/monaco")
for path <- [monaco_js, monaco_css] do
for path <- [monaco_js, monaco_css, monaco_workers] do
if File.exists?(path) do
File.rm!(path)
File.rm_rf!(path)
end
end
Mix.shell().info("Removed old monaco.js and monaco.css")
Mix.shell().info("Removed old Monaco bundle assets")
# 3. Rebuild via esbuild (dev + minified)
Mix.Task.run("esbuild", ["monaco"])
Mix.Task.run("esbuild", ["monaco", "--minify"])
run_esbuild(["monaco"])
run_esbuild(["monaco_workers"])
run_esbuild(["monaco", "--minify"])
run_esbuild(["monaco_workers", "--minify"])
# 4. Verify
new = current_version()
Mix.shell().info("monaco-editor is now at #{new}")
end
defp run_esbuild(args) do
Mix.Task.run("esbuild", args)
end
defp current_version do
lock = Path.join(File.cwd!(), "package-lock.json")

View File

@@ -6,8 +6,9 @@ defmodule Mix.Tasks.Monaco.Version do
mix monaco.version
Reads the version from package-lock.json. The Monaco bundle lives at
`priv/static/assets/monaco.js` and is built via `mix esbuild monaco`.
Reads the version from package-lock.json. The Monaco main bundle lives at
`priv/static/assets/monaco.js`; worker bundles live under
`priv/static/assets/monaco/`.
"""
use Mix.Task
@@ -30,12 +31,12 @@ defmodule Mix.Tasks.Monaco.Version do
v when is_binary(v) -> Mix.shell().info("monaco-editor: #{v}")
nil ->
Mix.shell().error("Could not find monaco-editor in package-lock.json")
System.at_exit(fn _ -> System.halt(1) end)
Mix.raise("could not find monaco-editor in package-lock.json")
end
{:error, reason} ->
Mix.shell().error("package-lock.json not found: #{reason}")
System.at_exit(fn _ -> System.halt(1) end)
Mix.raise("package-lock.json not found: #{reason}")
end
end
end

View File

@@ -66,8 +66,13 @@ defmodule BDS.MixProject do
"ecto.setup": ["ecto.create", "ecto.migrate"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
"assets.build": ["tailwind default", "esbuild default", "esbuild monaco"],
"assets.deploy": ["tailwind default --minify", "esbuild default --minify", "esbuild monaco"],
"assets.build": ["tailwind default", "esbuild default", "esbuild monaco", "esbuild monaco_workers"],
"assets.deploy": [
"tailwind default --minify",
"esbuild default --minify",
"esbuild monaco --minify",
"esbuild monaco_workers --minify"
],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
validate: ["test", "credo --strict", "deps.audit --ignore-file .mix_audit.ignore", "dialyzer"]
]

View File

@@ -8821,7 +8821,46 @@ removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}"
// js/monaco/services.js
var monacoLoaderPromise;
var monacoEditors = /* @__PURE__ */ new Map();
var monacoWorkerUrls = {
editor: "/assets/monaco/editor.worker.js",
css: "/assets/monaco/css.worker.js",
html: "/assets/monaco/html.worker.js",
json: "/assets/monaco/json.worker.js",
ts: "/assets/monaco/ts.worker.js"
};
var workerNameForLanguage = (label) => {
switch (label) {
case "css":
case "scss":
case "less":
return "css";
case "html":
case "handlebars":
case "razor":
return "html";
case "json":
return "json";
case "typescript":
case "javascript":
return "ts";
default:
return "editor";
}
};
var ensureMonacoEnvironment = () => {
if (globalThis.MonacoEnvironment?.getWorker) {
return;
}
globalThis.MonacoEnvironment = {
...globalThis.MonacoEnvironment,
getWorker(_workerId, label) {
const workerName = workerNameForLanguage(label);
return new Worker(monacoWorkerUrls[workerName], { name: label, type: "module" });
}
};
};
var loadMonaco = () => {
ensureMonacoEnvironment();
if (window.monaco?.editor) {
ensureMonacoTheme(window.monaco);
registerLiquidLanguage(window.monaco);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

93
priv/static/assets/monaco/css.worker.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

470
priv/static/assets/monaco/html.worker.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

67736
priv/static/assets/monaco/ts.worker.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
Enum.reduce_while(IO.stream(:stdio, :line), :ok, fn line, _acc ->
if String.trim(line) == "stop" do
{:halt, :ok}
else
{:cont, :ok}
end
end)

View File

@@ -0,0 +1,138 @@
import { chromium } from "playwright";
import readline from "node:readline";
const [url, screenshotDir] = process.argv.slice(2);
if (!url) {
console.log(JSON.stringify({ status: "error", message: "missing automation url" }));
process.exit(1);
}
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
try {
await page.goto(url, { waitUntil: "networkidle" });
await page.locator("#bds-shell-app").waitFor({ state: "visible" });
await page.emulateMedia({ reducedMotion: "reduce" });
console.log(JSON.stringify({ status: "ready", screenshotDir }));
} catch (error) {
console.log(JSON.stringify({ status: "error", message: error.message }));
await browser.close();
process.exit(1);
}
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
for await (const line of rl) {
if (!line.trim()) {
continue;
}
const message = JSON.parse(line);
const ref = message.ref;
try {
if (message.command === "snapshot") {
const result = await page.evaluate(() => {
const text = (selector) => document.querySelector(selector)?.textContent?.trim() ?? null;
const texts = (selector, mapper) => Array.from(document.querySelectorAll(selector)).map(mapper);
const hasClass = (selector, className) => document.querySelector(selector)?.classList.contains(className) ?? false;
return {
window_title: text("[data-testid='window-title']"),
active_view: document.querySelector("[data-testid='activity-button'][data-active='true']")?.dataset.view ?? null,
sidebar_visible: !hasClass("[data-testid='sidebar-shell']", "is-hidden"),
sidebar_width: document.querySelector("[data-testid='sidebar-shell']")?.getBoundingClientRect().width ?? 0,
assistant_visible: !hasClass("[data-testid='assistant-shell']", "is-hidden"),
assistant_width: document.querySelector("[data-testid='assistant-shell']")?.getBoundingClientRect().width ?? 0,
panel_visible: !hasClass(".panel-shell", "is-hidden"),
editor_title: text("[data-testid='editor-title']"),
activity_labels: texts("[data-testid='activity-button']", (node) => node.getAttribute("aria-label")),
sidebar_sections: texts("[data-testid='sidebar-section-title']", (node) => node.textContent.trim()),
editor_meta_labels: texts("[data-testid='editor-meta-label']", (node) => node.textContent.trim())
};
});
console.log(JSON.stringify({ ref, status: "ok", result }));
continue;
}
if (message.command === "click") {
await page.locator(message.selector).click();
await page.waitForTimeout(50);
console.log(JSON.stringify({ ref, status: "ok", result: "ok" }));
continue;
}
if (message.command === "press") {
await page.keyboard.press(message.shortcut);
await page.waitForTimeout(50);
console.log(JSON.stringify({ ref, status: "ok", result: "ok" }));
continue;
}
if (message.command === "native_menu_action") {
await page.evaluate((action) => {
return new Promise((resolve) => {
const ackId = `ack-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const handler = (event) => {
if (event.detail?.ackId === ackId) {
window.removeEventListener("bds:native-menu-action-ack", handler);
resolve();
}
};
window.addEventListener("bds:native-menu-action-ack", handler);
window.dispatchEvent(
new CustomEvent("bds:native-menu-action", { detail: { action, ackId } })
);
});
}, message.action);
console.log(JSON.stringify({ ref, status: "ok", result: "ok" }));
continue;
}
if (message.command === "drag") {
const locator = page.locator(message.selector);
const box = await locator.boundingBox();
if (!box) {
throw new Error(`unable to drag missing element: ${message.selector}`);
}
const startX = box.x + box.width / 2;
const startY = box.y + box.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX + message.deltaX, startY, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(50);
console.log(JSON.stringify({ ref, status: "ok", result: "ok" }));
continue;
}
if (message.command === "reload") {
await page.reload({ waitUntil: "networkidle" });
await page.locator("#bds-shell-app").waitFor({ state: "visible" });
await page.waitForTimeout(100);
console.log(JSON.stringify({ ref, status: "ok", result: "ok" }));
continue;
}
if (message.command === "screenshot") {
await page.screenshot({ path: message.path, fullPage: false });
console.log(JSON.stringify({ ref, status: "ok", result: message.path }));
continue;
}
if (message.command === "close") {
await browser.close();
console.log(JSON.stringify({ ref, status: "ok", result: "closed" }));
process.exit(0);
}
console.log(JSON.stringify({ ref, status: "error", message: `unknown command: ${message.command}` }));
} catch (error) {
console.log(JSON.stringify({ ref, status: "error", message: error.message }));
}
}

View File

@@ -88,6 +88,7 @@ defmodule BDS.AI.ChatStreamingTest do
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
Application.put_env(:bds, :chat_stream_test_pid, self())
Application.put_env(:bds, :chat_stream_scenario, :short)
@@ -101,6 +102,7 @@ defmodule BDS.AI.ChatStreamingTest do
)
on_exit(fn ->
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, :manual)
Application.delete_env(:bds, :chat_stream_scenario)
case original_chat do

View File

@@ -325,6 +325,8 @@ defmodule BDS.AITest do
setup do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo)
Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, {:shared, self()})
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.mode(BDS.Repo, :manual) end)
:ok
end

View File

@@ -352,6 +352,7 @@ defmodule BDS.DesktopTest do
assert conn.resp_body =~ ~s(data-phx-main)
assert conn.resp_body =~ ~s(href="/assets/app.css")
assert conn.resp_body =~ ~s(href="/assets/monaco.css")
assert conn.resp_body =~ ~s(src="/assets/app.js")
refute conn.resp_body =~ ~s(src="/assets/live.js")
refute conn.resp_body =~ ~s(src="/vendor/phoenix/phoenix.min.js")
@@ -371,6 +372,31 @@ defmodule BDS.DesktopTest do
assert byte_size(js_conn.resp_body) > 0
end
test "desktop endpoint serves Monaco ESM bundle and worker assets" do
paths = [
"/assets/monaco.css",
"/assets/monaco.js",
"/assets/monaco/editor.worker.js",
"/assets/monaco/css.worker.js",
"/assets/monaco/html.worker.js",
"/assets/monaco/json.worker.js",
"/assets/monaco/ts.worker.js"
]
for path <- paths do
asset_conn = conn(:get, "#{path}?k=#{Desktop.Auth.login_key()}")
asset_conn = BDS.Desktop.Endpoint.call(asset_conn, BDS.Desktop.Endpoint.init([]))
assert asset_conn.status == 200
assert byte_size(asset_conn.resp_body) > 0
end
end
test "desktop automation launch scripts exist at the paths used by automation" do
assert File.regular?("/Users/gb/Projects/bDS2/scripts/desktop_automation_app.exs")
assert File.regular?("/Users/gb/Projects/bDS2/scripts/desktop_automation_runner.mjs")
end
test "desktop endpoint serves the live shell without extra router-side secret injection" do
conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}")
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))

View File

@@ -358,6 +358,22 @@ defmodule BDS.UI.ShellTest do
assert File.exists?("/Users/gb/Projects/bDS2/assets/js/monaco/languages.js")
end
test "monaco ESM build config emits served worker bundles" do
mix_exs = File.read!("/Users/gb/Projects/bDS2/mix.exs")
config = File.read!("/Users/gb/Projects/bDS2/config/config.exs")
services = File.read!("/Users/gb/Projects/bDS2/assets/js/monaco/services.js")
update_task = File.read!("/Users/gb/Projects/bDS2/lib/mix/tasks/monaco.update.ex")
assert mix_exs =~ ~s("esbuild monaco --minify")
assert mix_exs =~ ~s("esbuild monaco_workers --minify")
assert config =~ "monaco_workers:"
assert config =~ "js/monaco_workers/editor.worker.js"
assert services =~ "globalThis.MonacoEnvironment"
assert services =~ "/assets/monaco/editor.worker.js"
assert update_task =~ ~S|run_esbuild(["monaco_workers", "--minify"])|
refute File.exists?("/Users/gb/Projects/bDS2/assets/js/error_reporter.js")
end
test "top level shell render uses utility classes for common layout" do
template = File.read!("/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live/index.html.heex")