diff --git a/.changeset/fn-7360-desktop-windows.md b/.changeset/fn-7360-desktop-windows.md new file mode 100644 index 0000000000..c040368703 --- /dev/null +++ b/.changeset/fn-7360-desktop-windows.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Windows desktop startup failures in packaged builds. +category: fix +dev: Externalizes Electron updater CJS dependencies, loads the dashboard registry manifest through Node-safe file IO, and separates NSIS/portable Windows artifacts. diff --git a/.github/workflows/desktop-windows.yml b/.github/workflows/desktop-windows.yml index 8df066b402..be50354685 100644 --- a/.github/workflows/desktop-windows.yml +++ b/.github/workflows/desktop-windows.yml @@ -58,6 +58,27 @@ jobs: if ($sig.Status -ne 'Valid') { Write-Error "Signature invalid: $($exe.Name) ($($sig.Status))"; exit 1 } } + - name: Verify Windows runtime resources + shell: pwsh + run: | + # FNXC:WindowsDesktopPackaging 2026-07-01-08:08: + # The Windows app must install Electron's root .pak runtime resources; + # missing chrome_100_percent.pak, chrome_200_percent.pak, or resources.pak + # leaves Fusion.exe unable to start even when the NSIS installer succeeds. + $requiredResources = @('chrome_100_percent.pak', 'chrome_200_percent.pak', 'resources.pak') + $unpackedRoots = Get-ChildItem packages/desktop/dist-electron -Directory -Filter 'win*-unpacked' + if ($unpackedRoots.Count -eq 0) { Write-Error "No win-unpacked directory produced"; exit 1 } + foreach ($root in $unpackedRoots) { + foreach ($resource in $requiredResources) { + $resourcePath = Join-Path $root.FullName $resource + if (!(Test-Path $resourcePath)) { Write-Error "Missing Electron runtime resource: $resourcePath"; exit 1 } + } + } + $nsis = Get-ChildItem packages/desktop/dist-electron -Filter 'Fusion-*-win-*.exe' | Where-Object { $_.Name -notmatch '-portable\.exe$' } + $portable = Get-ChildItem packages/desktop/dist-electron -Filter 'Fusion-*-win-*-portable.exe' + if ($nsis.Count -eq 0) { Write-Error "No NSIS installer artifact produced"; exit 1 } + if ($portable.Count -eq 0) { Write-Error "No portable EXE artifact produced"; exit 1 } + # Automated publish is intentionally deferred to FN-5593. # Keep a single artifact; filenames include -x64 / -arm64 so both arches are captured. - name: Upload Windows artifacts diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index e68101d8cb..bfac69fa44 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -59,7 +59,7 @@ "gen:locales": "node scripts/sync-locales.mjs", "verify:locale-chunks": "node scripts/assert-locale-chunks.mjs", "prebuild": "node scripts/sync-locales.mjs", - "build": "vite build && tsc", + "build": "vite build && tsc && node -e \"require('node:fs').copyFileSync('src/registry-manifest.json','dist/registry-manifest.json')\"", "prebuild:client": "node scripts/sync-locales.mjs", "build:client": "vite build", "predev:serve": "node scripts/sync-locales.mjs", diff --git a/packages/dashboard/src/__tests__/plugin-registry-dist.test.ts b/packages/dashboard/src/__tests__/plugin-registry-dist.test.ts new file mode 100644 index 0000000000..477b2b4007 --- /dev/null +++ b/packages/dashboard/src/__tests__/plugin-registry-dist.test.ts @@ -0,0 +1,32 @@ +// @vitest-environment node + +import { access, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const dashboardRoot = path.resolve(__dirname, "../.."); + +async function readDashboardFile(relativePath: string): Promise { + return readFile(path.join(dashboardRoot, relativePath), "utf-8"); +} + +describe("plugin registry production output", () => { + it("does not emit a Node 22-invalid static JSON import for registry-manifest.json", async () => { + const pluginRoutesDist = await readDashboardFile("dist/plugin-routes.js"); + + expect(pluginRoutesDist).not.toMatch(/import\s+\w+\s+from\s+["']\.\/registry-manifest\.json["'];?/); + expect(pluginRoutesDist).toContain('new URL("./registry-manifest.json", import.meta.url)'); + expect(pluginRoutesDist).toContain("JSON.parse"); + }); + + it("copies a readable registry manifest beside the emitted dashboard server files", async () => { + const manifestPath = path.join(dashboardRoot, "dist/registry-manifest.json"); + await expect(access(manifestPath)).resolves.toBeUndefined(); + + const manifest = JSON.parse(await readFile(manifestPath, "utf-8")) as { plugins?: unknown }; + expect(Array.isArray(manifest.plugins)).toBe(true); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-plugin-registry.test.ts b/packages/dashboard/src/__tests__/routes-plugin-registry.test.ts index f6a68afa3a..7495ac0277 100644 --- a/packages/dashboard/src/__tests__/routes-plugin-registry.test.ts +++ b/packages/dashboard/src/__tests__/routes-plugin-registry.test.ts @@ -4,8 +4,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import express from "express"; import type { PluginInstallation, PluginStore } from "@fusion/core"; -import registryManifest from "../registry-manifest.json"; -import { buildRegistryPluginEntries, createPluginRouter } from "../plugin-routes.js"; +import { buildRegistryPluginEntries, createPluginRouter, loadRegistryManifest } from "../plugin-routes.js"; import { get as performGet } from "../test-request.js"; import * as projectStoreResolver from "../project-store-resolver.js"; @@ -68,9 +67,12 @@ describe("GET /api/plugins/registry", () => { const res = await performGet(buildApp(pluginStore), "/api/plugins/registry"); expect(res.status).toBe(200); + const registryManifest = await loadRegistryManifest(); + const manifestPlugins = Array.isArray(registryManifest.plugins) ? registryManifest.plugins : []; + // Registry currently includes Agent Browser as metadata-only plus 3 discovery-only partner/plugin ideas. - expect(registryManifest.plugins.filter((plugin) => !plugin.path)).toHaveLength(4); - expect((res.body as { plugins: unknown[] }).plugins).toHaveLength(registryManifest.plugins.length); + expect(manifestPlugins.filter((plugin) => typeof plugin === "object" && plugin && !("path" in plugin))).toHaveLength(4); + expect((res.body as { plugins: unknown[] }).plugins).toHaveLength(manifestPlugins.length); }); it("filters by q across searchable text", async () => { diff --git a/packages/dashboard/src/plugin-routes.ts b/packages/dashboard/src/plugin-routes.ts index efcb79e001..eb0027650b 100644 --- a/packages/dashboard/src/plugin-routes.ts +++ b/packages/dashboard/src/plugin-routes.ts @@ -19,7 +19,6 @@ import { Router, type Request, type Response } from "express"; import { access, stat, readFile } from "node:fs/promises"; import { join, isAbsolute, dirname, basename } from "node:path"; import { emitPluginCustomSseEvent } from "./sse.js"; -import registryManifest from "./registry-manifest.json"; import type { PluginInstallation, PluginLoader, @@ -80,6 +79,31 @@ interface RegistryManifestShape { plugins?: unknown; } +const registryManifestUrl = new URL("./registry-manifest.json", import.meta.url); +let cachedRegistryManifest: RegistryManifestShape | null = null; + +export async function loadRegistryManifest(): Promise { + if (cachedRegistryManifest) { + return cachedRegistryManifest; + } + + try { + const raw = await readFile(registryManifestUrl, "utf-8"); + cachedRegistryManifest = JSON.parse(raw) as RegistryManifestShape; + return cachedRegistryManifest; + } catch (error) { + // FNXC:PluginRegistry 2026-07-01-07:45: + // Desktop local mode imports the dashboard server under Node 22+, where static + // JSON imports require attributes that TypeScript did not emit. Read the + // registry manifest as data at request time and degrade to an empty registry + // when the packaged manifest is missing or malformed so startup never fails at + // module load with ERR_IMPORT_ATTRIBUTE_MISSING. + console.warn("[dashboard/plugins] Registry manifest unavailable; serving an empty plugin registry", error); + cachedRegistryManifest = {}; + return cachedRegistryManifest; + } +} + function normalizeRegistryManifestEntries(manifest: RegistryManifestShape): RegistryManifestEntry[] { if (!manifest || !Array.isArray(manifest.plugins)) { return []; @@ -330,6 +354,7 @@ export function createPluginRouter( : undefined; const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : null; const store = scopedStore?.getPluginStore?.() ?? pluginStore; + const registryManifest = await loadRegistryManifest(); const plugins = await buildRegistryPluginEntries(registryManifest, store, { q, category }); res.json({ plugins }); })); diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 9c0f114e5a..aec579be69 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -358,7 +358,10 @@ Desktop packaging is configured in `electron-builder.yml`. - Output directory: `packages/desktop/dist-electron` - Targets: macOS (`dmg`, `zip`), Windows (`nsis`, `portable`), Linux (`AppImage`, `deb`, `tar.gz`) -- Windows artifacts: `Fusion--win-x64.exe` and `Fusion--win-arm64.exe` (both NSIS + portable variants) in `packages/desktop/dist-electron/` +- Windows NSIS installer artifacts: `Fusion--win-x64.exe` and `Fusion--win-arm64.exe` in `packages/desktop/dist-electron/` +- Windows portable artifacts: `Fusion--win-x64-portable.exe` and `Fusion--win-arm64-portable.exe` in `packages/desktop/dist-electron/` +- Silent NSIS installs support a custom destination with `/S /D=`; keep `/D=...` as the final installer argument (for example, `Fusion--win-x64.exe /S /D=C:\\Users\\me\\Tools\\fusion`). +- The Windows packaging workflow verifies `win*-unpacked` contains Electron root runtime resources (`chrome_100_percent.pak`, `chrome_200_percent.pak`, and `resources.pak`) before uploading artifacts. - Binary GitHub Release workflow (`.github/workflows/release.yml`) now attaches desktop artifacts for all supported platforms: - Electron-updater feed files are also published per platform: `latest.yml` (Windows), `latest-mac.yml` (macOS), and `latest-linux.yml` (Linux). `setupAutoUpdater` / `triggerUpdateCheck` resolve these feeds from the GitHub Release channel. - Windows: x64 + arm64 outputs (NSIS + portable), matching `.exe.sha256` sidecars, and `.blockmap` files. diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 7237884726..2a3be9b705 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -142,11 +142,20 @@ win: publisherName: Fusion nsis: + # FNXC:WindowsDesktopPackaging 2026-07-01-08:02: + # NSIS is the installable Windows artifact and must keep the 0.50-style + # Fusion--win-.exe name so updater/install docs remain stable. + # The portable target writes a separate -portable EXE below to avoid colliding + # with or overwriting the NSIS installer during x64/arm64 packaging. + artifactName: "${productName}-${version}-${os}-${arch}.${ext}" oneClick: false perMachine: false allowElevation: false allowToChangeInstallationDirectory: true +portable: + artifactName: "${productName}-${version}-${os}-${arch}-portable.${ext}" + linux: category: Development target: diff --git a/packages/desktop/scripts/build.ts b/packages/desktop/scripts/build.ts index 7c4fb4b453..8dc7ca3e5c 100644 --- a/packages/desktop/scripts/build.ts +++ b/packages/desktop/scripts/build.ts @@ -1,8 +1,11 @@ import { build } from "esbuild"; import { cp, mkdir, rm, stat } from "node:fs/promises"; import { join } from "node:path"; -import { buildDashboardClient, packageRoot, workspaceRoot } from "./workspace-tools"; -const dashboardClientDir = join(workspaceRoot, "packages", "dashboard", "dist", "client"); +import { buildDashboard, buildDashboardClient, packageRoot, workspaceRoot } from "./workspace-tools"; +const dashboardRoot = join(workspaceRoot, "packages", "dashboard"); +const dashboardClientDir = join(dashboardRoot, "dist", "client"); +const dashboardRegistryManifestSource = join(dashboardRoot, "src", "registry-manifest.json"); +const dashboardRegistryManifestDist = join(dashboardRoot, "dist", "registry-manifest.json"); const desktopDistDir = join(packageRoot, "dist"); const desktopClientDistDir = join(desktopDistDir, "client"); // FNXC:DesktopBuild 2026-06-25-09:45: @@ -23,7 +26,15 @@ const mainExternals = sharedExternals; const preloadExternals = sharedExternals; async function ensureDashboardBuild(): Promise { - console.log("[desktop:build] Building dashboard client..."); + // FNXC:DesktopBuild 2026-07-01-11:35: + // Windows release packaging invokes only `@fusion/desktop build` before electron-builder. + // Build the dashboard server dist and copy registry-manifest.json here so the packaged + // embedded runtime never depends on a separate `@fusion/dashboard build` workflow step. + console.log("[desktop:build] Building dashboard server runtime..."); + await buildDashboard(); + await cp(dashboardRegistryManifestSource, dashboardRegistryManifestDist); + + console.log("[desktop:build] Building dashboard client for file:// desktop loading..."); await buildDashboardClient(); try { @@ -31,6 +42,12 @@ async function ensureDashboardBuild(): Promise { } catch { throw new Error(`Dashboard client assets not found: ${dashboardClientDir}`); } + + try { + await stat(dashboardRegistryManifestDist); + } catch { + throw new Error(`Dashboard registry manifest not found: ${dashboardRegistryManifestDist}`); + } } async function buildElectronEntrypoints(): Promise { @@ -45,6 +62,12 @@ async function buildElectronEntrypoints(): Promise { platform: "node", target: "node22", sourcemap: true, + // FNXC:DesktopBuild 2026-07-01-07:31: + // Windows Electron main output is ESM, but electron-updater loads CJS deps + // such as fs-extra/graceful-fs that dynamically require built-ins. Keep all + // npm packages external so Node/Electron evaluates those CJS modules natively + // instead of esbuild emitting a __require("fs") trap in dist/main.js. + packages: "external", external: mainExternals, logLevel: "info", }), diff --git a/packages/desktop/scripts/workspace-tools.ts b/packages/desktop/scripts/workspace-tools.ts index 7b948524ef..573675f74f 100644 --- a/packages/desktop/scripts/workspace-tools.ts +++ b/packages/desktop/scripts/workspace-tools.ts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { dirname, resolve } from "node:path"; import { existsSync } from "node:fs"; +import { cp } from "node:fs/promises"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -64,6 +65,9 @@ export async function buildDashboard(): Promise { await buildDashboardRuntimePlugins(); await runWorkspaceBin("vite", ["build"], dashboardRoot); await runWorkspaceBin("tsc", [], dashboardRoot); + // FNXC:DesktopBuild 2026-07-01-11:45: + // Desktop release and test paths call this helper directly instead of the dashboard package script, so copy the Node-read registry manifest beside server dist here as the shared build invariant. + await cp(resolve(dashboardRoot, "src", "registry-manifest.json"), resolve(dashboardRoot, "dist", "registry-manifest.json")); } export async function buildDashboardClient(): Promise { diff --git a/packages/desktop/src/__tests__/build-bundling.test.ts b/packages/desktop/src/__tests__/build-bundling.test.ts new file mode 100644 index 0000000000..3fe2d48add --- /dev/null +++ b/packages/desktop/src/__tests__/build-bundling.test.ts @@ -0,0 +1,61 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const desktopRoot = path.resolve(__dirname, "../.."); + +async function readDesktopFile(relativePath: string): Promise { + return readFile(path.join(desktopRoot, relativePath), "utf-8"); +} + +describe("desktop Electron main bundling", () => { + it("builds dashboard server artifacts and registry manifest from the desktop release build path", async () => { + const buildScript = await readDesktopFile("scripts/build.ts"); + + expect(buildScript).toContain("buildDashboard()"); + expect(buildScript).toContain("dashboardRegistryManifestSource"); + expect(buildScript).toContain("dashboardRegistryManifestDist"); + expect(buildScript).toContain("await cp(dashboardRegistryManifestSource, dashboardRegistryManifestDist)"); + }); + + it("externalizes production main-process packages so updater CJS deps are not bundled into ESM", async () => { + const buildScript = await readDesktopFile("scripts/build.ts"); + const mainBuildBlock = buildScript.match(/entryPoints: \[join\(packageRoot, "src", "main\.ts"\)\],[\s\S]*?logLevel: "info",/m)?.[0]; + + expect(mainBuildBlock).toBeDefined(); + expect(mainBuildBlock).toContain('format: "esm"'); + expect(mainBuildBlock).toContain('platform: "node"'); + expect(mainBuildBlock).toContain('packages: "external"'); + expect(mainBuildBlock).toContain("external: mainExternals"); + + const preloadBuildBlock = buildScript.match(/entryPoints: \[join\(packageRoot, "src", "preload\.ts"\)\],[\s\S]*?logLevel: "info",/m)?.[0]; + expect(preloadBuildBlock).toBeDefined(); + expect(preloadBuildBlock).toContain('format: "cjs"'); + expect(preloadBuildBlock).toContain('packages: "external"'); + }); + + it("keeps development main-process bundling aligned with the production package-external invariant", async () => { + const devScript = await readDesktopFile("scripts/dev.ts"); + const mainBuildBlock = devScript.match(/entryPoints: \[join\(packageRoot, "src", "main\.ts"\)\],[\s\S]*?logLevel: "info",/m)?.[0]; + + expect(mainBuildBlock).toBeDefined(); + expect(mainBuildBlock).toContain('format: "esm"'); + expect(mainBuildBlock).toContain('packages: "external"'); + expect(mainBuildBlock).toContain('external: ["electron"]'); + }); + + it("keeps known updater CommonJS dependencies available as packaged runtime files", async () => { + const builderConfig = await readDesktopFile("electron-builder.yml"); + + for (const runtimeDependency of [ + "node_modules/electron-updater/**/*", + "node_modules/fs-extra/**/*", + "node_modules/graceful-fs/**/*", + ]) { + expect(builderConfig).toContain(`- ${runtimeDependency}`); + } + }); +}); diff --git a/packages/desktop/src/__tests__/electron-builder-config.test.ts b/packages/desktop/src/__tests__/electron-builder-config.test.ts index 943d550a93..ed3e77246a 100644 --- a/packages/desktop/src/__tests__/electron-builder-config.test.ts +++ b/packages/desktop/src/__tests__/electron-builder-config.test.ts @@ -35,8 +35,10 @@ describe("electron-builder desktop config", () => { expect(extractArchValues(nsisArchMatch![1])).toEqual(["arm64", "x64"]); expect(extractArchValues(portableArchMatch![1])).toEqual(["arm64", "x64"]); + expect(builderConfig).toMatch(/nsis:\s*[\s\S]*?artifactName:\s*"\$\{productName\}-\$\{version\}-\$\{os\}-\$\{arch\}\.\$\{ext\}"/m); expect(builderConfig).toMatch(/nsis:\s*[\s\S]*?oneClick:\s*false/m); expect(builderConfig).toMatch(/nsis:\s*[\s\S]*?allowToChangeInstallationDirectory:\s*true/m); + expect(builderConfig).toMatch(/portable:\s*[\s\S]*?artifactName:\s*"\$\{productName\}-\$\{version\}-\$\{os\}-\$\{arch\}-portable\.\$\{ext\}"/m); expect(builderConfig).toMatch(/artifactName:\s*"\$\{productName\}-\$\{version\}-\$\{os\}-\$\{arch\}\.\$\{ext\}"/m); expect(builderConfig).toMatch(/appId:\s*com\.gsxdsm\.fusion\.desktop/m); @@ -107,6 +109,14 @@ describe("electron-builder desktop config", () => { expect(linuxArchByTarget.get("tar.gz")).toEqual(["arm64", "x64"]); }); + it("does not exclude Electron runtime pak resources from Windows unpacked output", async () => { + const builderConfig = await readDesktopFile("electron-builder.yml"); + + for (const requiredPak of ["chrome_100_percent.pak", "chrome_200_percent.pak", "resources.pak"]) { + expect(builderConfig).not.toMatch(new RegExp(`!.*${requiredPak.replaceAll(".", "\\.")}`)); + } + }); + it("packages @fusion/core runtime dependencies used during desktop startup", async () => { const builderConfig = await readDesktopFile("electron-builder.yml"); const requiredRuntimeDependencyGlobs = [ @@ -175,6 +185,11 @@ describe("desktop windows workflow signing guards", () => { expect(workflow).toContain("CSC_KEY_PASSWORD:"); expect(workflow).toContain("WINDOWS_CERTIFICATE_BASE64 != ''"); expect(workflow).toContain("Get-AuthenticodeSignature"); + expect(workflow).toContain("Verify Windows runtime resources"); + expect(workflow).toContain("chrome_100_percent.pak"); + expect(workflow).toContain("chrome_200_percent.pak"); + expect(workflow).toContain("resources.pak"); + expect(workflow).toContain("Fusion-*-win-*-portable.exe"); expect(workflow).toContain("intentionally deferred"); }); }); diff --git a/packages/desktop/src/__tests__/ipc.test.ts b/packages/desktop/src/__tests__/ipc.test.ts index de2789eb4e..9adc06ee9c 100644 --- a/packages/desktop/src/__tests__/ipc.test.ts +++ b/packages/desktop/src/__tests__/ipc.test.ts @@ -169,6 +169,21 @@ describe("ipc handlers", () => { expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object)); }); + it("shell:setDesktopMode propagates local runtime startup errors", async () => { + const onDesktopModeChange = vi.fn(async () => { + throw new Error("dashboard import failed: ERR_IMPORT_ATTRIBUTE_MISSING"); + }); + const { window } = await registerHandlers({ onDesktopModeChange, getRuntimeStatus: () => ({ source: "embedded-local", state: "error", error: "dashboard import failed: ERR_IMPORT_ATTRIBUTE_MISSING" }) }); + const handler = mocks.ipcHandlers.get("shell:setDesktopMode"); + + await expect(handler?.({}, "local")).rejects.toThrow("ERR_IMPORT_ATTRIBUTE_MISSING"); + + expect(mocks.writeShellSettings).toHaveBeenCalledWith( + expect.objectContaining({ desktopMode: "local", hasCompletedModeSelection: true }), + ); + expect(window.webContents.send).not.toHaveBeenCalledWith("shell:state", expect.any(Object)); + }); + it("desktop launch mode handlers return mode/context and validate payload", async () => { const getDesktopLaunchContext = vi.fn(() => ({ mode: "remote", profileId: "profile_1", serverBaseUrl: "https://remote.example.com" })); const onDesktopLaunchModeChange = vi.fn(async () => undefined); diff --git a/packages/desktop/src/__tests__/local-runtime.test.ts b/packages/desktop/src/__tests__/local-runtime.test.ts index b931ae819d..f5ab008748 100644 --- a/packages/desktop/src/__tests__/local-runtime.test.ts +++ b/packages/desktop/src/__tests__/local-runtime.test.ts @@ -114,6 +114,26 @@ describe("LocalRuntimeManager", () => { }); }); + it("surfaces dashboard import failures instead of replacing them with a generic timeout", async () => { + const { LocalRuntimeManager } = await import("../local-runtime.ts"); + const importError = new TypeError("ERR_IMPORT_ATTRIBUTE_MISSING: registry-manifest.json requires an import attribute"); + const manager = new LocalRuntimeManager({ + rootDir: "/repo", + createStore: async () => store, + createDashboardServer: async () => { + throw importError; + }, + }); + + await expect(manager.startLocal()).rejects.toThrow("ERR_IMPORT_ATTRIBUTE_MISSING"); + expect(store.close).toHaveBeenCalledTimes(1); + expect(manager.getStatus()).toMatchObject({ + source: "embedded-local", + state: "error", + error: "ERR_IMPORT_ATTRIBUTE_MISSING: registry-manifest.json requires an import attribute", + }); + }); + it("stopLocal is idempotent and no-op when inactive", async () => { const { LocalRuntimeManager } = await import("../local-runtime.ts"); const server = new FakeServer(4545);