mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-04 10:10:01 +00:00
697 lines
26 KiB
TypeScript
697 lines
26 KiB
TypeScript
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<string>("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<Promise<void>["then"]>) => {
|
|
observe("method");
|
|
return Promise.prototype.then.apply(pending.promise, args);
|
|
};
|
|
},
|
|
});
|
|
let result: Promise<void> | 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]<Accumulator>((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<Visitor, Handler> {
|
|
#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<void> | 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<unknown, unknown> | Set<unknown> | 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<string, unknown>; set: Set<unknown>; 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();
|
|
}
|
|
});
|
|
});
|