| @@ -1,31 +1,52 @@ |
| 1 | import * as NodeTimersPromises from "node:timers/promises"; | 1 | import { it } from "@effect/vitest"; |
| | 2 | import { describe, expect } from "vite-plus/test"; |
| | 3 | import * as Cause from "effect/Cause"; |
| | 4 | import * as Effect from "effect/Effect"; |
| | 5 | import * as Exit from "effect/Exit"; |
| | 6 | import * as Fiber from "effect/Fiber"; |
| | 7 | import * as Scope from "effect/Scope"; |
| 2 | | 8 | |
| 3 | import { describe, expect, it } from "vite-plus/test"; | 9 | import type { PluginRuntime, PluginRuntimeReconcileError } from "../src/runtime.ts"; |
| 4 | | 10 | |
| 5 | import type { | 11 | import type { |
| 6 | PluginDefinition, | 12 | PluginDefinition, |
| 7 | PluginRuntimeFactory, | 13 | PluginRuntimeOptions, |
| 8 | PluginRuntimeSnapshot, | 14 | PluginRuntimeSnapshot, |
| 9 | } from "../src/contract.ts"; | 15 | } from "../src/contract.ts"; |
| 10 | | 16 | |
| 11 | const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) => | 17 | type TestPluginRuntimeFactory = ( |
| 12 | snapshot.contributions[slot]?.map((item) => item.label) ?? []; | 18 | options?: PluginRuntimeOptions, |
| | 19 | ) => Effect.Effect<PluginRuntime["Service"], never, Scope.Scope>; |
| | 20 | |
| | 21 | const makeEffectCallback = <A, E, R = never>() => { |
| | 22 | let unsafeResume: (effect: Effect.Effect<A, E, R>) => void = () => { |
| | 23 | throw new Error("effect callback has not started"); |
| | 24 | }; |
| | 25 | return { |
| | 26 | await: Effect.callback<A, E, R>((resume) => { |
| | 27 | unsafeResume = resume; |
| | 28 | }), |
| | 29 | resume: (effect: Effect.Effect<A, E, R>) => unsafeResume(effect), |
| | 30 | } as const; |
| | 31 | }; |
| 13 | | 32 | |
| 14 | const withOperationTimeout = async <Result>(operation: Promise<Result>): Promise<Result> => { | 33 | const failureOf = <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 15 | const controller = new AbortController(); | 34 | Effect.exit(effect).pipe( |
| 16 | const timeout = NodeTimersPromises.setTimeout(100, undefined, { | 35 | Effect.map((exit) => (Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined)), |
| 17 | ref: false, | 36 | ); |
| 18 | signal: controller.signal, | 37 | |
| 19 | }).then(() => { | 38 | const settlePromise = ( |
| 20 | throw new Error("operation timed out"); | 39 | exit: Exit.Exit<unknown, unknown>, |
| 21 | }); | 40 | resolve: () => void, |
| 22 | try { | 41 | reject: (error: unknown) => void, |
| 23 | return await Promise.race([operation, timeout]); | 42 | ) => { |
| 24 | } finally { | 43 | if (Exit.isFailure(exit)) reject(Cause.squash(exit.cause)); |
| 25 | controller.abort(); | 44 | else resolve(); |
| 26 | } | | |
| 27 | }; | 45 | }; |
| 28 | | 46 | |
| | 47 | const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) => |
| | 48 | snapshot.contributions[slot]?.map((item) => item.label) ?? []; |
| | 49 | |
| 29 | const failureCause = (error: unknown): unknown => | 50 | const failureCause = (error: unknown): unknown => |
| 30 | typeof error === "object" && error !== null && "cause" in error | 51 | typeof error === "object" && error !== null && "cause" in error |
| 31 | ? (error as { readonly cause: unknown }).cause | 52 | ? (error as { readonly cause: unknown }).cause |
| @@ -55,814 +76,982 @@ const consumer = (): PluginDefinition => ({ · 23 unmodified lines |
| 55 | }, | 76 | }, |
| 56 | }); | 77 | }); |
| 57 | | 78 | |
| 58 | export function defineRuntimeContract(name: string, createRuntime: PluginRuntimeFactory) { | 79 | export function defineRuntimeContract(name: string, createRuntime: TestPluginRuntimeFactory) { |
| 59 | describe(name, () => { | 80 | describe(name, () => { |
| 60 | it("activates providers before consumers regardless of manifest order", async () => { | 81 | it.effect("activates providers before consumers regardless of manifest order", () => |
| 61 | const runtime = createRuntime(); | 82 | Effect.scoped( |
| 62 | | 83 | Effect.gen(function* () { |
| 63 | const snapshot = await runtime.reconcile([consumer(), provider()]); | 84 | const runtime = yield* createRuntime(); |
| 64 | | 85 | |
| 65 | expect(snapshot.active).toEqual(["acme.database", "acme.issues"]); | 86 | const snapshot = yield* runtime.reconcile([consumer(), provider()]); |
| 66 | expect(contributionLabels(snapshot, "commands")).toEqual(["database-1.0.0"]); | 87 | |
| 67 | await runtime.dispose(); | 88 | expect(snapshot.active).toEqual(["acme.database", "acme.issues"]); |
| 68 | }); | 89 | expect(contributionLabels(snapshot, "commands")).toEqual(["database-1.0.0"]); |
| 69 | | 90 | yield* runtime.dispose; |
| 70 | it("blocks missing dependencies without blocking independent plugins", async () => { | 91 | }), |
| 71 | const runtime = createRuntime(); | 92 | ), |
| 72 | const independent: PluginDefinition = { | 93 | ); |
| 73 | id: "acme.clock", | 94 | |
| 74 | version: "1.0.0", | 95 | it.effect("blocks missing dependencies without blocking independent plugins", () => |
| 75 | activate(context) { | 96 | Effect.scoped( |
| 76 | context.register("status", { id: "clock", label: "clock" }); | 97 | Effect.gen(function* () { |
| 77 | }, | 98 | const runtime = yield* createRuntime(); |
| 78 | }; | 99 | const independent: PluginDefinition = { |
| 79 | | 100 | id: "acme.clock", |
| 80 | const snapshot = await runtime.reconcile([consumer(), independent]); | 101 | version: "1.0.0", |
| 81 | | 102 | activate(context) { |
| 82 | expect(snapshot.active).toEqual(["acme.clock"]); | 103 | context.register("status", { id: "clock", label: "clock" }); |
| 83 | expect(snapshot.blocked["acme.issues"]).toContain("acme.database@1"); | 104 | }, |
| 84 | expect(contributionLabels(snapshot, "status")).toEqual(["clock"]); | 105 | }; |
| 85 | await runtime.dispose(); | 106 | |
| 86 | }); | 107 | const snapshot = yield* runtime.reconcile([consumer(), independent]); |
| 87 | | 108 | |
| 88 | it("deactivates dependents before providers", async () => { | 109 | expect(snapshot.active).toEqual(["acme.clock"]); |
| 89 | const lifecycle: Array<string> = []; | 110 | expect(snapshot.blocked["acme.issues"]).toContain("acme.database@1"); |
| 90 | const runtime = createRuntime({ | 111 | expect(contributionLabels(snapshot, "status")).toEqual(["clock"]); |
| 91 | onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), | 112 | yield* runtime.dispose; |
| 92 | }); | 113 | }), |
| 93 | await runtime.reconcile([provider(), consumer()]); | 114 | ), |
| 94 | lifecycle.length = 0; | 115 | ); |
| 95 | | 116 | |
| 96 | await runtime.reconcile([]); | 117 | it.effect("deactivates dependents before providers", () => |
| 97 | | 118 | Effect.scoped( |
| 98 | expect(lifecycle).toEqual(["deactivate:acme.issues", "deactivate:acme.database"]); | 119 | Effect.gen(function* () { |
| 99 | await runtime.dispose(); | 120 | const lifecycle: Array<string> = []; |
| 100 | }); | 121 | const runtime = yield* createRuntime({ |
| 101 | | 122 | onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), |
| 102 | it("runs plugin finalizers in reverse registration order", async () => { | | |
| 103 | const disposed: Array<string> = []; | | |
| 104 | const runtime = createRuntime(); | | |
| 105 | await runtime.reconcile([ | | |
| 106 | { | | |
| 107 | id: "acme.finalizers", | | |
| 108 | version: "1.0.0", | | |
| 109 | activate(context) { | | |
| 110 | context.onDispose(() => { | | |
| 111 | disposed.push("first"); | | |
| 112 | }); | | |
| 113 | context.onDispose(() => { | | |
| 114 | disposed.push("second"); | | |
| 115 | }); | | |
| 116 | }, | | |
| 117 | }, | | |
| 118 | ]); | | |
| 119 | | | |
| 120 | await runtime.reconcile([]); | | |
| 121 | | | |
| 122 | expect(disposed).toEqual(["second", "first"]); | | |
| 123 | await runtime.dispose(); | | |
| 124 | }); | | |
| 125 | | | |
| 126 | it("keeps unchanged plugin scopes alive across reconciliation", async () => { | | |
| 127 | let activations = 0; | | |
| 128 | let disposals = 0; | | |
| 129 | const stable: PluginDefinition = { | | |
| 130 | id: "acme.stable", | | |
| 131 | version: "1.0.0", | | |
| 132 | activate(context) { | | |
| 133 | activations += 1; | | |
| 134 | context.onDispose(() => { | | |
| 135 | disposals += 1; | | |
| 136 | }); | | |
| 137 | }, | | |
| 138 | }; | | |
| 139 | const runtime = createRuntime(); | | |
| 140 | | | |
| 141 | await runtime.reconcile([stable]); | | |
| 142 | await runtime.reconcile([stable]); | | |
| 143 | | | |
| 144 | expect(activations).toBe(1); | | |
| 145 | expect(disposals).toBe(0); | | |
| 146 | await runtime.dispose(); | | |
| 147 | expect(disposals).toBe(1); | | |
| 148 | }); | | |
| 149 | | | |
| 150 | it("treats reordered requirements and providers as the same definition", async () => { | | |
| 151 | let activations = 0; | | |
| 152 | const firstService = {}; | | |
| 153 | const secondService = {}; | | |
| 154 | const activate: PluginDefinition["activate"] = () => { | | |
| 155 | activations += 1; | | |
| 156 | }; | | |
| 157 | const dependencies: ReadonlyArray<PluginDefinition> = [ | | |
| 158 | { | | |
| 159 | id: "acme.first-dependency", | | |
| 160 | version: "1.0.0", | | |
| 161 | provides: { "acme.first@1": true }, | | |
| 162 | activate() {}, | | |
| 163 | }, | | |
| 164 | { | | |
| 165 | id: "acme.second-dependency", | | |
| 166 | version: "1.0.0", | | |
| 167 | provides: { "acme.second@1": true }, | | |
| 168 | activate() {}, | | |
| 169 | }, | | |
| 170 | ]; | | |
| 171 | const runtime = createRuntime(); | | |
| 172 | await runtime.reconcile([ | | |
| 173 | ...dependencies, | | |
| 174 | { | | |
| 175 | id: "acme.stable-order", | | |
| 176 | version: "1.0.0", | | |
| 177 | requires: ["acme.first@1", "acme.second@1"], | | |
| 178 | provides: { "acme.output-one@1": firstService, "acme.output-two@1": secondService }, | | |
| 179 | activate, | | |
| 180 | }, | | |
| 181 | ]); | | |
| 182 | | | |
| 183 | await runtime.reconcile([ | | |
| 184 | ...dependencies, | | |
| 185 | { | | |
| 186 | id: "acme.stable-order", | | |
| 187 | version: "1.0.0", | | |
| 188 | requires: ["acme.second@1", "acme.first@1"], | | |
| 189 | provides: { "acme.output-two@1": secondService, "acme.output-one@1": firstService }, | | |
| 190 | activate, | | |
| 191 | }, | | |
| 192 | ]); | | |
| 193 | | | |
| 194 | expect(activations).toBe(1); | | |
| 195 | await runtime.dispose(); | | |
| 196 | }); | | |
| 197 | | | |
| 198 | it("restarts a plugin when its activation implementation changes", async () => { | | |
| 199 | const runtime = createRuntime(); | | |
| 200 | const first: PluginDefinition = { | | |
| 201 | id: "acme.implementation", | | |
| 202 | version: "1.0.0", | | |
| 203 | activate(context) { | | |
| 204 | context.register("commands", { id: "implementation", label: "first" }); | | |
| 205 | }, | | |
| 206 | }; | | |
| 207 | const second: PluginDefinition = { | | |
| 208 | id: "acme.implementation", | | |
| 209 | version: "1.0.0", | | |
| 210 | activate(context) { | | |
| 211 | context.register("commands", { id: "implementation", label: "second" }); | | |
| 212 | }, | | |
| 213 | }; | | |
| 214 | | | |
| 215 | await runtime.reconcile([first]); | | |
| 216 | const snapshot = await runtime.reconcile([second]); | | |
| 217 | | | |
| 218 | expect(contributionLabels(snapshot, "commands")).toEqual(["second"]); | | |
| 219 | await runtime.dispose(); | | |
| 220 | }); | | |
| 221 | | | |
| 222 | it("restarts a changed provider and its dependents without touching independent plugins", async () => { | | |
| 223 | let independentActivations = 0; | | |
| 224 | const lifecycle: Array<string> = []; | | |
| 225 | const independent: PluginDefinition = { | | |
| 226 | id: "acme.independent", | | |
| 227 | version: "1.0.0", | | |
| 228 | activate() { | | |
| 229 | independentActivations += 1; | | |
| 230 | }, | | |
| 231 | }; | | |
| 232 | const runtime = createRuntime({ | | |
| 233 | onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), | | |
| 234 | }); | | |
| 235 | await runtime.reconcile([consumer(), independent, provider()]); | | |
| 236 | lifecycle.length = 0; | | |
| 237 | | | |
| 238 | const snapshot = await runtime.reconcile([consumer(), independent, provider("2.0.0")]); | | |
| 239 | | | |
| 240 | expect(independentActivations).toBe(1); | | |
| 241 | expect(lifecycle).not.toContain("activate:acme.independent"); | | |
| 242 | expect(lifecycle).not.toContain("deactivate:acme.independent"); | | |
| 243 | expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([ | | |
| 244 | "activate:acme.database", | | |
| 245 | "activate:acme.issues", | | |
| 246 | ]); | | |
| 247 | expect(lifecycle.filter((event) => event.startsWith("deactivate:"))).toEqual([ | | |
| 248 | "deactivate:acme.issues", | | |
| 249 | "deactivate:acme.database", | | |
| 250 | ]); | | |
| 251 | expect(contributionLabels(snapshot, "commands")).toEqual(["database-2.0.0"]); | | |
| 252 | await runtime.dispose(); | | |
| 253 | }); | | |
| 254 | | | |
| 255 | it("returns deeply frozen snapshots", async () => { | | |
| 256 | const runtime = createRuntime(); | | |
| 257 | | | |
| 258 | const snapshot = await runtime.reconcile([provider()]); | | |
| 259 | const status = snapshot.contributions.status; | | |
| 260 | | | |
| 261 | expect(Object.isFrozen(snapshot)).toBe(true); | | |
| 262 | expect(Object.isFrozen(snapshot.active)).toBe(true); | | |
| 263 | expect(Object.isFrozen(snapshot.blocked)).toBe(true); | | |
| 264 | expect(Object.isFrozen(snapshot.contributions)).toBe(true); | | |
| 265 | expect(Object.isFrozen(status)).toBe(true); | | |
| 266 | expect(Object.isFrozen(status?.[0])).toBe(true); | | |
| 267 | await runtime.dispose(); | | |
| 268 | }); | | |
| 269 | | | |
| 270 | it("detaches registered contributions from later plugin mutation", async () => { | | |
| 271 | const contribution = { id: "mutable", label: "original" }; | | |
| 272 | const plugin: PluginDefinition = { | | |
| 273 | id: "acme.mutable-contribution", | | |
| 274 | version: "1.0.0", | | |
| 275 | activate(context) { | | |
| 276 | context.register("commands", contribution); | | |
| 277 | }, | | |
| 278 | }; | | |
| 279 | const unrelated: PluginDefinition = { | | |
| 280 | id: "acme.unrelated-contribution", | | |
| 281 | version: "1.0.0", | | |
| 282 | activate() {}, | | |
| 283 | }; | | |
| 284 | const runtime = createRuntime(); | | |
| 285 | await runtime.reconcile([plugin]); | | |
| 286 | | | |
| 287 | contribution.label = "changed"; | | |
| 288 | const snapshot = await runtime.reconcile([plugin, unrelated]); | | |
| 289 | | | |
| 290 | expect(contributionLabels(snapshot, "commands")).toEqual(["original"]); | | |
| 291 | await runtime.dispose(); | | |
| 292 | }); | | |
| 293 | | | |
| 294 | it("treats every plugin id and contribution slot as data", async () => { | | |
| 295 | const runtime = createRuntime(); | | |
| 296 | const unusualProvider: PluginDefinition = { | | |
| 297 | id: "toString", | | |
| 298 | version: "1.0.0", | | |
| 299 | provides: { "acme.unusual@1": "available" }, | | |
| 300 | activate(context) { | | |
| 301 | context.register("__proto__", { id: "provider", label: "provider" }); | | |
| 302 | }, | | |
| 303 | }; | | |
| 304 | const unusualConsumer: PluginDefinition = { | | |
| 305 | id: "constructor", | | |
| 306 | version: "1.0.0", | | |
| 307 | requires: ["acme.unusual@1"], | | |
| 308 | activate(context) { | | |
| 309 | context.resolve("acme.unusual@1"); | | |
| 310 | context.register("toString", { id: "consumer", label: "consumer" }); | | |
| 311 | }, | | |
| 312 | }; | | |
| 313 | const unusualBlocked: PluginDefinition = { | | |
| 314 | id: "__proto__", | | |
| 315 | version: "1.0.0", | | |
| 316 | requires: ["acme.missing@1"], | | |
| 317 | activate() { | | |
| 318 | throw new Error("blocked plugin activated"); | | |
| 319 | }, | | |
| 320 | }; | | |
| 321 | | | |
| 322 | const snapshot = await runtime.reconcile([unusualConsumer, unusualProvider, unusualBlocked]); | | |
| 323 | | | |
| 324 | expect(snapshot.active).toEqual(["toString", "constructor"]); | | |
| 325 | expect(Object.hasOwn(snapshot.blocked, "__proto__")).toBe(true); | | |
| 326 | expect(snapshot.blocked.__proto__).toContain("acme.missing@1"); | | |
| 327 | expect(Object.hasOwn(snapshot.contributions, "__proto__")).toBe(true); | | |
| 328 | expect(Object.hasOwn(snapshot.contributions, "toString")).toBe(true); | | |
| 329 | expect(contributionLabels(snapshot, "__proto__")).toEqual(["provider"]); | | |
| 330 | expect(contributionLabels(snapshot, "toString")).toEqual(["consumer"]); | | |
| 331 | await runtime.dispose(); | | |
| 332 | }); | | |
| 333 | | | |
| 334 | it("restarts dependents when a provided value changes", async () => { | | |
| 335 | const lifecycle: Array<string> = []; | | |
| 336 | const runtime = createRuntime({ | | |
| 337 | onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), | | |
| 338 | }); | | |
| 339 | const serviceProvider = (name: string): PluginDefinition => ({ | | |
| 340 | id: "acme.service", | | |
| 341 | version: "1.0.0", | | |
| 342 | provides: { "acme.service@1": { name } }, | | |
| 343 | activate() {}, | | |
| 344 | }); | | |
| 345 | const serviceConsumer: PluginDefinition = { | | |
| 346 | id: "acme.service-consumer", | | |
| 347 | version: "1.0.0", | | |
| 348 | requires: ["acme.service@1"], | | |
| 349 | activate(context) { | | |
| 350 | const service = context.resolve<{ readonly name: string }>("acme.service@1"); | | |
| 351 | context.register("commands", { id: "service", label: service.name }); | | |
| 352 | }, | | |
| 353 | }; | | |
| 354 | await runtime.reconcile([serviceProvider("first"), serviceConsumer]); | | |
| 355 | lifecycle.length = 0; | | |
| 356 | | | |
| 357 | const snapshot = await runtime.reconcile([serviceProvider("second"), serviceConsumer]); | | |
| 358 | | | |
| 359 | expect(contributionLabels(snapshot, "commands")).toEqual(["second"]); | | |
| 360 | expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([ | | |
| 361 | "activate:acme.service", | | |
| 362 | "activate:acme.service-consumer", | | |
| 363 | ]); | | |
| 364 | await runtime.dispose(); | | |
| 365 | }); | | |
| 366 | | | |
| 367 | it("does not report a rolled-back candidate as activated", async () => { | | |
| 368 | const lifecycle: Array<string> = []; | | |
| 369 | const runtime = createRuntime({ | | |
| 370 | onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), | | |
| 371 | }); | | |
| 372 | const staged: PluginDefinition = { | | |
| 373 | id: "acme.staged", | | |
| 374 | version: "1.0.0", | | |
| 375 | activate() {}, | | |
| 376 | }; | | |
| 377 | const broken: PluginDefinition = { | | |
| 378 | id: "acme.broken", | | |
| 379 | version: "1.0.0", | | |
| 380 | activate() { | | |
| 381 | throw new Error("activation failed"); | | |
| 382 | }, | | |
| 383 | }; | | |
| 384 | | | |
| 385 | let stagingFailure: unknown; | | |
| 386 | try { | | |
| 387 | await runtime.reconcile([staged, broken]); | | |
| 388 | } catch (error) { | | |
| 389 | stagingFailure = error; | | |
| 390 | } | | |
| 391 | expect(failureMessage(stagingFailure)).toContain("activation failed"); | | |
| 392 | | | |
| 393 | expect(lifecycle).toEqual([]); | | |
| 394 | await runtime.dispose(); | | |
| 395 | }); | | |
| 396 | | | |
| 397 | it("preserves activation failures when rollback cleanup also fails", async () => { | | |
| 398 | const activationError = new Error("activation failed"); | | |
| 399 | const cleanupError = new Error("rollback cleanup failed"); | | |
| 400 | const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; | | |
| 401 | const runtime = createRuntime({ | | |
| 402 | onCleanupError: (event) => { | | |
| 403 | cleanupEvents.push(event); | | |
| 404 | throw new Error("cleanup observer failed"); | | |
| 405 | }, | | |
| 406 | }); | | |
| 407 | const broken: PluginDefinition = { | | |
| 408 | id: "acme.rollback-error", | | |
| 409 | version: "1.0.0", | | |
| 410 | activate(context) { | | |
| 411 | context.onDispose(() => { | | |
| 412 | throw cleanupError; | | |
| 413 | }); | 123 | }); |
| 414 | throw activationError; | 124 | yield* runtime.reconcile([provider(), consumer()]); |
| 415 | }, | 125 | lifecycle.length = 0; |
| 416 | }; | 126 | |
| 417 | | 127 | yield* runtime.reconcile([]); |
| 418 | let activationFailure: unknown; | 128 | |
| 419 | try { | 129 | expect(lifecycle).toEqual(["deactivate:acme.issues", "deactivate:acme.database"]); |
| 420 | await runtime.reconcile([broken]); | 130 | yield* runtime.dispose; |
| 421 | } catch (error) { | 131 | }), |
| 422 | activationFailure = error; | 132 | ), |
| 423 | } | 133 | ); |
| 424 | expect(failureCause(activationFailure)).toBe(activationError); | 134 | |
| 425 | expect(cleanupEvents).toHaveLength(1); | 135 | it.effect("runs plugin finalizers in reverse registration order", () => |
| 426 | expect(cleanupEvents[0]?.phase).toBe("rollback"); | 136 | Effect.scoped( |
| 427 | expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError); | 137 | Effect.gen(function* () { |
| 428 | await runtime.dispose(); | 138 | const disposed: Array<string> = []; |
| 429 | }); | 139 | const runtime = yield* createRuntime(); |
| 430 | | 140 | yield* runtime.reconcile([ |
| 431 | it("returns the committed snapshot when retiring an old plugin reports cleanup errors", async () => { | 141 | { |
| 432 | const cleanupError = new Error("retirement cleanup failed"); | 142 | id: "acme.finalizers", |
| 433 | const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; | 143 | version: "1.0.0", |
| 434 | const runtime = createRuntime({ | 144 | activate(context) { |
| 435 | onCleanupError: (event) => { | | |
| 436 | cleanupEvents.push(event); | | |
| 437 | throw new Error("cleanup observer failed"); | | |
| 438 | }, | | |
| 439 | }); | | |
| 440 | await runtime.reconcile([ | | |
| 441 | { | | |
| 442 | id: "acme.retirement-error", | | |
| 443 | version: "1.0.0", | | |
| 444 | activate(context) { | | |
| 445 | context.register("commands", { id: "retirement", label: "old" }); | | |
| 446 | context.onDispose(() => { | | |
| 447 | throw cleanupError; | | |
| 448 | }); | | |
| 449 | }, | | |
| 450 | }, | | |
| 451 | ]); | | |
| 452 | | | |
| 453 | const snapshot = await runtime.reconcile([ | | |
| 454 | { | | |
| 455 | id: "acme.retirement-error", | | |
| 456 | version: "2.0.0", | | |
| 457 | activate(context) { | | |
| 458 | context.register("commands", { id: "retirement", label: "new" }); | | |
| 459 | }, | | |
| 460 | }, | | |
| 461 | ]); | | |
| 462 | | | |
| 463 | expect(contributionLabels(snapshot, "commands")).toEqual(["new"]); | | |
| 464 | expect(runtime.snapshot()).toBe(snapshot); | | |
| 465 | expect(cleanupEvents).toHaveLength(1); | | |
| 466 | expect(cleanupEvents[0]?.phase).toBe("retire"); | | |
| 467 | expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError); | | |
| 468 | await runtime.dispose(); | | |
| 469 | }); | | |
| 470 | | | |
| 471 | it("does not let lifecycle observers interrupt a committed transition", async () => { | | |
| 472 | let oldDisposed = false; | | |
| 473 | const observerErrors: Array<string> = []; | | |
| 474 | const runtime = createRuntime({ | | |
| 475 | onLifecycle: ({ phase, pluginId }) => { | | |
| 476 | throw new Error(`${phase}:${pluginId}`); | | |
| 477 | }, | | |
| 478 | onLifecycleError: ({ phase, pluginId }) => { | | |
| 479 | observerErrors.push(`${phase}:${pluginId}`); | | |
| 480 | }, | | |
| 481 | }); | | |
| 482 | await runtime.reconcile([ | | |
| 483 | { | | |
| 484 | id: "acme.lifecycle-observer", | | |
| 485 | version: "1.0.0", | | |
| 486 | activate(context) { | | |
| 487 | context.onDispose(() => { | | |
| 488 | oldDisposed = true; | | |
| 489 | }); | | |
| 490 | }, | | |
| 491 | }, | | |
| 492 | ]); | | |
| 493 | | | |
| 494 | const snapshot = await runtime.reconcile([ | | |
| 495 | { | | |
| 496 | id: "acme.lifecycle-observer", | | |
| 497 | version: "2.0.0", | | |
| 498 | activate(context) { | | |
| 499 | context.register("commands", { id: "observer", label: "committed" }); | | |
| 500 | }, | | |
| 501 | }, | | |
| 502 | ]); | | |
| 503 | | | |
| 504 | expect(oldDisposed).toBe(true); | | |
| 505 | expect(contributionLabels(snapshot, "commands")).toEqual(["committed"]); | | |
| 506 | expect(observerErrors).toEqual([ | | |
| 507 | "activate:acme.lifecycle-observer", | | |
| 508 | "activate:acme.lifecycle-observer", | | |
| 509 | "deactivate:acme.lifecycle-observer", | | |
| 510 | ]); | | |
| 511 | await runtime.dispose(); | | |
| 512 | }); | | |
| 513 | | | |
| 514 | it("rejects runtime operations reentered from plugin activation", async () => { | | |
| 515 | const runtime = createRuntime(); | | |
| 516 | const reentrant: PluginDefinition = { | | |
| 517 | id: "acme.reentrant", | | |
| 518 | version: "1.0.0", | | |
| 519 | async activate() { | | |
| 520 | await runtime.reconcile([]); | | |
| 521 | }, | | |
| 522 | }; | | |
| 523 | await expect(withOperationTimeout(runtime.reconcile([reentrant]))).rejects.toThrow( | | |
| 524 | "reentrant", | | |
| 525 | ); | | |
| 526 | }); | | |
| 527 | | | |
| 528 | it("rejects runtime operations reentered from plugin finalizers", async () => { | | |
| 529 | let nestedFailure: unknown; | | |
| 530 | const runtime = createRuntime(); | | |
| 531 | await runtime.reconcile([ | | |
| 532 | { | | |
| 533 | id: "acme.reentrant-finalizer", | | |
| 534 | version: "1.0.0", | | |
| 535 | activate(context) { | | |
| 536 | context.onDispose(async () => { | | |
| 537 | try { | | |
| 538 | await runtime.dispose(); | | |
| 539 | } catch (error) { | | |
| 540 | nestedFailure = error; | | |
| 541 | } | | |
| 542 | }); | | |
| 543 | }, | | |
| 544 | }, | | |
| 545 | ]); | | |
| 546 | await expect(withOperationTimeout(runtime.reconcile([]))).resolves.toBeDefined(); | | |
| 547 | expect(nestedFailure).toBeInstanceOf(Error); | | |
| 548 | expect((nestedFailure as Error).message).toContain("reentrant"); | | |
| 549 | await runtime.dispose(); | | |
| 550 | }); | | |
| 551 | | | |
| 552 | it("allows descendant tasks to use the runtime after their plugin callback settles", async () => { | | |
| 553 | let releaseBackground!: () => void; | | |
| 554 | const gate = new Promise<void>((resolve) => { | | |
| 555 | releaseBackground = resolve; | | |
| 556 | }); | | |
| 557 | let backgroundResult: Promise<PluginRuntimeSnapshot> | undefined; | | |
| 558 | const runtime = createRuntime(); | | |
| 559 | | | |
| 560 | await runtime.reconcile([ | | |
| 561 | { | | |
| 562 | id: "acme.background-task", | | |
| 563 | version: "1.0.0", | | |
| 564 | activate() { | | |
| 565 | backgroundResult = gate.then(() => runtime.reconcile([])); | | |
| 566 | }, | | |
| 567 | }, | | |
| 568 | ]); | | |
| 569 | | | |
| 570 | releaseBackground(); | | |
| 571 | await expect(withOperationTimeout(backgroundResult!)).resolves.toBeDefined(); | | |
| 572 | await runtime.dispose(); | | |
| 573 | }); | | |
| 574 | | | |
| 575 | it("allows microtasks spawned by synchronous plugin callbacks to use the runtime", async () => { | | |
| 576 | let backgroundResult: Promise<PluginRuntimeSnapshot> | undefined; | | |
| 577 | const runtime = createRuntime(); | | |
| 578 | | | |
| 579 | await runtime.reconcile([ | | |
| 580 | { | | |
| 581 | id: "acme.microtask", | | |
| 582 | version: "1.0.0", | | |
| 583 | activate() { | | |
| 584 | queueMicrotask(() => { | | |
| 585 | backgroundResult = runtime.reconcile([]); | | |
| 586 | }); | | |
| 587 | }, | | |
| 588 | }, | | |
| 589 | ]); | | |
| 590 | | | |
| 591 | expect(backgroundResult).toBeDefined(); | | |
| 592 | await expect(withOperationTimeout(backgroundResult!)).resolves.toBeDefined(); | | |
| 593 | await runtime.dispose(); | | |
| 594 | }); | | |
| 595 | | | |
| 596 | it("allows microtasks spawned by synchronous callbacks that return plain functions", async () => { | | |
| 597 | let backgroundResult: Promise<PluginRuntimeSnapshot> | undefined; | | |
| 598 | const runtime = createRuntime(); | | |
| 599 | const activate = (() => { | | |
| 600 | queueMicrotask(() => { | | |
| 601 | backgroundResult = runtime.reconcile([]); | | |
| 602 | }); | | |
| 603 | return () => undefined; | | |
| 604 | }) as unknown as PluginDefinition["activate"]; | | |
| 605 | | | |
| 606 | await runtime.reconcile([ | | |
| 607 | { | | |
| 608 | id: "acme.function-return", | | |
| 609 | version: "1.0.0", | | |
| 610 | activate, | | |
| 611 | }, | | |
| 612 | ]); | | |
| 613 | | | |
| 614 | expect(backgroundResult).toBeDefined(); | | |
| 615 | await expect(withOperationTimeout(backgroundResult!)).resolves.toBeDefined(); | | |
| 616 | await runtime.dispose(); | | |
| 617 | }); | | |
| 618 | | | |
| 619 | it("rejects activation-context calls from microtasks queued by synchronous callbacks", async () => { | | |
| 620 | let lateFailure: unknown; | | |
| 621 | let disposed = false; | | |
| 622 | const runtime = createRuntime(); | | |
| 623 | | | |
| 624 | await runtime.reconcile([ | | |
| 625 | { | | |
| 626 | id: "acme.microtask-context", | | |
| 627 | version: "1.0.0", | | |
| 628 | activate(context) { | | |
| 629 | queueMicrotask(() => { | | |
| 630 | try { | | |
| 631 | context.onDispose(() => { | 145 | context.onDispose(() => { |
| 632 | disposed = true; | 146 | disposed.push("first"); |
| 633 | }); | 147 | }); |
| 634 | } catch (error) { | 148 | context.onDispose(() => { |
| 635 | lateFailure = error; | 149 | disposed.push("second"); |
| 636 | } | 150 | }); |
| 637 | }); | 151 | }, |
| 638 | }, | 152 | }, |
| 639 | }, | 153 | ]); |
| 640 | ]); | 154 | |
| 641 | | 155 | yield* runtime.reconcile([]); |
| 642 | expect(lateFailure).toBeInstanceOf(Error); | 156 | |
| 643 | expect((lateFailure as Error).message).toContain("no longer active"); | 157 | expect(disposed).toEqual(["second", "first"]); |
| 644 | await runtime.dispose(); | 158 | yield* runtime.dispose; |
| 645 | expect(disposed).toBe(false); | 159 | }), |
| 646 | }); | 160 | ), |
| 647 | | 161 | ); |
| 648 | it("rejects finalizers registered after activation settles", async () => { | 162 | |
| 649 | let registerLateFinalizer!: () => void; | 163 | it.effect("keeps unchanged plugin scopes alive across reconciliation", () => |
| 650 | let disposed = false; | 164 | Effect.scoped( |
| 651 | const runtime = createRuntime(); | 165 | Effect.gen(function* () { |
| 652 | await runtime.reconcile([ | 166 | let activations = 0; |
| 653 | { | 167 | let disposals = 0; |
| 654 | id: "acme.late-finalizer", | 168 | const stable: PluginDefinition = { |
| 655 | version: "1.0.0", | 169 | id: "acme.stable", |
| 656 | activate(context) { | 170 | version: "1.0.0", |
| 657 | registerLateFinalizer = () => | 171 | activate(context) { |
| | 172 | activations += 1; |
| 658 | context.onDispose(() => { | 173 | context.onDispose(() => { |
| 659 | disposed = true; | 174 | disposals += 1; |
| 660 | }); | 175 | }); |
| 661 | }, | 176 | }, |
| 662 | }, | 177 | }; |
| 663 | ]); | 178 | const runtime = yield* createRuntime(); |
| 664 | | 179 | |
| 665 | expect(registerLateFinalizer).toThrow("no longer active"); | 180 | yield* runtime.reconcile([stable]); |
| 666 | await runtime.dispose(); | 181 | yield* runtime.reconcile([stable]); |
| 667 | expect(disposed).toBe(false); | 182 | |
| 668 | }); | 183 | expect(activations).toBe(1); |
| 669 | | 184 | expect(disposals).toBe(0); |
| 670 | it("snapshots the requested definitions before waiting for an earlier transition", async () => { | 185 | yield* runtime.dispose; |
| 671 | let releaseActivation!: () => void; | 186 | expect(disposals).toBe(1); |
| 672 | const activationGate = new Promise<void>((resolve) => { | 187 | }), |
| 673 | releaseActivation = resolve; | 188 | ), |
| 674 | }); | 189 | ); |
| 675 | const runtime = createRuntime(); | 190 | |
| 676 | const first = runtime.reconcile([ | 191 | it.effect("treats reordered requirements and providers as the same definition", () => |
| 677 | { | 192 | Effect.scoped( |
| 678 | id: "acme.blocking", | 193 | Effect.gen(function* () { |
| 679 | version: "1.0.0", | 194 | let activations = 0; |
| 680 | async activate() { | 195 | const firstService = {}; |
| 681 | await activationGate; | 196 | const secondService = {}; |
| 682 | }, | 197 | const activate: PluginDefinition["activate"] = () => { |
| 683 | }, | 198 | activations += 1; |
| 684 | ]); | 199 | }; |
| 685 | const requested: Array<PluginDefinition> = [provider()]; | 200 | const dependencies: ReadonlyArray<PluginDefinition> = [ |
| 686 | const second = runtime.reconcile(requested); | 201 | { |
| 687 | requested.splice(0); | 202 | id: "acme.first-dependency", |
| 688 | | 203 | version: "1.0.0", |
| 689 | releaseActivation(); | 204 | provides: { "acme.first@1": true }, |
| 690 | await first; | 205 | activate() {}, |
| 691 | const snapshot = await second; | 206 | }, |
| 692 | | 207 | { |
| 693 | expect(snapshot.active).toEqual(["acme.database"]); | 208 | id: "acme.second-dependency", |
| 694 | await runtime.dispose(); | 209 | version: "1.0.0", |
| 695 | }); | 210 | provides: { "acme.second@1": true }, |
| 696 | | 211 | activate() {}, |
| 697 | it("snapshots each requested definition and its declarations before queueing", async () => { | 212 | }, |
| 698 | let releaseActivation!: () => void; | 213 | ]; |
| 699 | const activationGate = new Promise<void>((resolve) => { | 214 | const runtime = yield* createRuntime(); |
| 700 | releaseActivation = resolve; | 215 | yield* runtime.reconcile([ |
| 701 | }); | 216 | ...dependencies, |
| 702 | const runtime = createRuntime(); | 217 | { |
| 703 | const first = runtime.reconcile([ | 218 | id: "acme.stable-order", |
| 704 | { | 219 | version: "1.0.0", |
| 705 | id: "acme.blocking", | 220 | requires: ["acme.first@1", "acme.second@1"], |
| 706 | version: "1.0.0", | 221 | provides: { "acme.output-one@1": firstService, "acme.output-two@1": secondService }, |
| 707 | async activate() { | 222 | activate, |
| 708 | await activationGate; | 223 | }, |
| 709 | }, | 224 | ]); |
| 710 | }, | 225 | |
| 711 | ]); | 226 | yield* runtime.reconcile([ |
| 712 | const requires = ["acme.database@1"]; | 227 | ...dependencies, |
| 713 | const provides: Record<string, unknown> = { "acme.consumer@1": true }; | 228 | { |
| 714 | const requestedDefinition: PluginDefinition = { | 229 | id: "acme.stable-order", |
| 715 | id: "acme.consumer", | 230 | version: "1.0.0", |
| 716 | version: "1.0.0", | 231 | requires: ["acme.second@1", "acme.first@1"], |
| 717 | requires, | 232 | provides: { "acme.output-two@1": secondService, "acme.output-one@1": firstService }, |
| 718 | provides, | 233 | activate, |
| 719 | activate() {}, | 234 | }, |
| 720 | }; | 235 | ]); |
| 721 | const second = runtime.reconcile([provider(), requestedDefinition]); | 236 | |
| 722 | | 237 | expect(activations).toBe(1); |
| 723 | (requestedDefinition as { id: string }).id = "acme.changed"; | 238 | yield* runtime.dispose; |
| 724 | requires[0] = "acme.missing@1"; | 239 | }), |
| 725 | delete provides["acme.consumer@1"]; | 240 | ), |
| 726 | | 241 | ); |
| 727 | releaseActivation(); | 242 | |
| 728 | await first; | 243 | it.effect("restarts a plugin when its activation implementation changes", () => |
| 729 | const snapshot = await second; | 244 | Effect.scoped( |
| 730 | | 245 | Effect.gen(function* () { |
| 731 | expect(snapshot.active).toEqual(["acme.database", "acme.consumer"]); | 246 | const runtime = yield* createRuntime(); |
| 732 | await runtime.dispose(); | 247 | const first: PluginDefinition = { |
| 733 | }); | 248 | id: "acme.implementation", |
| 734 | | 249 | version: "1.0.0", |
| 735 | it("rejects capabilities that the plugin did not declare", async () => { | 250 | activate(context) { |
| 736 | const runtime = createRuntime(); | 251 | context.register("commands", { id: "implementation", label: "first" }); |
| 737 | const undeclaredConsumer: PluginDefinition = { | 252 | }, |
| 738 | id: "acme.undeclared-consumer", | 253 | }; |
| 739 | version: "1.0.0", | 254 | const second: PluginDefinition = { |
| 740 | activate(context) { | 255 | id: "acme.implementation", |
| 741 | context.resolve("acme.database@1"); | 256 | version: "1.0.0", |
| 742 | }, | 257 | activate(context) { |
| 743 | }; | 258 | context.register("commands", { id: "implementation", label: "second" }); |
| 744 | | 259 | }, |
| 745 | let undeclaredFailure: unknown; | 260 | }; |
| 746 | try { | 261 | |
| 747 | await runtime.reconcile([provider(), undeclaredConsumer]); | 262 | yield* runtime.reconcile([first]); |
| 748 | } catch (error) { | 263 | const snapshot = yield* runtime.reconcile([second]); |
| 749 | undeclaredFailure = error; | 264 | |
| 750 | } | 265 | expect(contributionLabels(snapshot, "commands")).toEqual(["second"]); |
| 751 | expect(failureMessage(undeclaredFailure)).toContain("did not declare"); | 266 | yield* runtime.dispose; |
| 752 | expect(runtime.snapshot().active).toEqual([]); | 267 | }), |
| 753 | await runtime.dispose(); | 268 | ), |
| 754 | }); | 269 | ); |
| 755 | | 270 | |
| 756 | it("rolls back a plugin scope when contribution snapshotting throws", async () => { | 271 | it.effect( |
| 757 | let disposed = false; | 272 | "restarts a changed provider and its dependents without touching independent plugins", |
| 758 | const runtime = createRuntime(); | 273 | () => |
| 759 | const badContribution = { | 274 | Effect.scoped( |
| 760 | get id(): string { | 275 | Effect.gen(function* () { |
| 761 | throw new Error("bad contribution getter"); | 276 | let independentActivations = 0; |
| 762 | }, | 277 | const lifecycle: Array<string> = []; |
| 763 | label: "bad", | 278 | const independent: PluginDefinition = { |
| 764 | }; | 279 | id: "acme.independent", |
| 765 | | 280 | version: "1.0.0", |
| 766 | let snapshotFailure: unknown; | 281 | activate() { |
| 767 | try { | 282 | independentActivations += 1; |
| 768 | await runtime.reconcile([ | 283 | }, |
| 769 | { | 284 | }; |
| 770 | id: "acme.snapshot-defect", | 285 | const runtime = yield* createRuntime({ |
| | 286 | onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), |
| | 287 | }); |
| | 288 | yield* runtime.reconcile([consumer(), independent, provider()]); |
| | 289 | lifecycle.length = 0; |
| | 290 | |
| | 291 | const snapshot = yield* runtime.reconcile([consumer(), independent, provider("2.0.0")]); |
| | 292 | |
| | 293 | expect(independentActivations).toBe(1); |
| | 294 | expect(lifecycle).not.toContain("activate:acme.independent"); |
| | 295 | expect(lifecycle).not.toContain("deactivate:acme.independent"); |
| | 296 | expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([ |
| | 297 | "activate:acme.database", |
| | 298 | "activate:acme.issues", |
| | 299 | ]); |
| | 300 | expect(lifecycle.filter((event) => event.startsWith("deactivate:"))).toEqual([ |
| | 301 | "deactivate:acme.issues", |
| | 302 | "deactivate:acme.database", |
| | 303 | ]); |
| | 304 | expect(contributionLabels(snapshot, "commands")).toEqual(["database-2.0.0"]); |
| | 305 | yield* runtime.dispose; |
| | 306 | }), |
| | 307 | ), |
| | 308 | ); |
| | 309 | |
| | 310 | it.effect("returns deeply frozen snapshots", () => |
| | 311 | Effect.scoped( |
| | 312 | Effect.gen(function* () { |
| | 313 | const runtime = yield* createRuntime(); |
| | 314 | |
| | 315 | const snapshot = yield* runtime.reconcile([provider()]); |
| | 316 | const status = snapshot.contributions.status; |
| | 317 | |
| | 318 | expect(Object.isFrozen(snapshot)).toBe(true); |
| | 319 | expect(Object.isFrozen(snapshot.active)).toBe(true); |
| | 320 | expect(Object.isFrozen(snapshot.blocked)).toBe(true); |
| | 321 | expect(Object.isFrozen(snapshot.contributions)).toBe(true); |
| | 322 | expect(Object.isFrozen(status)).toBe(true); |
| | 323 | expect(Object.isFrozen(status?.[0])).toBe(true); |
| | 324 | yield* runtime.dispose; |
| | 325 | }), |
| | 326 | ), |
| | 327 | ); |
| | 328 | |
| | 329 | it.effect("detaches registered contributions from later plugin mutation", () => |
| | 330 | Effect.scoped( |
| | 331 | Effect.gen(function* () { |
| | 332 | const contribution = { id: "mutable", label: "original" }; |
| | 333 | const plugin: PluginDefinition = { |
| | 334 | id: "acme.mutable-contribution", |
| | 335 | version: "1.0.0", |
| | 336 | activate(context) { |
| | 337 | context.register("commands", contribution); |
| | 338 | }, |
| | 339 | }; |
| | 340 | const unrelated: PluginDefinition = { |
| | 341 | id: "acme.unrelated-contribution", |
| | 342 | version: "1.0.0", |
| | 343 | activate() {}, |
| | 344 | }; |
| | 345 | const runtime = yield* createRuntime(); |
| | 346 | yield* runtime.reconcile([plugin]); |
| | 347 | |
| | 348 | contribution.label = "changed"; |
| | 349 | const snapshot = yield* runtime.reconcile([plugin, unrelated]); |
| | 350 | |
| | 351 | expect(contributionLabels(snapshot, "commands")).toEqual(["original"]); |
| | 352 | yield* runtime.dispose; |
| | 353 | }), |
| | 354 | ), |
| | 355 | ); |
| | 356 | |
| | 357 | it.effect("treats every plugin id and contribution slot as data", () => |
| | 358 | Effect.scoped( |
| | 359 | Effect.gen(function* () { |
| | 360 | const runtime = yield* createRuntime(); |
| | 361 | const unusualProvider: PluginDefinition = { |
| | 362 | id: "toString", |
| | 363 | version: "1.0.0", |
| | 364 | provides: { "acme.unusual@1": "available" }, |
| | 365 | activate(context) { |
| | 366 | context.register("__proto__", { id: "provider", label: "provider" }); |
| | 367 | }, |
| | 368 | }; |
| | 369 | const unusualConsumer: PluginDefinition = { |
| | 370 | id: "constructor", |
| | 371 | version: "1.0.0", |
| | 372 | requires: ["acme.unusual@1"], |
| | 373 | activate(context) { |
| | 374 | context.resolve("acme.unusual@1"); |
| | 375 | context.register("toString", { id: "consumer", label: "consumer" }); |
| | 376 | }, |
| | 377 | }; |
| | 378 | const unusualBlocked: PluginDefinition = { |
| | 379 | id: "__proto__", |
| | 380 | version: "1.0.0", |
| | 381 | requires: ["acme.missing@1"], |
| | 382 | activate() { |
| | 383 | throw new Error("blocked plugin activated"); |
| | 384 | }, |
| | 385 | }; |
| | 386 | |
| | 387 | const snapshot = yield* runtime.reconcile([ |
| | 388 | unusualConsumer, |
| | 389 | unusualProvider, |
| | 390 | unusualBlocked, |
| | 391 | ]); |
| | 392 | |
| | 393 | expect(snapshot.active).toEqual(["toString", "constructor"]); |
| | 394 | expect(Object.hasOwn(snapshot.blocked, "__proto__")).toBe(true); |
| | 395 | expect(snapshot.blocked.__proto__).toContain("acme.missing@1"); |
| | 396 | expect(Object.hasOwn(snapshot.contributions, "__proto__")).toBe(true); |
| | 397 | expect(Object.hasOwn(snapshot.contributions, "toString")).toBe(true); |
| | 398 | expect(contributionLabels(snapshot, "__proto__")).toEqual(["provider"]); |
| | 399 | expect(contributionLabels(snapshot, "toString")).toEqual(["consumer"]); |
| | 400 | yield* runtime.dispose; |
| | 401 | }), |
| | 402 | ), |
| | 403 | ); |
| | 404 | |
| | 405 | it.effect("restarts dependents when a provided value changes", () => |
| | 406 | Effect.scoped( |
| | 407 | Effect.gen(function* () { |
| | 408 | const lifecycle: Array<string> = []; |
| | 409 | const runtime = yield* createRuntime({ |
| | 410 | onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), |
| | 411 | }); |
| | 412 | const serviceProvider = (name: string): PluginDefinition => ({ |
| | 413 | id: "acme.service", |
| | 414 | version: "1.0.0", |
| | 415 | provides: { "acme.service@1": { name } }, |
| | 416 | activate() {}, |
| | 417 | }); |
| | 418 | const serviceConsumer: PluginDefinition = { |
| | 419 | id: "acme.service-consumer", |
| | 420 | version: "1.0.0", |
| | 421 | requires: ["acme.service@1"], |
| | 422 | activate(context) { |
| | 423 | const service = context.resolve<{ readonly name: string }>("acme.service@1"); |
| | 424 | context.register("commands", { id: "service", label: service.name }); |
| | 425 | }, |
| | 426 | }; |
| | 427 | yield* runtime.reconcile([serviceProvider("first"), serviceConsumer]); |
| | 428 | lifecycle.length = 0; |
| | 429 | |
| | 430 | const snapshot = yield* runtime.reconcile([serviceProvider("second"), serviceConsumer]); |
| | 431 | |
| | 432 | expect(contributionLabels(snapshot, "commands")).toEqual(["second"]); |
| | 433 | expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([ |
| | 434 | "activate:acme.service", |
| | 435 | "activate:acme.service-consumer", |
| | 436 | ]); |
| | 437 | yield* runtime.dispose; |
| | 438 | }), |
| | 439 | ), |
| | 440 | ); |
| | 441 | |
| | 442 | it.effect("does not report a rolled-back candidate as activated", () => |
| | 443 | Effect.scoped( |
| | 444 | Effect.gen(function* () { |
| | 445 | const lifecycle: Array<string> = []; |
| | 446 | const runtime = yield* createRuntime({ |
| | 447 | onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), |
| | 448 | }); |
| | 449 | const staged: PluginDefinition = { |
| | 450 | id: "acme.staged", |
| | 451 | version: "1.0.0", |
| | 452 | activate() {}, |
| | 453 | }; |
| | 454 | const broken: PluginDefinition = { |
| | 455 | id: "acme.broken", |
| | 456 | version: "1.0.0", |
| | 457 | activate() { |
| | 458 | throw new Error("activation failed"); |
| | 459 | }, |
| | 460 | }; |
| | 461 | |
| | 462 | const stagingFailure = yield* failureOf(runtime.reconcile([staged, broken])); |
| | 463 | expect(failureMessage(stagingFailure)).toContain("activation failed"); |
| | 464 | |
| | 465 | expect(lifecycle).toEqual([]); |
| | 466 | yield* runtime.dispose; |
| | 467 | }), |
| | 468 | ), |
| | 469 | ); |
| | 470 | |
| | 471 | it.effect("preserves activation failures when rollback cleanup also fails", () => |
| | 472 | Effect.scoped( |
| | 473 | Effect.gen(function* () { |
| | 474 | const activationError = new Error("activation failed"); |
| | 475 | const cleanupError = new Error("rollback cleanup failed"); |
| | 476 | const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; |
| | 477 | const runtime = yield* createRuntime({ |
| | 478 | onCleanupError: (event) => { |
| | 479 | cleanupEvents.push(event); |
| | 480 | throw new Error("cleanup observer failed"); |
| | 481 | }, |
| | 482 | }); |
| | 483 | const broken: PluginDefinition = { |
| | 484 | id: "acme.rollback-error", |
| 771 | version: "1.0.0", | 485 | version: "1.0.0", |
| 772 | activate(context) { | 486 | activate(context) { |
| 773 | context.onDispose(() => { | 487 | context.onDispose(() => { |
| 774 | disposed = true; | 488 | throw cleanupError; |
| 775 | }); | 489 | }); |
| 776 | context.register("commands", badContribution); | 490 | throw activationError; |
| 777 | }, | 491 | }, |
| 778 | }, | 492 | }; |
| 779 | ]); | 493 | |
| 780 | } catch (error) { | 494 | const activationFailure = yield* failureOf(runtime.reconcile([broken])); |
| 781 | snapshotFailure = error; | 495 | expect(failureCause(activationFailure)).toBe(activationError); |
| 782 | } | 496 | expect(cleanupEvents).toHaveLength(1); |
| 783 | | 497 | expect(cleanupEvents[0]?.phase).toBe("rollback"); |
| 784 | expect(failureMessage(snapshotFailure)).toContain("bad contribution getter"); | 498 | expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError); |
| 785 | expect(disposed).toBe(true); | 499 | yield* runtime.dispose; |
| 786 | expect(runtime.snapshot().active).toEqual([]); | 500 | }), |
| 787 | await runtime.dispose(); | 501 | ), |
| 788 | }); | 502 | ); |
| 789 | | 503 | |
| 790 | it("attempts every plugin cleanup when one finalizer fails", async () => { | 504 | it.effect( |
| 791 | const disposed: Array<string> = []; | 505 | "returns the committed snapshot when retiring an old plugin reports cleanup errors", |
| 792 | const runtime = createRuntime(); | 506 | () => |
| 793 | await runtime.reconcile([ | 507 | Effect.scoped( |
| 794 | { | 508 | Effect.gen(function* () { |
| 795 | id: "acme.first-cleanup", | 509 | const cleanupError = new Error("retirement cleanup failed"); |
| 796 | version: "1.0.0", | 510 | const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; |
| 797 | activate(context) { | 511 | const runtime = yield* createRuntime({ |
| 798 | context.onDispose(() => { | 512 | onCleanupError: (event) => { |
| 799 | disposed.push("first"); | 513 | cleanupEvents.push(event); |
| | 514 | throw new Error("cleanup observer failed"); |
| | 515 | }, |
| 800 | }); | 516 | }); |
| 801 | }, | 517 | yield* runtime.reconcile([ |
| 802 | }, | 518 | { |
| 803 | { | 519 | id: "acme.retirement-error", |
| 804 | id: "acme.second-cleanup", | 520 | version: "1.0.0", |
| 805 | version: "1.0.0", | 521 | activate(context) { |
| 806 | activate(context) { | 522 | context.register("commands", { id: "retirement", label: "old" }); |
| 807 | context.onDispose(() => { | 523 | context.onDispose(() => { |
| 808 | disposed.push("second"); | 524 | throw cleanupError; |
| 809 | throw new Error("cleanup failed"); | 525 | }); |
| | 526 | }, |
| | 527 | }, |
| | 528 | ]); |
| | 529 | |
| | 530 | const snapshot = yield* runtime.reconcile([ |
| | 531 | { |
| | 532 | id: "acme.retirement-error", |
| | 533 | version: "2.0.0", |
| | 534 | activate(context) { |
| | 535 | context.register("commands", { id: "retirement", label: "new" }); |
| | 536 | }, |
| | 537 | }, |
| | 538 | ]); |
| | 539 | |
| | 540 | expect(contributionLabels(snapshot, "commands")).toEqual(["new"]); |
| | 541 | expect(yield* runtime.snapshot).toBe(snapshot); |
| | 542 | expect(cleanupEvents).toHaveLength(1); |
| | 543 | expect(cleanupEvents[0]?.phase).toBe("retire"); |
| | 544 | expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError); |
| | 545 | yield* runtime.dispose; |
| | 546 | }), |
| | 547 | ), |
| | 548 | ); |
| | 549 | |
| | 550 | it.effect("does not let lifecycle observers interrupt a committed transition", () => |
| | 551 | Effect.scoped( |
| | 552 | Effect.gen(function* () { |
| | 553 | let oldDisposed = false; |
| | 554 | const observerErrors: Array<string> = []; |
| | 555 | const runtime = yield* createRuntime({ |
| | 556 | onLifecycle: ({ phase, pluginId }) => { |
| | 557 | throw new Error(`${phase}:${pluginId}`); |
| | 558 | }, |
| | 559 | onLifecycleError: ({ phase, pluginId }) => { |
| | 560 | observerErrors.push(`${phase}:${pluginId}`); |
| | 561 | }, |
| | 562 | }); |
| | 563 | yield* runtime.reconcile([ |
| | 564 | { |
| | 565 | id: "acme.lifecycle-observer", |
| | 566 | version: "1.0.0", |
| | 567 | activate(context) { |
| | 568 | context.onDispose(() => { |
| | 569 | oldDisposed = true; |
| | 570 | }); |
| | 571 | }, |
| | 572 | }, |
| | 573 | ]); |
| | 574 | |
| | 575 | const snapshot = yield* runtime.reconcile([ |
| | 576 | { |
| | 577 | id: "acme.lifecycle-observer", |
| | 578 | version: "2.0.0", |
| | 579 | activate(context) { |
| | 580 | context.register("commands", { id: "observer", label: "committed" }); |
| | 581 | }, |
| | 582 | }, |
| | 583 | ]); |
| | 584 | |
| | 585 | expect(oldDisposed).toBe(true); |
| | 586 | expect(contributionLabels(snapshot, "commands")).toEqual(["committed"]); |
| | 587 | expect(observerErrors).toEqual([ |
| | 588 | "activate:acme.lifecycle-observer", |
| | 589 | "activate:acme.lifecycle-observer", |
| | 590 | "deactivate:acme.lifecycle-observer", |
| | 591 | ]); |
| | 592 | yield* runtime.dispose; |
| | 593 | }), |
| | 594 | ), |
| | 595 | ); |
| | 596 | |
| | 597 | it.effect("rejects runtime operations reentered from plugin activation", () => |
| | 598 | Effect.scoped( |
| | 599 | Effect.gen(function* () { |
| | 600 | const runtime = yield* createRuntime(); |
| | 601 | const nested = makeEffectCallback<Exit.Exit<PluginRuntimeSnapshot, unknown>, never>(); |
| | 602 | const nestedFiber = yield* Effect.forkChild(nested.await); |
| | 603 | yield* Effect.yieldNow; |
| | 604 | let settleActivation!: (exit: Exit.Exit<unknown, unknown>) => void; |
| | 605 | const activationGate = new Promise<void>((resolve, reject) => { |
| | 606 | settleActivation = (exit) => settlePromise(exit, resolve, reject); |
| | 607 | }); |
| | 608 | const reentrant: PluginDefinition = { |
| | 609 | id: "acme.reentrant", |
| | 610 | version: "1.0.0", |
| | 611 | activate() { |
| | 612 | nested.resume( |
| | 613 | Effect.exit(runtime.reconcile([])).pipe( |
| | 614 | Effect.tap((exit) => Effect.sync(() => settleActivation(exit))), |
| | 615 | ), |
| | 616 | ); |
| | 617 | return activationGate; |
| | 618 | }, |
| | 619 | }; |
| | 620 | const activationFailure = yield* failureOf(runtime.reconcile([reentrant])); |
| | 621 | yield* Fiber.join(nestedFiber); |
| | 622 | |
| | 623 | expect(failureMessage(activationFailure)).toContain("reentrant"); |
| | 624 | }), |
| | 625 | ), |
| | 626 | ); |
| | 627 | |
| | 628 | it.effect("rejects runtime operations reentered from plugin finalizers", () => |
| | 629 | Effect.scoped( |
| | 630 | Effect.gen(function* () { |
| | 631 | const runtime = yield* createRuntime(); |
| | 632 | const nested = makeEffectCallback<Exit.Exit<void, unknown>, never>(); |
| | 633 | const nestedFiber = yield* Effect.forkChild(nested.await); |
| | 634 | yield* Effect.yieldNow; |
| | 635 | let finishFinalizer!: (exit: Exit.Exit<void, unknown>) => void; |
| | 636 | const finalizerGate = new Promise<void>((resolve) => { |
| | 637 | finishFinalizer = () => resolve(); |
| | 638 | }); |
| | 639 | yield* runtime.reconcile([ |
| | 640 | { |
| | 641 | id: "acme.reentrant-finalizer", |
| | 642 | version: "1.0.0", |
| | 643 | activate(context) { |
| | 644 | context.onDispose(() => { |
| | 645 | nested.resume( |
| | 646 | Effect.exit(runtime.dispose).pipe( |
| | 647 | Effect.tap((exit) => Effect.sync(() => finishFinalizer(exit))), |
| | 648 | ), |
| | 649 | ); |
| | 650 | return finalizerGate; |
| | 651 | }); |
| | 652 | }, |
| | 653 | }, |
| | 654 | ]); |
| | 655 | const snapshot = yield* runtime.reconcile([]); |
| | 656 | const nestedExit = yield* Fiber.join(nestedFiber); |
| | 657 | const nestedFailure = Exit.isFailure(nestedExit) |
| | 658 | ? Cause.squash(nestedExit.cause) |
| | 659 | : undefined; |
| | 660 | |
| | 661 | expect(snapshot).toBeDefined(); |
| | 662 | expect(nestedFailure).toBeInstanceOf(Error); |
| | 663 | expect((nestedFailure as Error).message).toContain("reentrant"); |
| | 664 | yield* runtime.dispose; |
| | 665 | }), |
| | 666 | ), |
| | 667 | ); |
| | 668 | |
| | 669 | it.effect( |
| | 670 | "allows descendant tasks to use the runtime after their plugin callback settles", |
| | 671 | () => |
| | 672 | Effect.scoped( |
| | 673 | Effect.gen(function* () { |
| | 674 | let releaseBackground!: () => void; |
| | 675 | const gate = new Promise<void>((resolve) => { |
| | 676 | releaseBackground = resolve; |
| 810 | }); | 677 | }); |
| 811 | }, | 678 | const runtime = yield* createRuntime(); |
| 812 | }, | 679 | const background = makeEffectCallback< |
| 813 | ]); | 680 | PluginRuntimeSnapshot, |
| 814 | | 681 | PluginRuntimeReconcileError |
| 815 | await expect(runtime.dispose()).rejects.toThrow(); | 682 | >(); |
| 816 | | 683 | const backgroundFiber = yield* Effect.forkChild(background.await); |
| 817 | expect(disposed).toEqual(["second", "first"]); | 684 | yield* Effect.yieldNow; |
| 818 | }); | 685 | |
| 819 | | 686 | yield* runtime.reconcile([ |
| 820 | it("keeps the old plugin active when a replacement fails", async () => { | 687 | { |
| 821 | const runtime = createRuntime(); | 688 | id: "acme.background-task", |
| 822 | await runtime.reconcile([provider()]); | 689 | version: "1.0.0", |
| 823 | const broken: PluginDefinition = { | 690 | activate() { |
| 824 | ...provider("2.0.0"), | 691 | void gate.then(() => background.resume(runtime.reconcile([]))); |
| 825 | async activate(context) { | 692 | }, |
| 826 | context.register("status", { id: "database", label: "database 2.0.0" }); | 693 | }, |
| 827 | throw new Error("candidate failed"); | 694 | ]); |
| 828 | }, | 695 | |
| 829 | }; | 696 | releaseBackground(); |
| 830 | | 697 | expect(yield* Fiber.join(backgroundFiber)).toBeDefined(); |
| 831 | let candidateFailure: unknown; | 698 | yield* runtime.dispose; |
| 832 | try { | 699 | }), |
| 833 | await runtime.reconcile([broken]); | 700 | ), |
| 834 | } catch (error) { | 701 | ); |
| 835 | candidateFailure = error; | 702 | |
| 836 | } | 703 | it.effect("allows microtasks spawned by synchronous plugin callbacks to use the runtime", () => |
| 837 | expect(failureMessage(candidateFailure)).toContain("candidate failed"); | 704 | Effect.scoped( |
| 838 | | 705 | Effect.gen(function* () { |
| 839 | expect(runtime.snapshot().active).toEqual(["acme.database"]); | 706 | let backgroundScheduled = false; |
| 840 | expect(contributionLabels(runtime.snapshot(), "status")).toEqual(["database 1.0.0"]); | 707 | const runtime = yield* createRuntime(); |
| 841 | await runtime.dispose(); | 708 | const background = makeEffectCallback< |
| 842 | }); | 709 | PluginRuntimeSnapshot, |
| 843 | | 710 | PluginRuntimeReconcileError |
| 844 | it("rejects dependency cycles without disturbing the current composition", async () => { | 711 | >(); |
| 845 | const runtime = createRuntime(); | 712 | const backgroundFiber = yield* Effect.forkChild(background.await); |
| 846 | await runtime.reconcile([provider()]); | 713 | yield* Effect.yieldNow; |
| 847 | const alpha: PluginDefinition = { | 714 | |
| 848 | id: "acme.alpha", | 715 | yield* runtime.reconcile([ |
| 849 | version: "1.0.0", | 716 | { |
| 850 | requires: ["acme.beta@1"], | 717 | id: "acme.microtask", |
| 851 | provides: { "acme.alpha@1": true }, | 718 | version: "1.0.0", |
| 852 | activate() {}, | 719 | activate() { |
| 853 | }; | 720 | queueMicrotask(() => { |
| 854 | const beta: PluginDefinition = { | 721 | backgroundScheduled = true; |
| 855 | id: "acme.beta", | 722 | background.resume(runtime.reconcile([])); |
| 856 | version: "1.0.0", | 723 | }); |
| 857 | requires: ["acme.alpha@1"], | 724 | }, |
| 858 | provides: { "acme.beta@1": true }, | 725 | }, |
| 859 | activate() {}, | 726 | ]); |
| 860 | }; | 727 | |
| 861 | | 728 | expect(backgroundScheduled).toBe(true); |
| 862 | await expect(runtime.reconcile([alpha, beta])).rejects.toThrow("cycle"); | 729 | expect(yield* Fiber.join(backgroundFiber)).toBeDefined(); |
| 863 | | 730 | yield* runtime.dispose; |
| 864 | expect(runtime.snapshot().active).toEqual(["acme.database"]); | 731 | }), |
| 865 | await runtime.dispose(); | 732 | ), |
| 866 | }); | 733 | ); |
| | 734 | |
| | 735 | it.effect( |
| | 736 | "allows microtasks spawned by synchronous callbacks that return plain functions", |
| | 737 | () => |
| | 738 | Effect.scoped( |
| | 739 | Effect.gen(function* () { |
| | 740 | let backgroundScheduled = false; |
| | 741 | const runtime = yield* createRuntime(); |
| | 742 | const background = makeEffectCallback< |
| | 743 | PluginRuntimeSnapshot, |
| | 744 | PluginRuntimeReconcileError |
| | 745 | >(); |
| | 746 | const backgroundFiber = yield* Effect.forkChild(background.await); |
| | 747 | yield* Effect.yieldNow; |
| | 748 | const activate = (() => { |
| | 749 | queueMicrotask(() => { |
| | 750 | backgroundScheduled = true; |
| | 751 | background.resume(runtime.reconcile([])); |
| | 752 | }); |
| | 753 | return () => undefined; |
| | 754 | }) as unknown as PluginDefinition["activate"]; |
| | 755 | |
| | 756 | yield* runtime.reconcile([ |
| | 757 | { |
| | 758 | id: "acme.function-return", |
| | 759 | version: "1.0.0", |
| | 760 | activate, |
| | 761 | }, |
| | 762 | ]); |
| | 763 | |
| | 764 | expect(backgroundScheduled).toBe(true); |
| | 765 | expect(yield* Fiber.join(backgroundFiber)).toBeDefined(); |
| | 766 | yield* runtime.dispose; |
| | 767 | }), |
| | 768 | ), |
| | 769 | ); |
| | 770 | |
| | 771 | it.effect( |
| | 772 | "rejects activation-context calls from microtasks queued by synchronous callbacks", |
| | 773 | () => |
| | 774 | Effect.scoped( |
| | 775 | Effect.gen(function* () { |
| | 776 | let lateFailure: unknown; |
| | 777 | let disposed = false; |
| | 778 | const runtime = yield* createRuntime(); |
| | 779 | |
| | 780 | yield* runtime.reconcile([ |
| | 781 | { |
| | 782 | id: "acme.microtask-context", |
| | 783 | version: "1.0.0", |
| | 784 | activate(context) { |
| | 785 | queueMicrotask(() => { |
| | 786 | try { |
| | 787 | context.onDispose(() => { |
| | 788 | disposed = true; |
| | 789 | }); |
| | 790 | } catch (error) { |
| | 791 | lateFailure = error; |
| | 792 | } |
| | 793 | }); |
| | 794 | }, |
| | 795 | }, |
| | 796 | ]); |
| | 797 | |
| | 798 | expect(lateFailure).toBeInstanceOf(Error); |
| | 799 | expect((lateFailure as Error).message).toContain("no longer active"); |
| | 800 | yield* runtime.dispose; |
| | 801 | expect(disposed).toBe(false); |
| | 802 | }), |
| | 803 | ), |
| | 804 | ); |
| | 805 | |
| | 806 | it.effect("rejects finalizers registered after activation settles", () => |
| | 807 | Effect.scoped( |
| | 808 | Effect.gen(function* () { |
| | 809 | let registerLateFinalizer!: () => void; |
| | 810 | let disposed = false; |
| | 811 | const runtime = yield* createRuntime(); |
| | 812 | yield* runtime.reconcile([ |
| | 813 | { |
| | 814 | id: "acme.late-finalizer", |
| | 815 | version: "1.0.0", |
| | 816 | activate(context) { |
| | 817 | registerLateFinalizer = () => |
| | 818 | context.onDispose(() => { |
| | 819 | disposed = true; |
| | 820 | }); |
| | 821 | }, |
| | 822 | }, |
| | 823 | ]); |
| | 824 | |
| | 825 | expect(registerLateFinalizer).toThrow("no longer active"); |
| | 826 | yield* runtime.dispose; |
| | 827 | expect(disposed).toBe(false); |
| | 828 | }), |
| | 829 | ), |
| | 830 | ); |
| | 831 | |
| | 832 | it.effect("snapshots the requested definitions before waiting for an earlier transition", () => |
| | 833 | Effect.scoped( |
| | 834 | Effect.gen(function* () { |
| | 835 | let releaseActivation!: () => void; |
| | 836 | const activationGate = new Promise<void>((resolve) => { |
| | 837 | releaseActivation = resolve; |
| | 838 | }); |
| | 839 | const runtime = yield* createRuntime(); |
| | 840 | const first = yield* Effect.forkChild( |
| | 841 | runtime.reconcile([ |
| | 842 | { |
| | 843 | id: "acme.blocking", |
| | 844 | version: "1.0.0", |
| | 845 | async activate() { |
| | 846 | await activationGate; |
| | 847 | }, |
| | 848 | }, |
| | 849 | ]), |
| | 850 | ); |
| | 851 | yield* Effect.yieldNow; |
| | 852 | const requested: Array<PluginDefinition> = [provider()]; |
| | 853 | const second = yield* Effect.forkChild(runtime.reconcile(requested)); |
| | 854 | requested.splice(0); |
| | 855 | |
| | 856 | releaseActivation(); |
| | 857 | yield* Fiber.join(first); |
| | 858 | const snapshot = yield* Fiber.join(second); |
| | 859 | |
| | 860 | expect(snapshot.active).toEqual(["acme.database"]); |
| | 861 | yield* runtime.dispose; |
| | 862 | }), |
| | 863 | ), |
| | 864 | ); |
| | 865 | |
| | 866 | it.effect("snapshots each requested definition and its declarations before queueing", () => |
| | 867 | Effect.scoped( |
| | 868 | Effect.gen(function* () { |
| | 869 | let releaseActivation!: () => void; |
| | 870 | const activationGate = new Promise<void>((resolve) => { |
| | 871 | releaseActivation = resolve; |
| | 872 | }); |
| | 873 | const runtime = yield* createRuntime(); |
| | 874 | const first = yield* Effect.forkChild( |
| | 875 | runtime.reconcile([ |
| | 876 | { |
| | 877 | id: "acme.blocking", |
| | 878 | version: "1.0.0", |
| | 879 | async activate() { |
| | 880 | await activationGate; |
| | 881 | }, |
| | 882 | }, |
| | 883 | ]), |
| | 884 | ); |
| | 885 | yield* Effect.yieldNow; |
| | 886 | const requires = ["acme.database@1"]; |
| | 887 | const provides: Record<string, unknown> = { "acme.consumer@1": true }; |
| | 888 | const requestedDefinition: PluginDefinition = { |
| | 889 | id: "acme.consumer", |
| | 890 | version: "1.0.0", |
| | 891 | requires, |
| | 892 | provides, |
| | 893 | activate() {}, |
| | 894 | }; |
| | 895 | const second = yield* Effect.forkChild( |
| | 896 | runtime.reconcile([provider(), requestedDefinition]), |
| | 897 | ); |
| | 898 | |
| | 899 | (requestedDefinition as { id: string }).id = "acme.changed"; |
| | 900 | requires[0] = "acme.missing@1"; |
| | 901 | delete provides["acme.consumer@1"]; |
| | 902 | |
| | 903 | releaseActivation(); |
| | 904 | yield* Fiber.join(first); |
| | 905 | const snapshot = yield* Fiber.join(second); |
| | 906 | |
| | 907 | expect(snapshot.active).toEqual(["acme.database", "acme.consumer"]); |
| | 908 | yield* runtime.dispose; |
| | 909 | }), |
| | 910 | ), |
| | 911 | ); |
| | 912 | |
| | 913 | it.effect("rejects capabilities that the plugin did not declare", () => |
| | 914 | Effect.scoped( |
| | 915 | Effect.gen(function* () { |
| | 916 | const runtime = yield* createRuntime(); |
| | 917 | const undeclaredConsumer: PluginDefinition = { |
| | 918 | id: "acme.undeclared-consumer", |
| | 919 | version: "1.0.0", |
| | 920 | activate(context) { |
| | 921 | context.resolve("acme.database@1"); |
| | 922 | }, |
| | 923 | }; |
| | 924 | |
| | 925 | const undeclaredFailure = yield* failureOf( |
| | 926 | runtime.reconcile([provider(), undeclaredConsumer]), |
| | 927 | ); |
| | 928 | expect(failureMessage(undeclaredFailure)).toContain("did not declare"); |
| | 929 | expect((yield* runtime.snapshot).active).toEqual([]); |
| | 930 | yield* runtime.dispose; |
| | 931 | }), |
| | 932 | ), |
| | 933 | ); |
| | 934 | |
| | 935 | it.effect("rolls back a plugin scope when contribution snapshotting throws", () => |
| | 936 | Effect.scoped( |
| | 937 | Effect.gen(function* () { |
| | 938 | let disposed = false; |
| | 939 | const runtime = yield* createRuntime(); |
| | 940 | const badContribution = { |
| | 941 | get id(): string { |
| | 942 | throw new Error("bad contribution getter"); |
| | 943 | }, |
| | 944 | label: "bad", |
| | 945 | }; |
| | 946 | |
| | 947 | const snapshotFailure = yield* failureOf( |
| | 948 | runtime.reconcile([ |
| | 949 | { |
| | 950 | id: "acme.snapshot-defect", |
| | 951 | version: "1.0.0", |
| | 952 | activate(context) { |
| | 953 | context.onDispose(() => { |
| | 954 | disposed = true; |
| | 955 | }); |
| | 956 | context.register("commands", badContribution); |
| | 957 | }, |
| | 958 | }, |
| | 959 | ]), |
| | 960 | ); |
| | 961 | |
| | 962 | expect(failureMessage(snapshotFailure)).toContain("bad contribution getter"); |
| | 963 | expect(disposed).toBe(true); |
| | 964 | expect((yield* runtime.snapshot).active).toEqual([]); |
| | 965 | yield* runtime.dispose; |
| | 966 | }), |
| | 967 | ), |
| | 968 | ); |
| | 969 | |
| | 970 | it.effect("attempts every plugin cleanup when one finalizer fails", () => |
| | 971 | Effect.scoped( |
| | 972 | Effect.gen(function* () { |
| | 973 | const disposed: Array<string> = []; |
| | 974 | const runtime = yield* createRuntime(); |
| | 975 | yield* runtime.reconcile([ |
| | 976 | { |
| | 977 | id: "acme.first-cleanup", |
| | 978 | version: "1.0.0", |
| | 979 | activate(context) { |
| | 980 | context.onDispose(() => { |
| | 981 | disposed.push("first"); |
| | 982 | }); |
| | 983 | }, |
| | 984 | }, |
| | 985 | { |
| | 986 | id: "acme.second-cleanup", |
| | 987 | version: "1.0.0", |
| | 988 | activate(context) { |
| | 989 | context.onDispose(() => { |
| | 990 | disposed.push("second"); |
| | 991 | throw new Error("cleanup failed"); |
| | 992 | }); |
| | 993 | }, |
| | 994 | }, |
| | 995 | ]); |
| | 996 | |
| | 997 | expect(yield* failureOf(runtime.dispose)).toBeInstanceOf(Error); |
| | 998 | |
| | 999 | expect(disposed).toEqual(["second", "first"]); |
| | 1000 | }), |
| | 1001 | ), |
| | 1002 | ); |
| | 1003 | |
| | 1004 | it.effect("keeps the old plugin active when a replacement fails", () => |
| | 1005 | Effect.scoped( |
| | 1006 | Effect.gen(function* () { |
| | 1007 | const runtime = yield* createRuntime(); |
| | 1008 | yield* runtime.reconcile([provider()]); |
| | 1009 | const broken: PluginDefinition = { |
| | 1010 | ...provider("2.0.0"), |
| | 1011 | async activate(context) { |
| | 1012 | context.register("status", { id: "database", label: "database 2.0.0" }); |
| | 1013 | throw new Error("candidate failed"); |
| | 1014 | }, |
| | 1015 | }; |
| | 1016 | |
| | 1017 | const candidateFailure = yield* failureOf(runtime.reconcile([broken])); |
| | 1018 | expect(failureMessage(candidateFailure)).toContain("candidate failed"); |
| | 1019 | |
| | 1020 | expect((yield* runtime.snapshot).active).toEqual(["acme.database"]); |
| | 1021 | expect(contributionLabels(yield* runtime.snapshot, "status")).toEqual(["database 1.0.0"]); |
| | 1022 | yield* runtime.dispose; |
| | 1023 | }), |
| | 1024 | ), |
| | 1025 | ); |
| | 1026 | |
| | 1027 | it.effect("rejects dependency cycles without disturbing the current composition", () => |
| | 1028 | Effect.scoped( |
| | 1029 | Effect.gen(function* () { |
| | 1030 | const runtime = yield* createRuntime(); |
| | 1031 | yield* runtime.reconcile([provider()]); |
| | 1032 | const alpha: PluginDefinition = { |
| | 1033 | id: "acme.alpha", |
| | 1034 | version: "1.0.0", |
| | 1035 | requires: ["acme.beta@1"], |
| | 1036 | provides: { "acme.alpha@1": true }, |
| | 1037 | activate() {}, |
| | 1038 | }; |
| | 1039 | const beta: PluginDefinition = { |
| | 1040 | id: "acme.beta", |
| | 1041 | version: "1.0.0", |
| | 1042 | requires: ["acme.alpha@1"], |
| | 1043 | provides: { "acme.beta@1": true }, |
| | 1044 | activate() {}, |
| | 1045 | }; |
| | 1046 | |
| | 1047 | expect(failureMessage(yield* failureOf(runtime.reconcile([alpha, beta])))).toContain( |
| | 1048 | "cycle", |
| | 1049 | ); |
| | 1050 | |
| | 1051 | expect((yield* runtime.snapshot).active).toEqual(["acme.database"]); |
| | 1052 | yield* runtime.dispose; |
| | 1053 | }), |
| | 1054 | ), |
| | 1055 | ); |
| 867 | }); | 1056 | }); |
| 868 | } | 1057 | } |