// Tests of the plugins component (slice 7c §4, §5, §7): per plugin at startup and reload (a failing build, evaluation, // setup; unchanged plugins untouched; added and removed plugins; the closure rule through builds), `rollout` (order, // stopping at a rejecting step, marks), installs through the component, GC over 200 reloads, and end to end a server // and a worker loading plugin directories, an edit rolled out, a broken edit kept old on both hosts. import { rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { BACKGROUND_CONTEXT as context, withAbortSignal } from "../../../src/context/index.ts"; import { hostKind, int, local, method, remote, str } from "../../../src/next/contracts/index.ts"; import { createHost, defineFacet, type Host, type ReloadReport } from "../../../src/next/host/index.ts"; import type { ChordHost } from "../../../src/next/host/internal.ts"; import { memoryLinkPair } from "../../../src/next/link/index.ts"; import { constant } from "../../../src/next/state/index.ts"; import { createPlugins, type Plugins, type RolloutHost, type RolloutStep, } from "../../../src/next-node/plugins/index.ts"; import { changesOf, delivered, expectCollected, rejection, settledHeap, transported, valuesOf, } from "../../next/support.ts"; import { HOST_PACKAGES, kindsOf, tempRoot, writeFiles, writePackage, writePlugin } from "./support.ts"; /** The fixtures' contracts, as the `contracts` fixture declares them. */ const Worker = hostKind("test.worker"); const Notes = remote("test.notes", { version: 1, params: {}, hostAddress: () => Worker(), variants: { main: { state: { label: str(), count: int() }, methods: { add: method(undefined, int()) } } }, }); const Index = local("test.index", { version: 1, params: {}, members: { label: str() } }); const Item = local("test.item", { version: 1, params: {}, members: { label: str() } }); /** A plugin `dir` named `@test/
({ address: Worker(), principal: { kind: "worker" }, onError: () => {} }); worker.connect(up, { principal: { kind: "server" } }, context); const server = createHost
({ principal: { kind: "server" }, onError: () => {} }); server.route( Worker, { authorize: () => true, resolve: async () => ({ link: down, principal: { kind: "worker" } }) }, context, ); await plugins.load(worker, { kind: "worker", core: [], modules: HOST_PACKAGES }, context); await plugins.load(server, { kind: "server", core: [], modules: HOST_PACKAGES }, context); const notes = await server.get(Notes, {}, context); const index = await server.get(Index, {}, context); expect([notes.label.value, index.label.value]).toEqual(["v1", "v1"]); await notes.add(context); const frames = changesOf(await notes.label.watch(context)); const steps = [plugins.step("server", server as Host
), plugins.step("worker", worker as Host
)]; await writeFile(join(root, "notes", "label.ts"), 'export const label = "v2";\n'); await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/notes"] }), { timeout: 5_000 }); const rolled = await plugins.rollout(steps, await plugins.build(plugins.state.value.dirty, context), context); expect( rolled.steps.map(({ name, hosts }) => [name, reportOf(hosts).plugins.changed, reportOf(hosts).objects]), ).toEqual([ ["server", ["@test/notes"], [{ object: "test.index", kind: "swapped" }]], ["worker", ["@test/notes"], [{ object: "test.notes", kind: "swapped" }]], ]); await transported(); await delivered(); expect(valuesOf(frames)).toEqual(["v2"]); expect([notes.label.value, notes.count.value, index.label.value]).toEqual(["v2", 1, "v2"]); expect(plugins.state.value).toMatchObject({ dirty: [] }); await writeFile(join(root, "notes", "label.ts"), "export const label = ;\n"); await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/notes"] }), { timeout: 5_000 }); const broken = await plugins.rollout(steps, await plugins.build(plugins.state.value.dirty, context), context); expect(broken.steps.map(({ name, hosts }) => [name, failedNames(reportOf(hosts))])).toEqual([ ["server", ["@test/notes"]], ["worker", ["@test/notes"]], ]); await transported(); expect([notes.label.value, index.label.value, notes.state.value]).toEqual(["v2", "v2", { kind: "live" }]); expect(plugins.state.value).toMatchObject({ dirty: ["@test/notes"] }); await server.close(context); await worker.close(context); plugins.close(); }); }); describe("fix round 1", () => { it("serializes reloads of one host: two started together keep both changes (Astra, Sol, Opus S1)", async () => { const root = await tempRoot(); await itemPlugin(root, "a", "a1"); await itemPlugin(root, "b", "b1"); const { plugins } = component(root); const host = createHost({ onError: () => {} }); await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context); await itemPlugin(root, "a", "a2"); const builtA = await plugins.build(["@test/a"], context); await itemPlugin(root, "b", "b2"); const builtB = await plugins.build(["@test/b"], context); const reports = await Promise.all([plugins.reload(host, builtA, context), plugins.reload(host, builtB, context)]); expect(reports.map(({ plugins: { changed } }) => changed)).toEqual([["@test/a"], ["@test/b"]]); const label = async (dir: string) => (await host.get(local(`test.${dir}`, { version: 1, params: {}, members: { label: str() } }), {}, context)) .label.value; expect([await label("a"), await label("b")]).toEqual(["a2", "b2"]); await host.close(context); plugins.close(); }); it("applies the removals of the build it was given, never a later discovery's (Astra)", async () => { const root = await tempRoot(); await itemPlugin(root, "a", "a1"); await itemPlugin(root, "b", "b1"); const { plugins } = component(root); const host = createHost({ onError: () => {} }); await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context); await itemPlugin(root, "a", "a2"); const first = await plugins.build(["@test/a"], context); await rm(join(root, "b"), { recursive: true }); await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ set: true }), { timeout: 5_000 }); const later = await plugins.build([], context); expect([first.removed, later.removed]).toEqual([[], ["@test/b"]]); const report = await plugins.reload(host, first, context); expect(report.plugins).toEqual({ added: [], changed: ["@test/a"], removed: [] }); expect((await plugins.reload(host, later, context)).plugins.removed).toEqual(["@test/b"]); await host.close(context); plugins.close(); }); it("adopts what a reload committed when its wait aborts after the commit: a later reload keeps it (Astra)", async () => { const root = await tempRoot(); await itemPlugin(root, "a", "a1"); await itemPlugin(root, "b", "b1"); const { plugins } = component(root); const host = createHost({ onError: () => {} }); await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context); const Item = local("test.a", { version: 1, params: {}, members: { label: str() } }); const item = await host.get(Item, {}, context); await itemPlugin(root, "a", "a2"); const first = await plugins.build(["@test/a"], context); // The abort lands right after the reload's commit (its publication batch), while it waits for its report. const { batch } = host as ChordHost; const commit = batch.commit.bind(batch); const controller = new AbortController(); const aborting = withAbortSignal(controller.signal, context); const hook = vi.spyOn(batch, "commit").mockImplementation((adopt, ctx) => { commit(adopt, ctx); if (ctx === aborting) controller.abort(); }); expect(await rejection(plugins.reload(host, first, aborting))).toMatchObject({ code: "aborted", details: { committed: { added: [], changed: ["@test/a"], removed: [], failed: [] } }, }); hook.mockRestore(); expect(item.label.value).toBe("a2"); await itemPlugin(root, "b", "b2"); await plugins.reload(host, await plugins.build(["@test/b"], context), context); expect(item.label.value).toBe("a2"); await host.close(context); plugins.close(); }); it("leaves a plugin out of a host whose kind its rebuilt facets no longer have (Opus recheck R1)", async () => { const root = await tempRoot(); await itemPlugin(root, "a", "a1"); const { plugins } = component(root); const host = createHost({ onError: () => {} }); await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context); // The plugin keeps a server facet only. await rm(join(root, "a", "worker.ts")); await writeFiles(join(root, "a"), { "server.ts": 'import { defineFacet } from "@earendil-works/chord/next";\nexport default defineFacet(() => {});', }); await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ set: true }), { timeout: 5_000 }); const built = await plugins.build(["@test/a"], context); expect(built.removed).toEqual([]); expect((await plugins.reload(host, built, context)).plugins).toEqual({ added: [], changed: [], removed: ["@test/a"], }); await host.close(context); plugins.close(); }); it("reloads a structured clone of a build as the build itself: it is plain data (Astra)", async () => { const root = await tempRoot(); await itemPlugin(root, "a", "a1"); const { plugins } = component(root); const host = createHost({ onError: () => {} }); await plugins.load(host, { kind: "worker", core: [], modules: HOST_PACKAGES }, context); const item = await host.get(local("test.a", { version: 1, params: {}, members: { label: str() } }), {}, context); await itemPlugin(root, "a", "a2"); await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ dirty: ["@test/a"] }), { timeout: 5_000 }); const built = await plugins.build(["@test/a"], context); expect((await plugins.reload(host, structuredClone(built), context)).plugins.changed).toEqual(["@test/a"]); expect(item.label.value).toBe("a2"); // The marks clear only through the build object itself (a copy carries no snapshot). const result = await plugins.rollout([plugins.step("worker", host)], structuredClone(built), context); expect(result.stopped).toBeUndefined(); expect(plugins.state.value).toMatchObject({ dirty: ["@test/a"] }); await host.close(context); plugins.close(); }); it("reads a changed package.json again at the next build: a new dependency is accepted (Astra, Sol)", async () => { const root = await tempRoot(); const tools = await tempRoot(); await writeFiles(tools, { "npm.mjs": "process.exit(0);" }); const dir = await writePlugin(root, "a", "@test/a", { "worker.ts": 'import { label } from "./helper.ts";\nexport default { setup: () => {}, label };', "helper.ts": 'export const label = "old";', }); const plugins = createPlugins( { roots: [root], kinds: kindsOf(HOST_PACKAGES), install: { npmCommand: [process.execPath, join(tools, "npm.mjs")] }, onError: () => {}, }, context, ); expect((await plugins.build([], context)).facets.map(({ kind }) => kind)).toEqual(["script"]); await writeFiles(dir, { "package.json": JSON.stringify({ name: "@test/a", type: "module", dependencies: { "new-dep": "1.0.0" } }), }); await vi.waitFor(() => expect(plugins.state.value).toMatchObject({ install: ["@test/a"] }), { timeout: 5_000 }); await writePackage(dir, "new-dep", { "index.js": 'exports.label = "new";' }); await writeFiles(dir, { "helper.ts": 'export { label } from "new-dep";' }); await plugins.installDependencies("@test/a", context); expect(plugins.state.value).toMatchObject({ set: false }); const built = await plugins.build(["@test/a"], context); expect(built.facets).toEqual([expect.objectContaining({ kind: "script", plugin: "@test/a" })]); expect(plugins.discovery.plugins[0]!.dependencies).toEqual(["new-dep"]); plugins.close(); }); }); describe("the reload queues and garbage collection", () => { it("lets a rejected new facet go once its report is dropped, the kept old one live (Sol)", async () => { const root = await tempRoot(); const made: WeakRef[] = []; const probe = { made: (value: object) => made.push(new WeakRef(value)) }; const source = (bad: boolean) => [ 'import { constant, defineFacet, int, local } from "@earendil-works/chord/next";', 'import { made } from "@test/probe";', `const state = { bad: ${bad}, big: new Array(10000).fill("payload") };`, "made(state);", 'const Type = local("test.failed-gc", { version: 1, params: {}, members: { n: int() } });', "export default defineFacet((h) => {", '\tif (state.bad) throw new Error("bad setup");', "\th.serve(Type, () => ({ n: constant(state.big.length) }));", "});", ].join("\n"); const dir = await writePlugin(root, "failed", "@test/failed", { "worker.ts": source(false) }); const modules = { ...HOST_PACKAGES, "@test/probe": probe }; const plugins = createPlugins({ roots: [root], kinds: kindsOf(modules), onError: () => {} }, context); const host = createHost({ onError: () => {} }); await plugins.load(host, { kind: "worker", core: [], modules }, context); await writeFiles(dir, { "worker.ts": source(true) }); const built = await plugins.build(["@test/failed"], context); // The report (its failure's error holds the new facet's closure) is dropped here; the queues must not keep it. expect((await plugins.reload(host, built, context)).failed.map(({ plugin }) => plugin)).toEqual(["@test/failed"]); await expectCollected(made[1]!); expect(made[0]!.deref()).toBeDefined(); await host.close(context); plugins.close(); }); });