diff --git a/src/lib/console.js b/src/lib/console.js index 05ca41fa6a..35a32cb2b9 100644 --- a/src/lib/console.js +++ b/src/lib/console.js @@ -7,6 +7,7 @@ import loadPolyFill from "utils/polyfill"; import ConsoleExecutor, { applyConsoleViewport, executeConsoleCommand, + executeConsoleScript, resolveConsoleExecutionContext, } from "./consoleRuntime"; @@ -25,6 +26,9 @@ import ConsoleExecutor, { const themeEndpoint = document.querySelector( 'meta[name="acode-console-theme-endpoint"]', )?.content; + const startupScriptUrl = document.querySelector( + 'meta[name="acode-console-executing-script"]', + )?.content; const originalConsole = console; const $input = tag("textarea", { id: "__c-input", @@ -184,6 +188,7 @@ import ConsoleExecutor, { if (isStandaloneConsole) { showConsole(); + void runStartupScript(); return; } @@ -205,6 +210,21 @@ import ConsoleExecutor, { themeSyncTimer = null; } + async function runStartupScript() { + if (!startupScriptUrl) return; + + setExecutionState(true); + const result = await executeConsoleScript({ + scriptUrl: startupScriptUrl, + workerExecutor: executor, + }); + setExecutionState(false); + + if (result?.type === "error") { + log("error", getStack(new Error()), result.value); + } + } + async function syncTheme() { if (themeSyncPending) return; themeSyncPending = true; diff --git a/src/lib/consoleRuntime.js b/src/lib/consoleRuntime.js index 383ddd0946..5e45e25236 100644 --- a/src/lib/consoleRuntime.js +++ b/src/lib/consoleRuntime.js @@ -184,3 +184,34 @@ export function executeConsoleCommand({ if (context === "page") return Promise.resolve(pageExecutor(code)); return workerExecutor.execute(code); } + +/** + * Loads a standalone JavaScript file and executes it in the isolated console + * worker. The file is fetched first so it is never inserted as a page script. + */ +export async function executeConsoleScript({ + scriptUrl, + workerExecutor, + fetchScript = globalThis.fetch, +}) { + if (!scriptUrl) return null; + + try { + if (typeof fetchScript !== "function") { + throw new Error("This WebView cannot load the JavaScript file."); + } + + const response = await fetchScript(scriptUrl, { cache: "no-store" }); + if (!response.ok) { + const status = response.status ? ` (${response.status})` : ""; + throw new Error(`Failed to load JavaScript file${status}.`); + } + + return workerExecutor.execute(await response.text()); + } catch (error) { + return { + type: "error", + value: error instanceof Error ? error : new Error(String(error)), + }; + } +} diff --git a/src/lib/run.js b/src/lib/run.js index d130c11bae..beb5346433 100644 --- a/src/lib/run.js +++ b/src/lib/run.js @@ -72,10 +72,13 @@ async function run( let isLoading = false; let filename, pathName, extension; let port = appSettings.value.serverPort; - let EXECUTING_SCRIPT = uuid + "_script.js"; + let shouldExecuteScript = false; const MIMETYPE_HTML = mimeType.lookup("html"); const CONSOLE_SCRIPT = uuid + "_console.js"; const CONSOLE_WORKER_SCRIPT = uuid + "_console_worker.js"; + // Keep this route independent of the filename because characters such as + // "#" and "?" change how browsers parse a URL. + const EXECUTING_SCRIPT = uuid + "_script.js"; const CONSOLE_THEME_STYLE = uuid + "_console_theme.css"; const MARKDOWN_STYLE = uuid + "_md.css"; const queue = []; @@ -143,7 +146,7 @@ async function run( } function runConsole() { - if (!isConsole) EXECUTING_SCRIPT = activeFile.filename; + if (!isConsole) shouldExecuteScript = true; isConsole = true; target = "inapp"; filename = "console.html"; @@ -253,7 +256,7 @@ async function run( CONSOLE_WORKER_SCRIPT, CONSOLE_THEME_STYLE, CONSOLE_THEME_STATE, - EXECUTING_SCRIPT, + EXECUTING_SCRIPT: shouldExecuteScript ? EXECUTING_SCRIPT : null, APP_THEME_TYPE: getConsoleThemeSnapshot().type, }), reqId, diff --git a/src/views/console.hbs b/src/views/console.hbs index 40068d74c8..69ba1eca4e 100644 --- a/src/views/console.hbs +++ b/src/views/console.hbs @@ -6,15 +6,17 @@ + {{#EXECUTING_SCRIPT}} + + {{/EXECUTING_SCRIPT}} Console - - - + + diff --git a/tests/unit/consoleExecutionContext.test.js b/tests/unit/consoleExecutionContext.test.js index ab92a29872..3d6a6519e9 100644 --- a/tests/unit/consoleExecutionContext.test.js +++ b/tests/unit/consoleExecutionContext.test.js @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { executeConsoleCommand, + executeConsoleScript, resolveConsoleExecutionContext, } from "lib/consoleRuntime"; @@ -49,4 +50,62 @@ describe("console execution context", () => { expect(calls).toEqual([["page", 'document.querySelector("main")']]); expect(result.value).toBe("live page"); }); + + it("loads Run-button scripts and executes them in the worker", async () => { + const calls = []; + const result = await executeConsoleScript({ + scriptUrl: "example.js", + fetchScript: async (url, options) => { + calls.push(["fetch", url, options]); + return { + ok: true, + text: async () => "while (true) {}", + }; + }, + workerExecutor: { + execute(code) { + calls.push(["worker", code]); + return Promise.resolve({ type: "result" }); + }, + }, + }); + + expect(calls).toEqual([ + ["fetch", "example.js", { cache: "no-store" }], + ["worker", "while (true) {}"], + ]); + expect(result).toEqual({ type: "result" }); + }); + + it("does not load a script for an empty menu console", async () => { + let fetched = false; + const result = await executeConsoleScript({ + scriptUrl: null, + fetchScript: async () => { + fetched = true; + }, + }); + + expect(fetched).toBe(false); + expect(result).toBeNull(); + }); + + it("returns a console error when a Run-button script cannot be loaded", async () => { + let executed = false; + const result = await executeConsoleScript({ + scriptUrl: "missing.js", + fetchScript: async () => ({ ok: false, status: 404 }), + workerExecutor: { + execute() { + executed = true; + }, + }, + }); + + expect(executed).toBe(false); + expect(result).toMatchObject({ + type: "error", + value: { message: "Failed to load JavaScript file (404)." }, + }); + }); });