import { setImmediate as yieldImmediate } from "node:timers/promises"; import { runInNewContext } from "node:vm"; import { describe, expect, it } from "vitest"; import { createPluginRuntimeStore } from "../plugin-sdk/runtime-store.js"; import { createDeferredCore } from "../shared/deferred.js"; import { PluginInstance } from "./plugin-instance.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js"; import { createPluginRecord } from "./status.test-helpers.js"; describe("plugin value invocation ownership", () => { it("keeps Promise inspection and assimilation in the admitted owner", async () => { const registry = createEmptyPluginRegistry(); const record = createPluginRecord({ id: "promise-export" }); registry.plugins.push(record); const instance = new PluginInstance(record.id, { record, registry }); const store = createPluginRuntimeStore("unset fixture runtime"); instance.run(() => store.setRuntime("owned runtime")); const pending = createDeferredCore(); const observed: Array<{ phase: string; registry: unknown; runtime: unknown }> = []; const observe = (phase: string) => observed.push({ phase, registry: getPluginRuntimeGatewayRequestScope()?.pluginRegistry, runtime: store.tryGetRuntime(), }); // oxlint-disable-next-line unicorn/no-thenable -- Plugin-defined Promise inspection must retain its admitted scope. void Object.defineProperty(pending.promise, "then", { get() { observe("getter"); return (...args: Parameters["then"]>) => { observe("method"); return Promise.prototype.then.apply(pending.promise, args); }; }, }); let result: Promise | undefined; try { result = instance.wrap(() => pending.promise)(); expect(observed.map((item) => item.phase)).toContain("getter"); expect(observed.map((item) => item.phase)).toContain("method"); for (const item of observed) { expect.soft(item.registry, item.phase).toBe(registry); expect.soft(item.runtime, item.phase).toBe("owned runtime"); } } finally { pending.resolve(); await result; await instance.dispose(); } }); }); describe("plugin values delivered through caller callbacks", () => { it.each(["array", "map", "set", "map-callback", "reduce", "exported-function"] as const)( "fences callable values delivered by %s after retirement", async (surface) => { const instance = new PluginInstance(`collection-${surface}`); const handler = () => "current"; const retained: Array<() => string> = []; class Receiver { #label = "caller receiver"; read() { return this.#label; } } const receiver = new Receiver(); const collect = function (this: Receiver, value: () => string, key?: unknown) { expect(this).toBe(receiver); expect(this.read()).toBe("caller receiver"); retained.push(value); if (typeof key === "function") { retained.push(key as () => string); } return value; }; try { if (surface === "map") { instance.wrap(new Map([[handler, handler]])).forEach(collect, receiver); } else if (surface === "set") { instance.wrap(new Set([handler])).forEach(collect, receiver); } else if (surface === "exported-function") { const deliver = instance.wrap((callback: typeof collect, target: Receiver) => { callback.call(target, handler); }); deliver(collect, receiver); } else { const collection = instance.wrap([handler]); if (surface === "map-callback") { collection.map(collect, receiver); } else if (surface === "reduce") { collection.reduce((_previous, value) => collect.call(receiver, value), handler); } else { collection.forEach(collect, receiver); } } expect(retained.length).toBeGreaterThan(0); for (const callback of retained) { expect(callback()).toBe("current"); } await instance.dispose(); for (const callback of retained) { expect(() => callback()).toThrow("reloaded or disabled"); } } finally { await instance.dispose(); } }, ); it.each(["caller", "returned"] as const)( "preserves %s callback identity for registration and removal", async (handle) => { const instance = new PluginInstance("callback-identity"); const listeners = new Set<() => void>(); const subscription = instance.wrap({ on(callback: () => void) { listeners.add(callback); return callback; }, off(callback: () => void) { listeners.delete(callback); }, emit() { for (const listener of listeners) { listener(); } }, }); let calls = 0; const callback = () => { calls += 1; }; try { const registered = subscription.on(callback); subscription.emit(); subscription.off(handle === "caller" ? callback : registered); subscription.emit(); expect(calls).toBe(1); } finally { await instance.dispose(); } }, ); it("returns callable handles to their owning registry", async () => { const instance = new PluginInstance("returned-callable"); const registrations = new Map<() => string, string>(); const registry = instance.wrap({ create(label: string) { const handle = () => label; registrations.set(handle, label); return handle; }, lookup(handle: () => string) { return registrations.get(handle); }, remove(handle: () => string) { return registrations.delete(handle); }, }); try { const first = registry.create("first"); const second = registry.create("second"); expect(registry.lookup(first)).toBe("first"); expect(registry.remove(first)).toBe(true); expect(registry.lookup(first)).toBeUndefined(); expect(registry.lookup(second)).toBe("second"); expect(registrations.size).toBe(1); } finally { await instance.dispose(); } }); }); describe("native collection data argument identity", () => { it("preserves host registration identity around a plugin-owned contribution", async () => { const instance = new PluginInstance("host-registry"); const contribution = instance.wrap({ run: () => "plugin" }); const context = Symbol("host context"); const registry = { contributions: [contribution], [context]: () => "host" }; const owners = new WeakMap([[registry, "registered"]]); const invoke = instance.wrap((params: { snapshot: { registry: typeof registry } }) => { expect(params.snapshot.registry[context]()).toBe("host"); return owners.get(params.snapshot.registry); }); try { expect(invoke({ snapshot: Object.freeze({ registry }) })).toBe("registered"); expect(invoke({ snapshot: { registry } })).toBe("registered"); } finally { await instance.dispose(); } }); it.each(["direct", "record", "array", "cycle"] as const)( "returns an opaque handle to its owning receiver through %s arguments", async (shape) => { class Handle { #value = 42; read() { return this.#value; } } const instance = new PluginInstance("opaque-handle"); const handle = new Handle(); const api = instance.wrap({ create: () => handle, consume(value: Handle | { handle: Handle }) { const received = shape === "direct" ? value : "handle" in value ? value.handle : undefined; expect(received).toBe(handle); expect(handle.read()).toBe(42); if (shape === "cycle") { expect(Reflect.get(value, "self")).toBe(value); } }, }); try { const view = api.create(); const envelope = shape === "array" ? Object.assign([], { handle: view }) : { handle: view }; if (shape === "cycle") { Object.assign(envelope, { self: envelope }); } api.consume(shape === "direct" ? view : envelope); expect(envelope.handle).toBe(view); await instance.dispose(); expect(() => api.consume(view)).toThrow("reloaded or disabled"); } finally { await instance.dispose(); } }, ); it("preserves untouched caller values while restoring a nested local handle", async () => { const instance = new PluginInstance("local-handle"); const other = new PluginInstance("other-handle"); const handle = { read: () => 42 }; const view = instance.wrap(handle); const foreign = other.wrap(handle); const callback = instance.wrap(() => "callback"); const callbackData = { callback, handle: view }; let reads = 0; const untouched = { get value() { reads += 1; return 1; }, }; const cycle: { self?: unknown } = {}; cycle.self = cycle; const mixed = { handle: view, get receiver() { return this; }, }; const input = { handle: view, foreign, callbackData, untouched, cycle, mixed }; const consume = instance.wrap((received: typeof input, options: typeof untouched) => { expect(received.handle).toBe(handle); expect(received.foreign).toBe(foreign); expect(received.callbackData).toBe(callbackData); expect(received.callbackData.callback).toBe(callback); expect(received.callbackData.handle).toBe(view); expect(received.untouched).toBe(untouched); expect(received.cycle).toBe(cycle); expect(received.mixed).toBe(mixed); expect(received.mixed.handle).toBe(view); expect(received.mixed.receiver).toBe(mixed); expect(options).toBe(untouched); expect(reads).toBe(0); }); try { consume(input, untouched); expect(input.handle).toBe(view); } finally { await Promise.all([instance.dispose(), other.dispose()]); } }); it.each( (["map", "set", "array"] as const).flatMap((collection) => (["raw", "member", "callback"] as const).flatMap((acquisition) => (["host", "VM"] as const).map((realm) => ({ collection, acquisition, realm })), ), ), )( "preserves $realm $collection callable data acquired through $acquisition", async ({ collection, acquisition, realm }) => { const instance = new PluginInstance(`data-${collection}-${acquisition}`); const original = () => "original"; const replacement = () => "replacement"; let key = original; try { if (collection === "map") { const source: Map<() => string, () => string> = realm === "host" ? new Map([[original, () => "value"]]) : runInNewContext('new Map([[original, () => "value"]])', { original }); const view = instance.wrap(source); if (acquisition === "member") { key = view.keys().next().value!; } else if (acquisition === "callback") { view.forEach((_value, candidate) => { key = candidate; }); } expect(view.has(key)).toBe(true); expect(view.get(key)!()).toBe("value"); expect(view.set(key, replacement)).toBe(view); expect(source.size).toBe(1); expect(source.get(original)).toBe(replacement); expect(view.get(key)!()).toBe("replacement"); expect(view.delete(key)).toBe(true); expect(source.size).toBe(0); } else if (collection === "set") { const source: Set<() => string> = realm === "host" ? new Set([original]) : runInNewContext("new Set([original])", { original }); const view = instance.wrap(source); if (acquisition === "member") { key = view.values().next().value!; } else if (acquisition === "callback") { view.forEach((candidate) => { key = candidate; }); } expect(view.has(key)).toBe(true); expect(view.add(key)).toBe(view); expect(source.size).toBe(1); view.add(replacement); expect(source.has(replacement)).toBe(true); expect(view.delete(key)).toBe(true); expect(source.has(original)).toBe(false); expect(view.delete(replacement)).toBe(true); } else { const source: Array<() => string> = realm === "host" ? [original] : runInNewContext("[original]", { original }); const view = instance.wrap(source); if (acquisition === "member") { key = view[0]!; } else if (acquisition === "callback") { view.forEach((candidate) => { key = candidate; }); } expect(view.includes(key)).toBe(true); expect(view.indexOf(key)).toBe(0); view.push(replacement); expect(source[1]).toBe(replacement); expect(view.includes(view[1]!)).toBe(true); view.splice(0, 1, replacement); expect(source[0]).toBe(replacement); } } finally { await instance.dispose(); } }, ); it.each( (["reduce", "reduceRight"] as const).flatMap((method) => (["unchanged", "function", "object"] as const).map((shape) => ({ method, shape })), ), )("preserves native $method $shape accumulator identity", async ({ method, shape }) => { type Accumulator = (() => string) | { read: () => string }; const instance = new PluginInstance("reduce-data"); const initial = () => "initial"; const returned: Accumulator[] = ["first", "final"].map((label) => shape === "unchanged" ? initial : shape === "function" ? () => label : { read: () => label }, ); const source = [() => "left plugin element", () => "right plugin element"]; const view = instance.wrap(source); const run = (collection: typeof source) => { const accumulators: Accumulator[] = []; const result = collection[method]((current, element, index, array) => { expect(array).toBe(collection); expect(element).toBe(collection === source ? source[index] : instance.wrap(source[index])); accumulators.push(current); return returned[accumulators.length - 1]!; }, initial); return { result, accumulators }; }; try { const native = run(source); const managed = run(view); expect(managed.accumulators).toHaveLength(2); managed.accumulators.forEach((value, index) => expect.soft(value).toBe(native.accumulators[index]), ); expect(managed.result).toBe(native.result); // Without an initial value or callback invocation, the result is still a plugin element. const single = instance.wrap([source[0]!]); expect(single[method](() => initial)).toBe(instance.wrap(source[0])); } finally { await instance.dispose(); } }); it.each(["reduce", "reduceRight", "forEach"] as const)( "preserves caller-owned %s data containing a plugin handle", async (method) => { const instance = new PluginInstance("native-caller-data"); const data = { handle: instance.wrap({ read: () => "owned value" }) }; const collection = instance.wrap([() => "plugin element"]); const check = (received: typeof data) => { expect(received).toBe(data); expect(received.handle).toBe(data.handle); }; try { if (method === "forEach") { collection.forEach(function (this: typeof data) { check(this); }, data); } else { collection[method]((current) => { check(current); return current; }, data); } } finally { await instance.dispose(); } }, ); }); describe("native collection method ownership", () => { it("keeps subclass overrides on their plugin callback contract", async () => { type Handler = () => string; type Visitor = (handler: Handler) => Handler; class CallbackMap extends Map { #handler = () => "private handler"; override get(visit: Visitor) { return visit(this.#handler); } } const instance = new PluginInstance("collection-override"); const view = instance.wrap(new CallbackMap([[(handler) => handler, () => "stored handler"]])); let retained: Handler | undefined; try { view.get((handler) => { retained = handler; return handler; }); expect(retained?.()).toBe("private handler"); await instance.dispose(); expect(() => retained!()).toThrow("reloaded or disabled"); } finally { await instance.dispose(); } }); it.each(["map", "set"] as const)("round-trips callable keys through a weak %s", async (kind) => { const instance = new PluginInstance(`weak-${kind}`); const key = () => "key"; const source = kind === "map" ? new WeakMap([[key, "value"]]) : new WeakSet([key]); const view = instance.wrap(source); try { expect(view.has(key)).toBe(true); expect(view.has(instance.wrap(key))).toBe(true); expect(view.delete(instance.wrap(key))).toBe(true); expect(source.has(key)).toBe(false); } finally { await instance.dispose(); } }); }); describe("async iterable helper callbacks", () => { it.each( (["source", "iterator"] as const).flatMap((target) => (["retained", "async"] as const).map((lifetime) => ({ target, lifetime })), ), )("owns $lifetime callback values from a $target helper", async ({ target, lifetime }) => { const instance = new PluginInstance(`iterable-${target}-${lifetime}`); const release = createDeferredCore(); const finished = createDeferredCore(); class Visitor { #handler = () => "private helper value"; visit(callback: (handler: () => string) => unknown) { callback(this.#handler); } } const iterator = Object.assign(new Visitor(), { next: async () => ({ done: true as const, value: undefined }), return: async () => ({ done: true as const, value: undefined }), }); const stream = instance.wrap( Object.assign(new Visitor(), { [Symbol.asyncIterator]: () => iterator, }), ); const view = stream[Symbol.asyncIterator](); const helper = target === "source" ? stream : view; let retained: (() => string) | undefined; let answer: unknown; let disposal: Promise | undefined; try { helper.visit( lifetime === "retained" ? (handler) => { retained = handler; expect(handler()).toBe("private helper value"); } : async (handler) => { try { await release.promise; answer = handler(); } catch (error) { answer = error; } finally { finished.resolve(); } }, ); await view.next(); let disposed = false; disposal = instance.dispose().then(() => { disposed = true; }); if (lifetime === "retained") { await disposal; expect(() => retained!()).toThrow("reloaded or disabled"); } else { await yieldImmediate(); expect(disposed, "stream completion retired an admitted helper callback").toBe(false); release.resolve(); await finished.promise; await disposal; expect(answer).toBe("private helper value"); } } finally { release.resolve(); await view.return(); await (disposal ?? instance.dispose()); if (lifetime === "async") { await finished.promise; } } }); it("preserves native lookup keys on an async iterable Map", async () => { class StreamMap extends Map<() => string, string> { async *[Symbol.asyncIterator]() { yield "complete"; } } const instance = new PluginInstance("iterable-map"); const key = () => "key"; const stream = instance.wrap(new StreamMap([[key, "value"]])); const iterator = stream[Symbol.asyncIterator](); try { expect(stream.get(key)).toBe("value"); } finally { await iterator.return(undefined); await instance.dispose(); } }); }); describe("collection data classification", () => { it.each( (["map", "set", "array"] as const).flatMap((kind) => (["host", "VM"] as const).flatMap((realm) => (["own", "subclass"] as const).flatMap((placement) => (["method", "getter", "iterator"] as const).flatMap((member) => [false, true].map((populated) => ({ kind, realm, placement, member, populated })), ), ), ), ), )( "fences $realm $kind $placement $member (populated=$populated)", async ({ kind, realm, placement, member, populated }) => { const constructors: { Map: typeof Map; Set: typeof Set; Array: typeof Array } = realm === "VM" ? runInNewContext("({ Map, Set, Array })") : { Map, Set, Array }; const MapType = placement === "subclass" ? class extends constructors.Map {} : constructors.Map; const SetType = placement === "subclass" ? class extends constructors.Set {} : constructors.Set; const ArrayType = placement === "subclass" ? class extends constructors.Array {} : constructors.Array; const source: Map | Set | unknown[] = kind === "map" ? new MapType() : kind === "set" ? new SetType() : new ArrayType(); if (populated) { if ("set" in source) { source.set("key", "value"); } else if ("add" in source) { source.add("key"); } else { source.push("key"); } } let getterCalls = 0; let calls = 0; const read = function (this: object) { calls += 1; if (member === "iterator") { throw new Error("classification executed a plugin iterator"); } const has = kind === "map" ? constructors.Map.prototype.has.call(this, "key") : kind === "set" ? constructors.Set.prototype.has.call(this, "key") : constructors.Array.prototype.includes.call(this, "key"); expect(has).toBe(populated); return "current"; }; const key = member === "iterator" ? Symbol.iterator : "read"; Object.defineProperty(placement === "own" ? source : Object.getPrototypeOf(source), key, { configurable: true, ...(member === "getter" ? { get: () => { getterCalls += 1; return read.bind(source); }, } : { value: read }), }); const instance = new PluginInstance("collection-members"); try { const view = instance.wrap(source); expect(getterCalls).toBe(0); expect(calls).toBe(0); const retained: () => string = Reflect.get(view, key); if (member !== "iterator") { expect(Reflect.apply(retained, source, [])).toBe("current"); } await instance.dispose(); expect(() => Reflect.apply(retained, source, [])).toThrow("reloaded or disabled"); if (member === "getter") { expect(() => Reflect.get(view, key)).toThrow("reloaded or disabled"); expect(getterCalls).toBe(1); } } finally { await instance.dispose(); } }, ); it.each(["host", "VM"] as const)( "keeps plain $realm data collections native and cloneable", async (realm) => { const source: { map: Map; set: Set; array: unknown[] } = realm === "VM" ? runInNewContext( '({map:new Map([["key",{value:1}]]),set:new Set(["value"]),array:["value"]})', ) : { map: new Map([["key", { value: 1 }]]), set: new Set(["value"]), array: ["value"] }; const instance = new PluginInstance("plain-collections"); const shared = { value: 2 }; source.map.set("shared", shared); source.map.set("self", source.map); source.set.add(shared); source.set.add(source.set); source.array.push(shared, source.array); const view = instance.wrap(source); expect(view).toBe(source); expect(Map.prototype.get.call(view.map, "key")).toEqual({ value: 1 }); expect(Set.prototype.has.call(view.set, "value")).toBe(true); expect(Array.prototype.includes.call(view.array, "value")).toBe(true); const cloned = structuredClone(view); expect(cloned.map.get("key")).toEqual({ value: 1 }); expect(cloned.map.get("shared")).toBe(cloned.array[1]); expect(cloned.set.has(cloned.array[1])).toBe(true); expect(cloned.map.get("self")).toBe(cloned.map); expect(cloned.set.has(cloned.set)).toBe(true); expect(cloned.array[2]).toBe(cloned.array); await instance.dispose(); expect(Map.prototype.get.call(view.map, "key")).toEqual({ value: 1 }); }, ); it("reclassifies a mutable record becoming callable and then data without releasing retained calls", async () => { const instance = new PluginInstance("mutable-classification"); const source: { value: number; run?: () => number } = { value: 1 }; try { expect(instance.wrap(source)).toBe(source); source.run = () => 42; const view = instance.wrap(source); expect(view).not.toBe(source); const retained = view.run!; expect(retained()).toBe(42); delete source.run; expect(instance.wrap(source)).toBe(source); expect(structuredClone(instance.wrap(source))).toEqual({ value: 1 }); await instance.dispose(); expect(() => retained()).toThrow("reloaded or disabled"); expect(instance.wrap(source)).toBe(source); } finally { await instance.dispose(); } }); });