mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 09:34:34 +00:00
fix(tui): reload local plugin dependency graphs (#47388)
Reload statically discovered local plugin dependencies through native module caches while preserving shared package identities and best-effort last-good registrations.
This commit is contained in:
parent
8d1a9799f4
commit
c72b535dee
8 changed files with 912 additions and 37 deletions
|
|
@ -54,6 +54,11 @@
|
|||
"./component/register-spinner": "./src/component/register-spinner.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#plugin-source": {
|
||||
"bun": "./src/plugin/source.bun.ts",
|
||||
"node": "./src/plugin/source.node.ts",
|
||||
"default": "./src/plugin/source.node.ts"
|
||||
},
|
||||
"#attention-sounds": {
|
||||
"bun": "./src/attention-sounds.bun.ts",
|
||||
"node": "./src/attention-sounds.node.ts",
|
||||
|
|
|
|||
|
|
@ -14,10 +14,9 @@ import {
|
|||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import path from "path"
|
||||
import { readFile, stat } from "fs/promises"
|
||||
import { stat } from "fs/promises"
|
||||
import { fileURLToPath } from "url"
|
||||
import type { Page } from "@opencode-ai/plugin/tui/context"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { resolveSlots, type Claim } from "./structure"
|
||||
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
|
||||
|
|
@ -31,7 +30,8 @@ import { errorMessage } from "../util/error"
|
|||
import { builtins } from "./builtins"
|
||||
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
|
||||
import { createSourceWatcher } from "./watch"
|
||||
import { discoverPluginTargets, freshSpecifier, localSource } from "./discovery"
|
||||
import { discoverPluginTargets, localSource } from "./discovery"
|
||||
import { createPluginSources } from "./source"
|
||||
import { isMissingPath } from "../util/config-directories"
|
||||
import { createMarkdownRenderer } from "./markdown"
|
||||
|
||||
|
|
@ -83,7 +83,6 @@ type Registration = {
|
|||
type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
|
||||
|
||||
const PluginContext = createContext<Value>()
|
||||
let sourceVersion = Date.now()
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageSource; directories: string[] }>) {
|
||||
const host = usePluginHost()
|
||||
|
|
@ -109,15 +108,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
|||
// One save can emit several watch events. Remember setup failures so those
|
||||
// events do not repeatedly tear down and restore the last good generation.
|
||||
const setupFailures = new Map<string, { version: string; options: Registration["options"]; error: string }>()
|
||||
const sourceVersions = new Map<string, { digest: string; generation: number }>()
|
||||
const sourceGeneration = async (entrypoint: string) => {
|
||||
const digest = Hash.sha256(await readFile(new URL(entrypoint)))
|
||||
const previous = sourceVersions.get(entrypoint)
|
||||
if (previous?.digest === digest) return previous.generation
|
||||
const generation = ++sourceVersion
|
||||
sourceVersions.set(entrypoint, { digest, generation })
|
||||
return generation
|
||||
}
|
||||
const markdown = createMarkdownRenderer(() =>
|
||||
Object.values(store.registrations).flatMap((registration) => (registration.active ? [registration.markdown] : [])),
|
||||
)
|
||||
|
|
@ -241,6 +231,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
|||
clearTimeout(pending)
|
||||
watcher.dispose()
|
||||
}
|
||||
const sources = createPluginSources(watcher.wait)
|
||||
onCleanup(stopWatching)
|
||||
|
||||
// Rebuild the plugin generation as resolve → compare → swap, mirroring the
|
||||
|
|
@ -304,7 +295,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
|||
const memo = local ? undefined : npmFailures.get(target)
|
||||
const resolved = memo
|
||||
? { status: "failed" as const, error: memo }
|
||||
: await resolvePlugin(target, local, options, previous, props.packages, source.install, sourceGeneration).catch(
|
||||
: await resolvePlugin(target, local, options, previous, props.packages, source.install, sources.read).catch(
|
||||
(error) => ({
|
||||
status: "failed" as const,
|
||||
error: errorMessage(error),
|
||||
|
|
@ -533,6 +524,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
|||
),
|
||||
)
|
||||
.then(() => setStore("registrations", reconcileStore({})))
|
||||
.finally(sources.dispose)
|
||||
return disposing
|
||||
}
|
||||
const unregister = lifecycle.add(dispose)
|
||||
|
|
@ -605,7 +597,7 @@ async function resolvePlugin(
|
|||
previous: Registration | undefined,
|
||||
packages: PackageSource,
|
||||
install: boolean,
|
||||
sourceGeneration: (entrypoint: string) => Promise<number>,
|
||||
readSource: ReturnType<typeof createPluginSources>["read"],
|
||||
) {
|
||||
// Package entrypoints never change within a session, so a loaded previous
|
||||
// version needs no re-resolution (which could otherwise hit npm).
|
||||
|
|
@ -616,18 +608,18 @@ async function resolvePlugin(
|
|||
if (!entrypoint) return { status: "unsupported" as const }
|
||||
// Content remains stable across the several mtimes one save may expose to
|
||||
// filesystem watchers, while the generation keeps reverted modules fresh.
|
||||
let generation = local ? await sourceGeneration(entrypoint) : undefined
|
||||
let source = local ? await readSource(entrypoint) : { version: entrypoint, module: await Host.load(entrypoint) }
|
||||
while (true) {
|
||||
const version = generation === undefined ? entrypoint : freshSpecifier(entrypoint, generation)
|
||||
const version = source.version
|
||||
if (previous && previous.version === version && sameOptions(previous.options, options))
|
||||
return { status: "unchanged" as const, plugin: previous.plugin, version }
|
||||
const mod = await Host.load(version)
|
||||
if (generation !== undefined) {
|
||||
const observed = await sourceGeneration(entrypoint)
|
||||
const mod = source.module
|
||||
if (local) {
|
||||
const observed = await readSource(entrypoint)
|
||||
// In-place saves can change the file between hashing and import. Retry
|
||||
// so setup always runs under the generation of the imported bytes.
|
||||
if (generation !== observed) {
|
||||
generation = observed
|
||||
if (version !== observed.version) {
|
||||
source = observed
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { readdir, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { isMissingPath, localProjectDirectory, projectConfigDirectories } from "../util/config-directories"
|
||||
|
||||
export async function localPluginDirectories(cwd: string, configDirectory: string) {
|
||||
|
|
@ -56,14 +56,3 @@ export function localSource(spec: string, directory: string) {
|
|||
return pathToFileURL(path.resolve(directory, spec))
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Key local plugin imports by a numeric source version so edited sources
|
||||
// re-import fresh instead of hitting the ESM cache. Bun ignores query params
|
||||
// when caching file:// URL imports, so bust with a plain path there; Node keys
|
||||
// its cache on the full URL. Fractional versions break Bun's runtime JSX/solid
|
||||
// plugin hooks, so always truncate them.
|
||||
export function freshSpecifier(entrypoint: string, sourceVersion: number) {
|
||||
const version = Math.trunc(sourceVersion)
|
||||
if (typeof Bun !== "undefined") return `${fileURLToPath(entrypoint).replaceAll("\\", "/")}?mtime=${version}`
|
||||
return `${entrypoint}?mtime=${version}`
|
||||
}
|
||||
|
|
|
|||
59
packages/tui/src/plugin/source.bun.ts
Normal file
59
packages/tui/src/plugin/source.bun.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { createRequire } from "node:module"
|
||||
import { readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { localSource } from "./discovery"
|
||||
|
||||
let generation = Date.now()
|
||||
|
||||
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
|
||||
const files = new Set<string>()
|
||||
const visit = (file: string, search = "") => {
|
||||
if (file.split(path.sep).includes("node_modules")) return
|
||||
if (search) delete require.cache[file + search]
|
||||
if (files.has(file)) return
|
||||
files.add(file)
|
||||
// Bun exposes ESM here too. Delete known keys even when absent: rejected
|
||||
// evaluations are not enumerable, but deletion still invalidates them.
|
||||
delete require.cache[file]
|
||||
track(file)
|
||||
if (!/\.[cm]?[jt]sx?$/.test(file)) return
|
||||
// Scan dependencies only; the normal runtime loader still owns compilation,
|
||||
// package resolution, import attributes, and error reporting.
|
||||
const imports = (() => {
|
||||
try {
|
||||
return new Bun.Transpiler({
|
||||
loader: file.endsWith("tsx") ? "tsx" : file.endsWith("jsx") ? "jsx" : /\.[cm]?ts$/.test(file) ? "ts" : "js",
|
||||
target: "bun",
|
||||
}).scan(readFileSync(file, "utf8")).imports
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})()
|
||||
for (const item of imports) {
|
||||
const local =
|
||||
item.path.startsWith("./") || item.path.startsWith("../")
|
||||
? new URL(item.path, pathToFileURL(file))
|
||||
: localSource(item.path, path.dirname(file))
|
||||
if (!local) continue
|
||||
const requested = fileURLToPath(local)
|
||||
// Resolving a workspace symlink can erase its node_modules boundary.
|
||||
if (requested.split(path.sep).includes("node_modules")) continue
|
||||
try {
|
||||
visit(
|
||||
item.kind === "require-call"
|
||||
? createRequire(file).resolve(requested)
|
||||
: Bun.resolveSync(requested, path.dirname(file)),
|
||||
local.search,
|
||||
)
|
||||
} catch {
|
||||
// A missing local dependency may appear on the next save. Leave its
|
||||
// actual failure (or optional fallback) to the native loader.
|
||||
track(path.dirname(requested), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(fileURLToPath(entrypoint))
|
||||
return { version: String(++generation), load: () => Host.load(entrypoint) }
|
||||
}
|
||||
43
packages/tui/src/plugin/source.node.ts
Normal file
43
packages/tui/src/plugin/source.node.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { registerHooks } from "node:module"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { localSource } from "./discovery"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
|
||||
let generation = Date.now()
|
||||
|
||||
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
|
||||
const version = String(++generation)
|
||||
const fresh = (specifier: string) => {
|
||||
const url = new URL(specifier)
|
||||
url.searchParams.set("__opencode_reload", version)
|
||||
return url.href
|
||||
}
|
||||
const hook = registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (!context.parentURL || new URL(context.parentURL).searchParams.get("__opencode_reload") !== version)
|
||||
return nextResolve(specifier, context)
|
||||
const local =
|
||||
specifier.startsWith("./") || specifier.startsWith("../")
|
||||
? new URL(specifier, context.parentURL)
|
||||
: localSource(specifier, path.dirname(fileURLToPath(context.parentURL)))
|
||||
if (!local) return nextResolve(specifier, context)
|
||||
if (fileURLToPath(local).split(path.sep).includes("node_modules")) return nextResolve(specifier, context)
|
||||
const resolved = (() => {
|
||||
try {
|
||||
return nextResolve(specifier, context)
|
||||
} catch (error) {
|
||||
track(path.dirname(fileURLToPath(local)), true)
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
if (!resolved.url.startsWith("file:")) return resolved
|
||||
const file = fileURLToPath(resolved.url)
|
||||
if (file.split(path.sep).includes("node_modules")) return resolved
|
||||
track(file)
|
||||
return { ...resolved, url: fresh(resolved.url) }
|
||||
},
|
||||
})
|
||||
const specifier = fresh(entrypoint)
|
||||
return { version: specifier, load: () => Host.load(specifier), dispose: () => hook.deregister() }
|
||||
}
|
||||
56
packages/tui/src/plugin/source.ts
Normal file
56
packages/tui/src/plugin/source.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { readFileSync, readdirSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
|
||||
// Keep source fingerprints and import attempts together. Filesystem events
|
||||
// should reload changed local graphs, not repeat unchanged evaluations.
|
||||
export function createPluginSources(watch: (file: string) => Promise<void>) {
|
||||
const sources = new Map<string, Source>()
|
||||
const cleanups: Array<() => void> = []
|
||||
const watching = new Set<Promise<void>>()
|
||||
return {
|
||||
read: async (entrypoint: string) => {
|
||||
await Promise.all(watching)
|
||||
const previous = sources.get(entrypoint)
|
||||
if (previous && [...previous.files].every(([file, item]) => item.digest === digest(file, item.directory)))
|
||||
return previous.loaded
|
||||
|
||||
const files: Source["files"] = new Map()
|
||||
const track = (file: string, directory = false) => {
|
||||
if (files.has(file)) return
|
||||
files.set(file, { digest: digest(file, directory), directory })
|
||||
const pending = watch(file).finally(() => watching.delete(pending))
|
||||
watching.add(pending)
|
||||
}
|
||||
track(fileURLToPath(entrypoint))
|
||||
const { prepareSource } = await import("#plugin-source")
|
||||
const prepared: { version: string; load: () => Promise<unknown>; dispose?: () => void } = await prepareSource(
|
||||
entrypoint,
|
||||
track,
|
||||
)
|
||||
if (prepared.dispose) cleanups.push(prepared.dispose)
|
||||
// Cache the attempt before evaluating it: unchanged failing modules must
|
||||
// not repeat import-time effects on every filesystem notification.
|
||||
const loaded = prepared.load().then((module) => ({ version: prepared.version, module }))
|
||||
sources.set(entrypoint, { loaded, files })
|
||||
return loaded.finally(() => Promise.all(watching))
|
||||
},
|
||||
dispose: () => {
|
||||
for (const cleanup of cleanups.splice(0)) cleanup()
|
||||
sources.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Source = {
|
||||
loaded: Promise<{ version: string; module: unknown }>
|
||||
files: Map<string, { digest: string; directory: boolean }>
|
||||
}
|
||||
|
||||
function digest(file: string, directory: boolean) {
|
||||
try {
|
||||
return Hash.sha256(directory ? JSON.stringify(readdirSync(file).sort()) : readFileSync(file))
|
||||
} catch {
|
||||
return "missing"
|
||||
}
|
||||
}
|
||||
|
|
@ -7,9 +7,9 @@ import { lstat, realpath, stat } from "fs/promises"
|
|||
// kills a direct file watch) and filtered by basename so bursts in busy
|
||||
// directories stay quiet. Symlinked files are additionally watched at their
|
||||
// resolved target, since edits there emit nothing at the link's location.
|
||||
// Directory targets are watched at their root only: edits to nested helper
|
||||
// files do not change the entrypoint mtime and are not detected. Watches are
|
||||
// never torn down individually (a stale watch costs one fs handle and a
|
||||
// Directory targets are watched at their root only; the plugin source loader
|
||||
// adds each resolved local dependency separately, including nested helpers.
|
||||
// Watches are never torn down individually (a stale watch costs one fs handle and a
|
||||
// spurious onChange); all die with dispose(). Missing retryable targets are
|
||||
// polled until they can be armed without relying on a racy chain of ancestor
|
||||
// watches.
|
||||
|
|
|
|||
731
packages/tui/test/plugin-source.test.ts
Normal file
731
packages/tui/test/plugin-source.test.ts
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { mkdir, rename, symlink } from "node:fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import "../src/plugin/runtime-plugin-support.bun"
|
||||
import { createPluginSources } from "../src/plugin/source"
|
||||
import { createSourceWatcher } from "../src/plugin/watch"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test("a fresh local plugin generation observes edited helper exports", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'export { value as default } from "./helper.ts"')
|
||||
await Bun.write(helper, 'export const value = "before"')
|
||||
const before = await sources.read(entry.href)
|
||||
expect(before.module).toMatchObject({ default: "before" })
|
||||
await Bun.write(helper, 'export const value = "after"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
expect(before.module).toMatchObject({ default: "before" })
|
||||
})
|
||||
|
||||
test("tracks transitive imports through the Solid runtime transform", async () => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.tsx", sources.url)
|
||||
const helper = new URL("nested/label.ts", sources.url)
|
||||
await Bun.write(entry, 'export { value as default } from "./panel"')
|
||||
await Bun.write(
|
||||
new URL("panel.tsx", sources.url),
|
||||
'import { label } from "./nested/label"; export const Panel = () => <text>{label}</text>; export const value = label',
|
||||
)
|
||||
await Bun.write(helper, 'export const label = "before"')
|
||||
const before = await sources.read(entry.href)
|
||||
expect(before.module).toMatchObject({ default: "before" })
|
||||
expect(watched).toContain(fileURLToPath(helper))
|
||||
await Bun.write(helper, 'export const label = "after"')
|
||||
const after = await sources.read(entry.href)
|
||||
expect(after.version).not.toBe(before.version)
|
||||
expect(after.module).toMatchObject({ default: "after" })
|
||||
})
|
||||
|
||||
test("unchanged bytes are a no-op, reverted bytes get a fresh module", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, "export default { value: 1 }")
|
||||
const first = await sources.read(entry.href)
|
||||
await Bun.write(entry, "export default { value: 1 }")
|
||||
expect(await sources.read(entry.href)).toBe(first)
|
||||
await Bun.write(entry, "export default { value: 2 }")
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: { value: 2 } })
|
||||
await Bun.write(entry, "export default { value: 1 }")
|
||||
const reverted = await sources.read(entry.href)
|
||||
expect(reverted.version).not.toBe(first.version)
|
||||
expect(reverted.module).not.toBe(first.module)
|
||||
expect(reverted.module).toMatchObject({ default: { value: 1 } })
|
||||
})
|
||||
|
||||
test("renamed exports, failed loads, and new dependencies recover without cached helpers", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'import { value } from "./helper"; export default value')
|
||||
await Bun.write(helper, "export const value = 1")
|
||||
const before = await sources.read(entry.href)
|
||||
expect(before.module).toMatchObject({ default: 1 })
|
||||
await Bun.write(helper, "export const renamed = 2")
|
||||
await expect(sources.read(entry.href)).rejects.toThrow()
|
||||
expect(before.module).toMatchObject({ default: 1 })
|
||||
await Bun.write(entry, 'import { renamed } from "./helper"; export default renamed')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 2 })
|
||||
await Bun.write(helper, 'export { value as renamed } from "./new/leaf"')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("leaf")
|
||||
await Bun.write(new URL("new/leaf.ts", sources.url), "export const value = 3")
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 3 })
|
||||
})
|
||||
|
||||
test("shared runtime and ordinary package identities survive plugin generations", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("node_modules/example/package.json", sources.url), '{"type":"module","main":"index.js"}')
|
||||
const library = new URL("node_modules/example/index.js", sources.url)
|
||||
await Bun.write(library, 'export default { value: "package" }')
|
||||
const pkg = await Host.load(library.href)
|
||||
if (typeof pkg !== "object" || pkg === null || !("default" in pkg)) throw new Error("Missing package fixture export")
|
||||
for (const label of ["before", "after"]) {
|
||||
await Bun.write(
|
||||
entry,
|
||||
`import { createSignal } from "solid-js"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import value from "example"
|
||||
export { createSignal, Plugin, value }; export const label = ${JSON.stringify(label)}`,
|
||||
)
|
||||
const loaded = (await sources.read(entry.href)).module
|
||||
if (typeof loaded !== "object" || loaded === null) throw new Error("Missing plugin fixture exports")
|
||||
expect("createSignal" in loaded && loaded.createSignal).toBe(createSignal)
|
||||
expect("Plugin" in loaded && loaded.Plugin).toBe(Plugin)
|
||||
expect("value" in loaded && loaded.value).toBe(pkg.default)
|
||||
expect(loaded).toMatchObject({ label })
|
||||
}
|
||||
})
|
||||
|
||||
test("helper import.meta stays anchored to its source, including assets and resolution", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("nested/helper.ts", sources.url)
|
||||
await Bun.write(entry, 'export { default } from "./nested/helper"')
|
||||
await Bun.write(new URL("nested/asset.txt", sources.url), "asset")
|
||||
await Bun.write(
|
||||
helper,
|
||||
`export default {
|
||||
url: import.meta.url, dir: import.meta.dirname, file: import.meta.file,
|
||||
resolved: import.meta.resolve("./asset.txt"),
|
||||
resolvedSync: import.meta.resolveSync("./asset.txt"),
|
||||
asset: await Bun.file(new URL("./asset.txt", import.meta.url)).text(),
|
||||
}`,
|
||||
)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({
|
||||
default: {
|
||||
url: helper.href,
|
||||
dir: path.dirname(fileURLToPath(helper)),
|
||||
file: "helper.ts",
|
||||
asset: "asset",
|
||||
resolved: new URL("nested/asset.txt", sources.url).href,
|
||||
resolvedSync: fileURLToPath(new URL("nested/asset.txt", sources.url)),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("literal dynamic imports and JSON join the source graph", async () => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const json = new URL("data.json", sources.url)
|
||||
await Bun.write(entry, 'export default (await import("./helper")).default')
|
||||
await Bun.write(new URL("helper.ts", sources.url), 'import data from "./data.json"; export default data.value')
|
||||
await Bun.write(json, '{"value":1}')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 1 })
|
||||
expect(watched).toContain(fileURLToPath(json))
|
||||
await Bun.write(json, '{"value":2}')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 2 })
|
||||
})
|
||||
|
||||
test("real watchers observe atomic saves to nested, outside-root, and symlinked helpers", async () => {
|
||||
let changes = 0
|
||||
const watcher = createSourceWatcher(() => {
|
||||
changes++
|
||||
})
|
||||
using _watcher = { [Symbol.dispose]: watcher.dispose }
|
||||
await using sources = await fixture(watcher.wait)
|
||||
const entry = new URL("plugin/tui.ts", sources.url)
|
||||
const helper = new URL("shared/nested/helper.ts", sources.url)
|
||||
await Bun.write(helper, "export const value = 1")
|
||||
await Bun.write(entry, 'export { value as default } from "./link"')
|
||||
await symlink(fileURLToPath(helper), fileURLToPath(new URL("plugin/link.ts", sources.url)))
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 1 })
|
||||
const count = changes
|
||||
await Bun.write(new URL(helper.href + ".new"), "export const value = 2")
|
||||
await rename(new URL(helper.href + ".new"), helper)
|
||||
const deadline = Date.now() + 3000
|
||||
while (Date.now() < deadline) {
|
||||
if (changes > count) break
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
expect(changes).toBeGreaterThan(count)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: 2 })
|
||||
})
|
||||
|
||||
test.each(["", "?mode=plugin", "?mode=plugin#section"])(
|
||||
"Node reloads the local ESM graph without relocating source files: %s",
|
||||
async (suffix) => {
|
||||
await using dir = await tmpdir()
|
||||
const script = path.join(dir.path, "probe.ts")
|
||||
await Bun.write(
|
||||
script,
|
||||
`
|
||||
import { createPluginSources } from ${JSON.stringify(fileURLToPath(new URL("../src/plugin/source.ts", import.meta.url)))}
|
||||
import { mkdir, symlink, writeFile } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import assert from "node:assert/strict"
|
||||
const entry = new URL("./entry.mjs", import.meta.url)
|
||||
const helper = new URL("./helper.mjs", import.meta.url)
|
||||
const suffix = ${JSON.stringify(suffix)}
|
||||
const watched = []
|
||||
const sources = createPluginSources(async file => { watched.push(file) })
|
||||
try {
|
||||
const library = new URL("./shared/index.mjs", import.meta.url)
|
||||
await mkdir(new URL("./shared", import.meta.url), { recursive: true })
|
||||
await writeFile(library, 'export default { shared: true }')
|
||||
await mkdir(new URL("./node_modules", import.meta.url), { recursive: true })
|
||||
await symlink(fileURLToPath(new URL("./shared", import.meta.url)), fileURLToPath(new URL("./node_modules/example", import.meta.url)), process.platform === "win32" ? "junction" : "dir")
|
||||
const external = await import(library.href)
|
||||
await writeFile(entry, 'import state from "./node_modules/example/index.mjs"; export { state }; export { value as default, source } from "./helper.mjs' + suffix + '"')
|
||||
await writeFile(helper, 'export const value = 1; export const source = import.meta.url')
|
||||
const initial = await sources.read(entry.href)
|
||||
assert.equal(initial.module.default, 1)
|
||||
assert.equal(initial.module.state, external.default)
|
||||
assert.equal(new URL(initial.module.source).pathname, helper.pathname)
|
||||
assert.equal(new URL(initial.module.source).searchParams.get("mode"), suffix ? "plugin" : null)
|
||||
assert.equal(new URL(initial.module.source).hash, suffix.includes("#") ? "#section" : "")
|
||||
assert.equal(await sources.read(entry.href), initial)
|
||||
await writeFile(helper, 'export const value = 2; export const source = import.meta.url')
|
||||
const updated = (await sources.read(entry.href)).module
|
||||
assert.equal(updated.default, 2)
|
||||
assert.equal(updated.state, external.default)
|
||||
assert.equal(watched.includes(fileURLToPath(library)), false)
|
||||
assert.equal(new URL(updated.source).pathname, helper.pathname)
|
||||
assert.equal(new URL(updated.source).searchParams.get("mode"), suffix ? "plugin" : null)
|
||||
assert.equal(new URL(updated.source).hash, suffix.includes("#") ? "#section" : "")
|
||||
console.log("node graph reload passed")
|
||||
} finally { sources.dispose() }
|
||||
`,
|
||||
)
|
||||
const build = await Bun.build({
|
||||
entrypoints: [script],
|
||||
target: "node",
|
||||
format: "esm",
|
||||
outdir: dir.path,
|
||||
naming: "probe.mjs",
|
||||
})
|
||||
expect(build.success).toBe(true)
|
||||
const child = Bun.spawn(["node", "--no-warnings", path.join(dir.path, "probe.mjs")], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, exit] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
expect({ stdout, stderr, exit }).toEqual({ stdout: "node graph reload passed\n", stderr: "", exit: 0 })
|
||||
},
|
||||
)
|
||||
|
||||
test("computed imports retain the importing helper's resolution base", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, 'import { read } from "./nested/reader"; export default await read("./leaf.mjs")')
|
||||
await Bun.write(
|
||||
new URL("nested/reader.ts", sources.url),
|
||||
"export const read = async (name: string) => (await import(name)).default",
|
||||
)
|
||||
await Bun.write(new URL("nested/leaf.mjs", sources.url), 'export default "computed"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "computed" })
|
||||
})
|
||||
|
||||
test("empty source modules remain valid dependencies", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, 'import "./empty"; export default "ready"')
|
||||
await Bun.write(new URL("empty.ts", sources.url), "")
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "ready" })
|
||||
})
|
||||
|
||||
test("folded imports are watched and dead imports need not be installed", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'if (false) require("not-installed"); export default (await import("./" + "helper")).default')
|
||||
await Bun.write(helper, 'export default "before"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "before" })
|
||||
await Bun.write(helper, 'export default "after"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
})
|
||||
|
||||
test("cycles retain one canonical entrypoint per generation and old bindings stay pinned", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(
|
||||
entry,
|
||||
'import { read } from "./helper"; export const value = {}; export default () => read() === value',
|
||||
)
|
||||
await Bun.write(new URL("helper.ts", sources.url), 'import { value } from "./tui"; export const read = () => value')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing cycle fixture")
|
||||
expect(before.default()).toBe(true)
|
||||
await Bun.write(
|
||||
entry,
|
||||
'import { read } from "./helper"; export const value = { changed: true }; export default () => read() === value',
|
||||
)
|
||||
const after = (await sources.read(entry.href)).module
|
||||
if (typeof after !== "object" || after === null || !("default" in after) || typeof after.default !== "function")
|
||||
throw new Error("Missing cycle fixture")
|
||||
expect(after.default()).toBe(true)
|
||||
expect(before.default()).toBe(true)
|
||||
})
|
||||
|
||||
test("old callbacks keep native deferred-import behavior after a failed replacement", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(entry, 'export default async () => (await import("./helper")).default')
|
||||
await Bun.write(helper, 'export default "old helper"')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing deferred fixture")
|
||||
await Bun.write(helper, 'export default "new helper"')
|
||||
await Bun.write(entry, 'throw new Error("replacement failed"); export default null')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("replacement failed")
|
||||
// Best-effort reload retains the registration, not a snapshot of files that
|
||||
// its callbacks have not imported yet.
|
||||
expect(await before.default()).toBe("new helper")
|
||||
})
|
||||
|
||||
test("each helper resolves packages from its own directory", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(
|
||||
entry,
|
||||
'import { Plugin } from "@opencode-ai/plugin/tui"; import value from "./nested/helper"; export default { Plugin, value }',
|
||||
)
|
||||
await Bun.write(new URL("nested/helper.ts", sources.url), 'import value from "example"; export default value')
|
||||
for (const directory of ["", "nested/"]) {
|
||||
await Bun.write(
|
||||
new URL(directory + "node_modules/example/package.json", sources.url),
|
||||
'{"type":"module","main":"index.js"}',
|
||||
)
|
||||
await Bun.write(
|
||||
new URL(directory + "node_modules/example/index.js", sources.url),
|
||||
`export default ${JSON.stringify(directory || "root")}`,
|
||||
)
|
||||
}
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: { value: "nested/" } })
|
||||
})
|
||||
|
||||
test("a warm deferred import can retain its native cache after a failed replacement", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.mjs", sources.url)
|
||||
await Bun.write(entry, 'export default async () => (await import("./helper.mjs")).default')
|
||||
await Bun.write(helper, 'export default "cached"')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing deferred fixture")
|
||||
expect(await before.default()).toBe("cached")
|
||||
await Bun.write(helper, 'export default "changed"')
|
||||
await Bun.write(entry, 'throw new Error("replacement failed"); export default null')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("replacement failed")
|
||||
expect(await before.default()).toBe("cached")
|
||||
})
|
||||
|
||||
test("computed-only dependencies remain outside static reload tracking", async () => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.mjs", sources.url)
|
||||
await Bun.write(entry, "export default async name => (await import(name)).default")
|
||||
await Bun.write(helper, 'export default "cached"')
|
||||
const before = await sources.read(entry.href)
|
||||
const mod = before.module
|
||||
if (typeof mod !== "object" || mod === null || !("default" in mod) || typeof mod.default !== "function")
|
||||
throw new Error("Missing computed fixture")
|
||||
expect(await mod.default("./helper.mjs")).toBe("cached")
|
||||
await Bun.write(helper, 'export default "changed"')
|
||||
expect(watched).not.toContain(fileURLToPath(helper))
|
||||
expect(await sources.read(entry.href)).toBe(before)
|
||||
})
|
||||
|
||||
test("unchanged evaluation failures do not repeat import-time effects", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const code = `import { appendFileSync } from "node:fs"
|
||||
appendFileSync(new URL("./attempts.log", import.meta.url), "attempt\\n")
|
||||
throw new Error("broken evaluation")`
|
||||
await Bun.write(entry, code)
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("broken evaluation")
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("broken evaluation")
|
||||
expect(await Bun.file(new URL("attempts.log", sources.url)).text()).toBe("attempt\n")
|
||||
await Bun.write(entry, code + "\n// another generation")
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("broken evaluation")
|
||||
expect(await Bun.file(new URL("attempts.log", sources.url)).text()).toBe("attempt\nattempt\n")
|
||||
})
|
||||
|
||||
test.each([
|
||||
'export default await import("not-installed-pkg").then(m => m.default, () => "fallback")',
|
||||
'let value; try { value = (await import("not-installed-pkg")).default } catch { value = "fallback" }; export default value',
|
||||
'let value; try { value = require("not-installed-pkg") } catch { value = "fallback" }; export default value',
|
||||
'export default await import("./not-installed").then(m => m.default, () => "fallback")',
|
||||
'let value; try { value = require("./not-installed") } catch { value = "fallback" }; export default value',
|
||||
])("optional dependencies keep their runtime fallback: %s", async (code) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, code)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "fallback" })
|
||||
})
|
||||
|
||||
test("installed optional dependencies still resolve beside the importing source", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("node_modules/example/package.json", sources.url), '{"main":"index.cjs"}')
|
||||
await Bun.write(new URL("node_modules/example/index.cjs", sources.url), 'module.exports = "installed"')
|
||||
await Bun.write(
|
||||
entry,
|
||||
`const dynamic = await import("example").then(m => m.default, () => "fallback")
|
||||
let sync; try { sync = require("example") } catch { sync = "fallback" }
|
||||
export default { dynamic, sync }`,
|
||||
)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({
|
||||
default: { dynamic: "installed", sync: "installed" },
|
||||
})
|
||||
})
|
||||
|
||||
test("computed imports reject asynchronously and capture their argument at the call site", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("one.mjs", sources.url), 'export default "one"')
|
||||
await Bun.write(new URL("two.mjs", sources.url), 'export default "two"')
|
||||
await Bun.write(new URL("data.json", sources.url), '{"value":7}')
|
||||
await Bun.write(
|
||||
entry,
|
||||
`const load = name => import(name)
|
||||
const fallback = await load("not-installed-pkg").then(m => m.default, () => "fallback")
|
||||
let name = "./one.mjs"
|
||||
const pending = import(name)
|
||||
name = "./two.mjs"
|
||||
const json = name => import(name, { with: { type: "json" } })
|
||||
export default { fallback, value: (await pending).default, json: (await json("./data.json")).default.value }`,
|
||||
)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({
|
||||
default: { fallback: "fallback", value: "one", json: 7 },
|
||||
})
|
||||
})
|
||||
|
||||
test("missing static dependencies still fail the plugin load", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(entry, 'import value from "not-installed-pkg"; export default value')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("not-installed-pkg")
|
||||
})
|
||||
|
||||
test.each([false, true])("plugin errors retain source filenames (during load: %s)", async (duringLoad) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(
|
||||
new URL("nested/helper.ts", sources.url),
|
||||
'export function boom() { void import.meta.url; throw new Error("source trace") }',
|
||||
)
|
||||
await Bun.write(entry, `import { boom } from "./nested/helper"; ${duringLoad ? "boom();" : ""} export default boom`)
|
||||
const error = await (async () => {
|
||||
try {
|
||||
const loaded = (await sources.read(entry.href)).module
|
||||
if (
|
||||
typeof loaded !== "object" ||
|
||||
loaded === null ||
|
||||
!("default" in loaded) ||
|
||||
typeof loaded.default !== "function"
|
||||
)
|
||||
throw new Error("Missing stack fixture")
|
||||
loaded.default()
|
||||
return undefined
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
})()
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toBe("source trace")
|
||||
expect(error.stack).toMatch(/nested[/\\]helper\.ts:\d+:\d+/)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["static import", 'import value from "example"; export default value', "import"],
|
||||
["dynamic import", 'export default (await import("example")).default', "import"],
|
||||
["literal require", 'export default require("example")', "require"],
|
||||
["computed require", 'const read = name => require(name); export default read("example")', "require"],
|
||||
["require alias", 'const read = require; export default read("example")', "require"],
|
||||
["require.resolve", 'export default require(require.resolve("example"))', "require"],
|
||||
["import.meta.require", 'export default import.meta.require("example")', "require"],
|
||||
["shadowed require", 'const read = require => require("example"); export default read(name => name)', "example"],
|
||||
["computed local require", 'const read = name => require(name); export default read("./data.json").value', "local"],
|
||||
[
|
||||
"computed optional require",
|
||||
'const read = name => { try { return require(name) } catch { return "fallback" } }; export default read("not-installed-pkg")',
|
||||
"fallback",
|
||||
],
|
||||
])("%s preserves direct runtime resolution", async (_name, code, expected) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(
|
||||
new URL("node_modules/example/package.json", sources.url),
|
||||
JSON.stringify({
|
||||
exports: { import: "./import.mjs", require: "./require.cjs" },
|
||||
}),
|
||||
)
|
||||
await Bun.write(new URL("node_modules/example/import.mjs", sources.url), 'export default "import"')
|
||||
await Bun.write(new URL("node_modules/example/require.cjs", sources.url), 'module.exports = "require"')
|
||||
await Bun.write(new URL("data.json", sources.url), '{"value":"local"}')
|
||||
await Bun.write(entry, code)
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: expected })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: expected })
|
||||
})
|
||||
|
||||
test.each(["ts", "tsx"])("%s helpers preserve source path globals and lexical bindings", async (extension) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL(`nested/helper.${extension}`, sources.url)
|
||||
await Bun.write(
|
||||
helper,
|
||||
`export default {
|
||||
file: __filename, dir: __dirname,
|
||||
shadowed: ((__filename, __dirname) => [__filename, __dirname])("file", "dir"),
|
||||
asset: await Bun.file(__dirname + "/asset.txt").text(),
|
||||
}`,
|
||||
)
|
||||
await Bun.write(new URL("nested/asset.txt", sources.url), "asset")
|
||||
await Bun.write(entry, `export { default } from "./nested/helper.${extension}"`)
|
||||
const expected = {
|
||||
default: {
|
||||
file: fileURLToPath(helper),
|
||||
dir: path.dirname(fileURLToPath(helper)),
|
||||
shadowed: ["file", "dir"],
|
||||
asset: "asset",
|
||||
},
|
||||
}
|
||||
expect(await Host.load(entry.href)).toMatchObject(expected)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject(expected)
|
||||
})
|
||||
|
||||
test("createRequire retains its explicit package resolution base", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
for (const directory of ["", "nested/"]) {
|
||||
await Bun.write(new URL(directory + "node_modules/example/package.json", sources.url), '{"main":"index.cjs"}')
|
||||
await Bun.write(
|
||||
new URL(directory + "node_modules/example/index.cjs", sources.url),
|
||||
`module.exports = ${JSON.stringify(directory || "root")}`,
|
||||
)
|
||||
}
|
||||
await Bun.write(
|
||||
entry,
|
||||
`import { createRequire } from "node:module"
|
||||
const require = createRequire(new URL("./nested/helper.ts", import.meta.url))
|
||||
export default require("example")`,
|
||||
)
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: "nested/" })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "nested/" })
|
||||
})
|
||||
|
||||
test("deferred require callbacks use the invalidated native cache", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("data.json", sources.url)
|
||||
await Bun.write(entry, 'export default () => require("./data.json").value')
|
||||
await Bun.write(helper, '{"value":"before"}')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing require fixture")
|
||||
await Bun.write(helper, '{"value":"after"}')
|
||||
const after = (await sources.read(entry.href)).module
|
||||
if (typeof after !== "object" || after === null || !("default" in after) || typeof after.default !== "function")
|
||||
throw new Error("Missing require fixture")
|
||||
expect(before.default()).toBe("after")
|
||||
expect(after.default()).toBe("after")
|
||||
})
|
||||
|
||||
test("local package requires select main rather than the ESM module field", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("helper/package.json", sources.url), '{"main":"require.cjs","module":"import.mjs"}')
|
||||
await Bun.write(new URL("helper/require.cjs", sources.url), 'module.exports = "require"')
|
||||
await Bun.write(new URL("helper/import.mjs", sources.url), 'export default "import"')
|
||||
await Bun.write(entry, 'export default require("./helper")')
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: "require" })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "require" })
|
||||
})
|
||||
|
||||
test.each([
|
||||
'import value from "example"; export default value',
|
||||
'export default (await import("example")).default',
|
||||
'import value from "./node_modules/example/index.js"; export default value',
|
||||
])("symlinked package instances remain external: %s", async (code) => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("plugin/tui.ts", sources.url)
|
||||
const library = new URL("lib/index.js", sources.url)
|
||||
await Bun.write(new URL("lib/package.json", sources.url), '{"type":"module","main":"index.js"}')
|
||||
await Bun.write(library, 'export default { value: "shared" }')
|
||||
await mkdir(new URL("plugin/node_modules", sources.url), { recursive: true })
|
||||
await symlink(
|
||||
fileURLToPath(new URL("lib", sources.url)),
|
||||
fileURLToPath(new URL("plugin/node_modules/example", sources.url)),
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
)
|
||||
const host = await Host.load(library.href)
|
||||
if (typeof host !== "object" || host === null || !("default" in host)) throw new Error("Missing package fixture")
|
||||
for (const generation of [1, 2]) {
|
||||
await Bun.write(entry, `${code}; export const generation = ${generation}`)
|
||||
const loaded = (await sources.read(entry.href)).module
|
||||
if (typeof loaded !== "object" || loaded === null || !("default" in loaded))
|
||||
throw new Error("Missing plugin fixture")
|
||||
expect(loaded.default).toBe(host.default)
|
||||
expect(loaded).toMatchObject({ generation })
|
||||
}
|
||||
expect(watched).not.toContain(fileURLToPath(library))
|
||||
})
|
||||
|
||||
test.each(["static", "query", "file-query", "dynamic-query"])(
|
||||
"JSON text %s imports survive graph resolution and edits",
|
||||
async (kind) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const json = new URL("data.json", sources.url)
|
||||
const specifier = kind === "file-query" ? json.href + "?v=1" : "./data.json" + (kind === "static" ? "" : "?v=1")
|
||||
await Bun.write(
|
||||
entry,
|
||||
kind === "dynamic-query"
|
||||
? `const text = (await import(${JSON.stringify(specifier)}, { with: { type: "text" } })).default; export default JSON.parse(text).value`
|
||||
: `import text from ${JSON.stringify(specifier)} with { type: "text" }; export default JSON.parse(text).value`,
|
||||
)
|
||||
await Bun.write(json, '{"value":"before"}')
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: "before" })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "before" })
|
||||
await Bun.write(json, '{"value":"after"}')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["mjs", "json"])("literal %s query imports are watched and reloaded natively", async (extension) => {
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL(`helper.${extension}`, sources.url)
|
||||
const prefix = extension === "json" ? "" : "export default "
|
||||
await Bun.write(entry, `export default async () => (await import("./helper.${extension}?mode=plugin")).default`)
|
||||
await Bun.write(helper, prefix + '"before"')
|
||||
const before = (await sources.read(entry.href)).module
|
||||
if (typeof before !== "object" || before === null || !("default" in before) || typeof before.default !== "function")
|
||||
throw new Error("Missing query fixture")
|
||||
await Bun.write(helper, prefix + '"after"')
|
||||
await Bun.write(entry, 'throw new Error("replacement failed"); export default null')
|
||||
await expect(sources.read(entry.href)).rejects.toThrow("replacement failed")
|
||||
expect(watched).toContain(fileURLToPath(helper))
|
||||
expect(await before.default()).toBe("after")
|
||||
await Bun.write(entry, `export { default } from "./helper.${extension}?mode=plugin"`)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
await Bun.write(helper, prefix + '"repaired"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "repaired" })
|
||||
})
|
||||
|
||||
test("query spellings retain distinct module identities and source URLs", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.mjs", sources.url)
|
||||
await Bun.write(
|
||||
helper,
|
||||
"export default { url: import.meta.url, path: import.meta.path, file: import.meta.file, filename: __filename }",
|
||||
)
|
||||
await Bun.write(
|
||||
entry,
|
||||
'import one from "./helper.mjs?one/path"; import two from "./helper.mjs?two"; export default { same: one === two, ...one }',
|
||||
)
|
||||
const expected = {
|
||||
default: {
|
||||
same: false,
|
||||
url: helper.href + "?one/path",
|
||||
path: fileURLToPath(helper),
|
||||
file: "helper.mjs",
|
||||
filename: fileURLToPath(helper),
|
||||
},
|
||||
}
|
||||
expect(await Host.load(entry.href)).toMatchObject(expected)
|
||||
expect((await sources.read(entry.href)).module).toMatchObject(expected)
|
||||
})
|
||||
|
||||
test("literal file URLs retain source loading and helper edits", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const helper = new URL("helper.ts", sources.url)
|
||||
await Bun.write(helper, 'export default "before"')
|
||||
await Bun.write(entry, `export { default } from ${JSON.stringify(helper.href)}`)
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: "before" })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "before" })
|
||||
await Bun.write(helper, 'export default "after"')
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: "after" })
|
||||
})
|
||||
|
||||
test.each(["ts", "tsx"])("%s wildcard package barrels retain live exports", async (extension) => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
await Bun.write(new URL("node_modules/example/package.json", sources.url), '{"type":"module","main":"index.js"}')
|
||||
await Bun.write(
|
||||
new URL("node_modules/example/index.js", sources.url),
|
||||
'export default { value: "package" }; export let count = 0; export const increment = () => count++',
|
||||
)
|
||||
await Bun.write(
|
||||
new URL(`helper.${extension}`, sources.url),
|
||||
'export * from "example"; import value from "example"; export default value',
|
||||
)
|
||||
await Bun.write(entry, `export { default } from "./helper.${extension}"`)
|
||||
expect(await Host.load(entry.href)).toMatchObject({ default: { value: "package" } })
|
||||
expect((await sources.read(entry.href)).module).toMatchObject({ default: { value: "package" } })
|
||||
await Bun.write(entry, `export { default, count, increment } from "./helper.${extension}"`)
|
||||
const loaded = (await sources.read(entry.href)).module
|
||||
if (
|
||||
typeof loaded !== "object" ||
|
||||
loaded === null ||
|
||||
!("increment" in loaded) ||
|
||||
typeof loaded.increment !== "function"
|
||||
)
|
||||
throw new Error("Missing barrel fixture")
|
||||
expect(loaded).toMatchObject({ default: { value: "package" }, count: 0 })
|
||||
loaded.increment()
|
||||
expect(loaded).toMatchObject({ count: 1 })
|
||||
})
|
||||
|
||||
async function fixture(watch: (file: string) => Promise<void> = async () => {}) {
|
||||
const dir = await tmpdir()
|
||||
const sources = createPluginSources(watch)
|
||||
return {
|
||||
...sources,
|
||||
url: pathToFileURL(dir.path + path.sep),
|
||||
async [Symbol.asyncDispose]() {
|
||||
sources.dispose()
|
||||
await dir[Symbol.asyncDispose]()
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue