| @@ -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 | 11 | import type { |
| 6 | 12 | PluginDefinition, |
| 7 | | − PluginRuntimeFactory, |
| 13 | + PluginRuntimeOptions, |
| 8 | 14 | PluginRuntimeSnapshot, |
| 9 | 15 | } from "../src/contract.ts"; |
| 10 | 16 | |
| 11 | | −const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) => |
| 12 | | − snapshot.contributions[slot]?.map((item) => item.label) ?? []; |
| 17 | +type TestPluginRuntimeFactory = ( |
| 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> => { |
| 15 | | − const controller = new AbortController(); |
| 16 | | − const timeout = NodeTimersPromises.setTimeout(100, undefined, { |
| 17 | | − ref: false, |
| 18 | | − signal: controller.signal, |
| 19 | | − }).then(() => { |
| 20 | | − throw new Error("operation timed out"); |
| 21 | | − }); |
| 22 | | − try { |
| 23 | | − return await Promise.race([operation, timeout]); |
| 24 | | − } finally { |
| 25 | | − controller.abort(); |
| 26 | | − } |
| 33 | +const failureOf = <A, E, R>(effect: Effect.Effect<A, E, R>) => |
| 34 | + Effect.exit(effect).pipe( |
| 35 | + Effect.map((exit) => (Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined)), |
| 36 | + ); |
| 37 | + |
| 38 | +const settlePromise = ( |
| 39 | + exit: Exit.Exit<unknown, unknown>, |
| 40 | + resolve: () => void, |
| 41 | + reject: (error: unknown) => void, |
| 42 | +) => { |
| 43 | + if (Exit.isFailure(exit)) reject(Cause.squash(exit.cause)); |
| 44 | + else resolve(); |
| 27 | 45 | }; |
| 28 | 46 | |
| 47 | +const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) => |
| 48 | + snapshot.contributions[slot]?.map((item) => item.label) ?? []; |
| 49 | + |
| 29 | 50 | const failureCause = (error: unknown): unknown => |
| 30 | 51 | typeof error === "object" && error !== null && "cause" in error |
| 31 | 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 | 80 | describe(name, () => { |
| 60 | | − it("activates providers before consumers regardless of manifest order", async () => { |
| 61 | | − const runtime = createRuntime(); |
| 62 | | − |
| 63 | | − const snapshot = await runtime.reconcile([consumer(), provider()]); |
| 64 | | − |
| 65 | | − expect(snapshot.active).toEqual(["acme.database", "acme.issues"]); |
| 66 | | − expect(contributionLabels(snapshot, "commands")).toEqual(["database-1.0.0"]); |
| 67 | | − await runtime.dispose(); |
| 68 | | − }); |
| 69 | | − |
| 70 | | − it("blocks missing dependencies without blocking independent plugins", async () => { |
| 71 | | − const runtime = createRuntime(); |
| 72 | | − const independent: PluginDefinition = { |
| 73 | | − id: "acme.clock", |
| 74 | | − version: "1.0.0", |
| 75 | | − activate(context) { |
| 76 | | − context.register("status", { id: "clock", label: "clock" }); |
| 77 | | − }, |
| 78 | | − }; |
| 79 | | − |
| 80 | | − const snapshot = await runtime.reconcile([consumer(), independent]); |
| 81 | | − |
| 82 | | − expect(snapshot.active).toEqual(["acme.clock"]); |
| 83 | | − expect(snapshot.blocked["acme.issues"]).toContain("acme.database@1"); |
| 84 | | − expect(contributionLabels(snapshot, "status")).toEqual(["clock"]); |
| 85 | | − await runtime.dispose(); |
| 86 | | − }); |
| 87 | | − |
| 88 | | − it("deactivates dependents before providers", async () => { |
| 89 | | − const lifecycle: Array<string> = []; |
| 90 | | − const runtime = createRuntime({ |
| 91 | | − onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), |
| 92 | | − }); |
| 93 | | − await runtime.reconcile([provider(), consumer()]); |
| 94 | | − lifecycle.length = 0; |
| 95 | | − |
| 96 | | − await runtime.reconcile([]); |
| 97 | | − |
| 98 | | − expect(lifecycle).toEqual(["deactivate:acme.issues", "deactivate:acme.database"]); |
| 99 | | − await runtime.dispose(); |
| 100 | | − }); |
| 101 | | − |
| 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; |
| 81 | + it.effect("activates providers before consumers regardless of manifest order", () => |
| 82 | + Effect.scoped( |
| 83 | + Effect.gen(function* () { |
| 84 | + const runtime = yield* createRuntime(); |
| 85 | + |
| 86 | + const snapshot = yield* runtime.reconcile([consumer(), provider()]); |
| 87 | + |
| 88 | + expect(snapshot.active).toEqual(["acme.database", "acme.issues"]); |
| 89 | + expect(contributionLabels(snapshot, "commands")).toEqual(["database-1.0.0"]); |
| 90 | + yield* runtime.dispose; |
| 91 | + }), |
| 92 | + ), |
| 93 | + ); |
| 94 | + |
| 95 | + it.effect("blocks missing dependencies without blocking independent plugins", () => |
| 96 | + Effect.scoped( |
| 97 | + Effect.gen(function* () { |
| 98 | + const runtime = yield* createRuntime(); |
| 99 | + const independent: PluginDefinition = { |
| 100 | + id: "acme.clock", |
| 101 | + version: "1.0.0", |
| 102 | + activate(context) { |
| 103 | + context.register("status", { id: "clock", label: "clock" }); |
| 104 | + }, |
| 105 | + }; |
| 106 | + |
| 107 | + const snapshot = yield* runtime.reconcile([consumer(), independent]); |
| 108 | + |
| 109 | + expect(snapshot.active).toEqual(["acme.clock"]); |
| 110 | + expect(snapshot.blocked["acme.issues"]).toContain("acme.database@1"); |
| 111 | + expect(contributionLabels(snapshot, "status")).toEqual(["clock"]); |
| 112 | + yield* runtime.dispose; |
| 113 | + }), |
| 114 | + ), |
| 115 | + ); |
| 116 | + |
| 117 | + it.effect("deactivates dependents before providers", () => |
| 118 | + Effect.scoped( |
| 119 | + Effect.gen(function* () { |
| 120 | + const lifecycle: Array<string> = []; |
| 121 | + const runtime = yield* createRuntime({ |
| 122 | + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), |
| 413 | 123 | }); |
| 414 | | − throw activationError; |
| 415 | | − }, |
| 416 | | − }; |
| 417 | | − |
| 418 | | − let activationFailure: unknown; |
| 419 | | − try { |
| 420 | | − await runtime.reconcile([broken]); |
| 421 | | − } catch (error) { |
| 422 | | − activationFailure = error; |
| 423 | | − } |
| 424 | | − expect(failureCause(activationFailure)).toBe(activationError); |
| 425 | | − expect(cleanupEvents).toHaveLength(1); |
| 426 | | − expect(cleanupEvents[0]?.phase).toBe("rollback"); |
| 427 | | − expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError); |
| 428 | | − await runtime.dispose(); |
| 429 | | − }); |
| 430 | | − |
| 431 | | − it("returns the committed snapshot when retiring an old plugin reports cleanup errors", async () => { |
| 432 | | − const cleanupError = new Error("retirement cleanup failed"); |
| 433 | | − const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; |
| 434 | | − const runtime = createRuntime({ |
| 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 { |
| 124 | + yield* runtime.reconcile([provider(), consumer()]); |
| 125 | + lifecycle.length = 0; |
| 126 | + |
| 127 | + yield* runtime.reconcile([]); |
| 128 | + |
| 129 | + expect(lifecycle).toEqual(["deactivate:acme.issues", "deactivate:acme.database"]); |
| 130 | + yield* runtime.dispose; |
| 131 | + }), |
| 132 | + ), |
| 133 | + ); |
| 134 | + |
| 135 | + it.effect("runs plugin finalizers in reverse registration order", () => |
| 136 | + Effect.scoped( |
| 137 | + Effect.gen(function* () { |
| 138 | + const disposed: Array<string> = []; |
| 139 | + const runtime = yield* createRuntime(); |
| 140 | + yield* runtime.reconcile([ |
| 141 | + { |
| 142 | + id: "acme.finalizers", |
| 143 | + version: "1.0.0", |
| 144 | + activate(context) { |
| 631 | 145 | context.onDispose(() => { |
| 632 | | − disposed = true; |
| 146 | + disposed.push("first"); |
| 633 | 147 | }); |
| 634 | | − } catch (error) { |
| 635 | | − lateFailure = error; |
| 636 | | − } |
| 637 | | − }); |
| 638 | | − }, |
| 639 | | − }, |
| 640 | | − ]); |
| 641 | | − |
| 642 | | − expect(lateFailure).toBeInstanceOf(Error); |
| 643 | | − expect((lateFailure as Error).message).toContain("no longer active"); |
| 644 | | − await runtime.dispose(); |
| 645 | | − expect(disposed).toBe(false); |
| 646 | | − }); |
| 647 | | − |
| 648 | | − it("rejects finalizers registered after activation settles", async () => { |
| 649 | | − let registerLateFinalizer!: () => void; |
| 650 | | − let disposed = false; |
| 651 | | − const runtime = createRuntime(); |
| 652 | | − await runtime.reconcile([ |
| 653 | | − { |
| 654 | | − id: "acme.late-finalizer", |
| 655 | | − version: "1.0.0", |
| 656 | | − activate(context) { |
| 657 | | − registerLateFinalizer = () => |
| 148 | + context.onDispose(() => { |
| 149 | + disposed.push("second"); |
| 150 | + }); |
| 151 | + }, |
| 152 | + }, |
| 153 | + ]); |
| 154 | + |
| 155 | + yield* runtime.reconcile([]); |
| 156 | + |
| 157 | + expect(disposed).toEqual(["second", "first"]); |
| 158 | + yield* runtime.dispose; |
| 159 | + }), |
| 160 | + ), |
| 161 | + ); |
| 162 | + |
| 163 | + it.effect("keeps unchanged plugin scopes alive across reconciliation", () => |
| 164 | + Effect.scoped( |
| 165 | + Effect.gen(function* () { |
| 166 | + let activations = 0; |
| 167 | + let disposals = 0; |
| 168 | + const stable: PluginDefinition = { |
| 169 | + id: "acme.stable", |
| 170 | + version: "1.0.0", |
| 171 | + activate(context) { |
| 172 | + activations += 1; |
| 658 | 173 | context.onDispose(() => { |
| 659 | | − disposed = true; |
| 174 | + disposals += 1; |
| 660 | 175 | }); |
| 661 | | − }, |
| 662 | | − }, |
| 663 | | − ]); |
| 664 | | − |
| 665 | | − expect(registerLateFinalizer).toThrow("no longer active"); |
| 666 | | − await runtime.dispose(); |
| 667 | | − expect(disposed).toBe(false); |
| 668 | | − }); |
| 669 | | − |
| 670 | | − it("snapshots the requested definitions before waiting for an earlier transition", async () => { |
| 671 | | − let releaseActivation!: () => void; |
| 672 | | − const activationGate = new Promise<void>((resolve) => { |
| 673 | | − releaseActivation = resolve; |
| 674 | | − }); |
| 675 | | − const runtime = createRuntime(); |
| 676 | | − const first = runtime.reconcile([ |
| 677 | | − { |
| 678 | | − id: "acme.blocking", |
| 679 | | − version: "1.0.0", |
| 680 | | − async activate() { |
| 681 | | − await activationGate; |
| 682 | | − }, |
| 683 | | − }, |
| 684 | | − ]); |
| 685 | | − const requested: Array<PluginDefinition> = [provider()]; |
| 686 | | − const second = runtime.reconcile(requested); |
| 687 | | − requested.splice(0); |
| 688 | | − |
| 689 | | − releaseActivation(); |
| 690 | | − await first; |
| 691 | | − const snapshot = await second; |
| 692 | | − |
| 693 | | − expect(snapshot.active).toEqual(["acme.database"]); |
| 694 | | − await runtime.dispose(); |
| 695 | | − }); |
| 696 | | − |
| 697 | | − it("snapshots each requested definition and its declarations before queueing", async () => { |
| 698 | | − let releaseActivation!: () => void; |
| 699 | | − const activationGate = new Promise<void>((resolve) => { |
| 700 | | − releaseActivation = resolve; |
| 701 | | − }); |
| 702 | | − const runtime = createRuntime(); |
| 703 | | − const first = runtime.reconcile([ |
| 704 | | − { |
| 705 | | − id: "acme.blocking", |
| 706 | | − version: "1.0.0", |
| 707 | | − async activate() { |
| 708 | | − await activationGate; |
| 709 | | − }, |
| 710 | | − }, |
| 711 | | − ]); |
| 712 | | − const requires = ["acme.database@1"]; |
| 713 | | − const provides: Record<string, unknown> = { "acme.consumer@1": true }; |
| 714 | | − const requestedDefinition: PluginDefinition = { |
| 715 | | − id: "acme.consumer", |
| 716 | | − version: "1.0.0", |
| 717 | | − requires, |
| 718 | | − provides, |
| 719 | | − activate() {}, |
| 720 | | − }; |
| 721 | | − const second = runtime.reconcile([provider(), requestedDefinition]); |
| 722 | | − |
| 723 | | − (requestedDefinition as { id: string }).id = "acme.changed"; |
| 724 | | − requires[0] = "acme.missing@1"; |
| 725 | | − delete provides["acme.consumer@1"]; |
| 726 | | − |
| 727 | | − releaseActivation(); |
| 728 | | − await first; |
| 729 | | − const snapshot = await second; |
| 730 | | − |
| 731 | | − expect(snapshot.active).toEqual(["acme.database", "acme.consumer"]); |
| 732 | | − await runtime.dispose(); |
| 733 | | − }); |
| 734 | | − |
| 735 | | − it("rejects capabilities that the plugin did not declare", async () => { |
| 736 | | − const runtime = createRuntime(); |
| 737 | | − const undeclaredConsumer: PluginDefinition = { |
| 738 | | − id: "acme.undeclared-consumer", |
| 739 | | − version: "1.0.0", |
| 740 | | − activate(context) { |
| 741 | | − context.resolve("acme.database@1"); |
| 742 | | − }, |
| 743 | | − }; |
| 744 | | − |
| 745 | | − let undeclaredFailure: unknown; |
| 746 | | − try { |
| 747 | | − await runtime.reconcile([provider(), undeclaredConsumer]); |
| 748 | | − } catch (error) { |
| 749 | | − undeclaredFailure = error; |
| 750 | | − } |
| 751 | | − expect(failureMessage(undeclaredFailure)).toContain("did not declare"); |
| 752 | | − expect(runtime.snapshot().active).toEqual([]); |
| 753 | | − await runtime.dispose(); |
| 754 | | − }); |
| 755 | | − |
| 756 | | − it("rolls back a plugin scope when contribution snapshotting throws", async () => { |
| 757 | | − let disposed = false; |
| 758 | | − const runtime = createRuntime(); |
| 759 | | − const badContribution = { |
| 760 | | − get id(): string { |
| 761 | | − throw new Error("bad contribution getter"); |
| 762 | | − }, |
| 763 | | − label: "bad", |
| 764 | | − }; |
| 765 | | − |
| 766 | | − let snapshotFailure: unknown; |
| 767 | | − try { |
| 768 | | − await runtime.reconcile([ |
| 769 | | − { |
| 770 | | − id: "acme.snapshot-defect", |
| 176 | + }, |
| 177 | + }; |
| 178 | + const runtime = yield* createRuntime(); |
| 179 | + |
| 180 | + yield* runtime.reconcile([stable]); |
| 181 | + yield* runtime.reconcile([stable]); |
| 182 | + |
| 183 | + expect(activations).toBe(1); |
| 184 | + expect(disposals).toBe(0); |
| 185 | + yield* runtime.dispose; |
| 186 | + expect(disposals).toBe(1); |
| 187 | + }), |
| 188 | + ), |
| 189 | + ); |
| 190 | + |
| 191 | + it.effect("treats reordered requirements and providers as the same definition", () => |
| 192 | + Effect.scoped( |
| 193 | + Effect.gen(function* () { |
| 194 | + let activations = 0; |
| 195 | + const firstService = {}; |
| 196 | + const secondService = {}; |
| 197 | + const activate: PluginDefinition["activate"] = () => { |
| 198 | + activations += 1; |
| 199 | + }; |
| 200 | + const dependencies: ReadonlyArray<PluginDefinition> = [ |
| 201 | + { |
| 202 | + id: "acme.first-dependency", |
| 203 | + version: "1.0.0", |
| 204 | + provides: { "acme.first@1": true }, |
| 205 | + activate() {}, |
| 206 | + }, |
| 207 | + { |
| 208 | + id: "acme.second-dependency", |
| 209 | + version: "1.0.0", |
| 210 | + provides: { "acme.second@1": true }, |
| 211 | + activate() {}, |
| 212 | + }, |
| 213 | + ]; |
| 214 | + const runtime = yield* createRuntime(); |
| 215 | + yield* runtime.reconcile([ |
| 216 | + ...dependencies, |
| 217 | + { |
| 218 | + id: "acme.stable-order", |
| 219 | + version: "1.0.0", |
| 220 | + requires: ["acme.first@1", "acme.second@1"], |
| 221 | + provides: { "acme.output-one@1": firstService, "acme.output-two@1": secondService }, |
| 222 | + activate, |
| 223 | + }, |
| 224 | + ]); |
| 225 | + |
| 226 | + yield* runtime.reconcile([ |
| 227 | + ...dependencies, |
| 228 | + { |
| 229 | + id: "acme.stable-order", |
| 230 | + version: "1.0.0", |
| 231 | + requires: ["acme.second@1", "acme.first@1"], |
| 232 | + provides: { "acme.output-two@1": secondService, "acme.output-one@1": firstService }, |
| 233 | + activate, |
| 234 | + }, |
| 235 | + ]); |
| 236 | + |
| 237 | + expect(activations).toBe(1); |
| 238 | + yield* runtime.dispose; |
| 239 | + }), |
| 240 | + ), |
| 241 | + ); |
| 242 | + |
| 243 | + it.effect("restarts a plugin when its activation implementation changes", () => |
| 244 | + Effect.scoped( |
| 245 | + Effect.gen(function* () { |
| 246 | + const runtime = yield* createRuntime(); |
| 247 | + const first: PluginDefinition = { |
| 248 | + id: "acme.implementation", |
| 249 | + version: "1.0.0", |
| 250 | + activate(context) { |
| 251 | + context.register("commands", { id: "implementation", label: "first" }); |
| 252 | + }, |
| 253 | + }; |
| 254 | + const second: PluginDefinition = { |
| 255 | + id: "acme.implementation", |
| 256 | + version: "1.0.0", |
| 257 | + activate(context) { |
| 258 | + context.register("commands", { id: "implementation", label: "second" }); |
| 259 | + }, |
| 260 | + }; |
| 261 | + |
| 262 | + yield* runtime.reconcile([first]); |
| 263 | + const snapshot = yield* runtime.reconcile([second]); |
| 264 | + |
| 265 | + expect(contributionLabels(snapshot, "commands")).toEqual(["second"]); |
| 266 | + yield* runtime.dispose; |
| 267 | + }), |
| 268 | + ), |
| 269 | + ); |
| 270 | + |
| 271 | + it.effect( |
| 272 | + "restarts a changed provider and its dependents without touching independent plugins", |
| 273 | + () => |
| 274 | + Effect.scoped( |
| 275 | + Effect.gen(function* () { |
| 276 | + let independentActivations = 0; |
| 277 | + const lifecycle: Array<string> = []; |
| 278 | + const independent: PluginDefinition = { |
| 279 | + id: "acme.independent", |
| 280 | + version: "1.0.0", |
| 281 | + activate() { |
| 282 | + independentActivations += 1; |
| 283 | + }, |
| 284 | + }; |
| 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 | 485 | version: "1.0.0", |
| 772 | 486 | activate(context) { |
| 773 | 487 | context.onDispose(() => { |
| 774 | | − disposed = true; |
| 488 | + throw cleanupError; |
| 775 | 489 | }); |
| 776 | | − context.register("commands", badContribution); |
| 490 | + throw activationError; |
| 777 | 491 | }, |
| 778 | | − }, |
| 779 | | − ]); |
| 780 | | − } catch (error) { |
| 781 | | − snapshotFailure = error; |
| 782 | | − } |
| 783 | | − |
| 784 | | − expect(failureMessage(snapshotFailure)).toContain("bad contribution getter"); |
| 785 | | − expect(disposed).toBe(true); |
| 786 | | − expect(runtime.snapshot().active).toEqual([]); |
| 787 | | − await runtime.dispose(); |
| 788 | | − }); |
| 789 | | − |
| 790 | | − it("attempts every plugin cleanup when one finalizer fails", async () => { |
| 791 | | − const disposed: Array<string> = []; |
| 792 | | − const runtime = createRuntime(); |
| 793 | | − await runtime.reconcile([ |
| 794 | | − { |
| 795 | | − id: "acme.first-cleanup", |
| 796 | | − version: "1.0.0", |
| 797 | | − activate(context) { |
| 798 | | − context.onDispose(() => { |
| 799 | | − disposed.push("first"); |
| 492 | + }; |
| 493 | + |
| 494 | + const activationFailure = yield* failureOf(runtime.reconcile([broken])); |
| 495 | + expect(failureCause(activationFailure)).toBe(activationError); |
| 496 | + expect(cleanupEvents).toHaveLength(1); |
| 497 | + expect(cleanupEvents[0]?.phase).toBe("rollback"); |
| 498 | + expect(failureCause(cleanupEvents[0]?.error)).toBe(cleanupError); |
| 499 | + yield* runtime.dispose; |
| 500 | + }), |
| 501 | + ), |
| 502 | + ); |
| 503 | + |
| 504 | + it.effect( |
| 505 | + "returns the committed snapshot when retiring an old plugin reports cleanup errors", |
| 506 | + () => |
| 507 | + Effect.scoped( |
| 508 | + Effect.gen(function* () { |
| 509 | + const cleanupError = new Error("retirement cleanup failed"); |
| 510 | + const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; |
| 511 | + const runtime = yield* createRuntime({ |
| 512 | + onCleanupError: (event) => { |
| 513 | + cleanupEvents.push(event); |
| 514 | + throw new Error("cleanup observer failed"); |
| 515 | + }, |
| 800 | 516 | }); |
| 801 | | − }, |
| 802 | | − }, |
| 803 | | − { |
| 804 | | − id: "acme.second-cleanup", |
| 805 | | − version: "1.0.0", |
| 806 | | − activate(context) { |
| 807 | | − context.onDispose(() => { |
| 808 | | − disposed.push("second"); |
| 809 | | − throw new Error("cleanup failed"); |
| 517 | + yield* runtime.reconcile([ |
| 518 | + { |
| 519 | + id: "acme.retirement-error", |
| 520 | + version: "1.0.0", |
| 521 | + activate(context) { |
| 522 | + context.register("commands", { id: "retirement", label: "old" }); |
| 523 | + context.onDispose(() => { |
| 524 | + throw cleanupError; |
| 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 | | − }, |
| 812 | | − }, |
| 813 | | − ]); |
| 814 | | − |
| 815 | | − await expect(runtime.dispose()).rejects.toThrow(); |
| 816 | | − |
| 817 | | − expect(disposed).toEqual(["second", "first"]); |
| 818 | | − }); |
| 819 | | − |
| 820 | | − it("keeps the old plugin active when a replacement fails", async () => { |
| 821 | | − const runtime = createRuntime(); |
| 822 | | − await runtime.reconcile([provider()]); |
| 823 | | − const broken: PluginDefinition = { |
| 824 | | − ...provider("2.0.0"), |
| 825 | | − async activate(context) { |
| 826 | | − context.register("status", { id: "database", label: "database 2.0.0" }); |
| 827 | | − throw new Error("candidate failed"); |
| 828 | | − }, |
| 829 | | − }; |
| 830 | | − |
| 831 | | − let candidateFailure: unknown; |
| 832 | | − try { |
| 833 | | − await runtime.reconcile([broken]); |
| 834 | | − } catch (error) { |
| 835 | | − candidateFailure = error; |
| 836 | | − } |
| 837 | | − expect(failureMessage(candidateFailure)).toContain("candidate failed"); |
| 838 | | − |
| 839 | | − expect(runtime.snapshot().active).toEqual(["acme.database"]); |
| 840 | | − expect(contributionLabels(runtime.snapshot(), "status")).toEqual(["database 1.0.0"]); |
| 841 | | − await runtime.dispose(); |
| 842 | | − }); |
| 843 | | − |
| 844 | | − it("rejects dependency cycles without disturbing the current composition", async () => { |
| 845 | | − const runtime = createRuntime(); |
| 846 | | − await runtime.reconcile([provider()]); |
| 847 | | − const alpha: PluginDefinition = { |
| 848 | | − id: "acme.alpha", |
| 849 | | − version: "1.0.0", |
| 850 | | − requires: ["acme.beta@1"], |
| 851 | | − provides: { "acme.alpha@1": true }, |
| 852 | | − activate() {}, |
| 853 | | − }; |
| 854 | | − const beta: PluginDefinition = { |
| 855 | | − id: "acme.beta", |
| 856 | | − version: "1.0.0", |
| 857 | | − requires: ["acme.alpha@1"], |
| 858 | | − provides: { "acme.beta@1": true }, |
| 859 | | − activate() {}, |
| 860 | | − }; |
| 861 | | − |
| 862 | | − await expect(runtime.reconcile([alpha, beta])).rejects.toThrow("cycle"); |
| 863 | | − |
| 864 | | − expect(runtime.snapshot().active).toEqual(["acme.database"]); |
| 865 | | − await runtime.dispose(); |
| 866 | | − }); |
| 678 | + const runtime = yield* createRuntime(); |
| 679 | + const background = makeEffectCallback< |
| 680 | + PluginRuntimeSnapshot, |
| 681 | + PluginRuntimeReconcileError |
| 682 | + >(); |
| 683 | + const backgroundFiber = yield* Effect.forkChild(background.await); |
| 684 | + yield* Effect.yieldNow; |
| 685 | + |
| 686 | + yield* runtime.reconcile([ |
| 687 | + { |
| 688 | + id: "acme.background-task", |
| 689 | + version: "1.0.0", |
| 690 | + activate() { |
| 691 | + void gate.then(() => background.resume(runtime.reconcile([]))); |
| 692 | + }, |
| 693 | + }, |
| 694 | + ]); |
| 695 | + |
| 696 | + releaseBackground(); |
| 697 | + expect(yield* Fiber.join(backgroundFiber)).toBeDefined(); |
| 698 | + yield* runtime.dispose; |
| 699 | + }), |
| 700 | + ), |
| 701 | + ); |
| 702 | + |
| 703 | + it.effect("allows microtasks spawned by synchronous plugin callbacks to use the runtime", () => |
| 704 | + Effect.scoped( |
| 705 | + Effect.gen(function* () { |
| 706 | + let backgroundScheduled = false; |
| 707 | + const runtime = yield* createRuntime(); |
| 708 | + const background = makeEffectCallback< |
| 709 | + PluginRuntimeSnapshot, |
| 710 | + PluginRuntimeReconcileError |
| 711 | + >(); |
| 712 | + const backgroundFiber = yield* Effect.forkChild(background.await); |
| 713 | + yield* Effect.yieldNow; |
| 714 | + |
| 715 | + yield* runtime.reconcile([ |
| 716 | + { |
| 717 | + id: "acme.microtask", |
| 718 | + version: "1.0.0", |
| 719 | + activate() { |
| 720 | + queueMicrotask(() => { |
| 721 | + backgroundScheduled = true; |
| 722 | + background.resume(runtime.reconcile([])); |
| 723 | + }); |
| 724 | + }, |
| 725 | + }, |
| 726 | + ]); |
| 727 | + |
| 728 | + expect(backgroundScheduled).toBe(true); |
| 729 | + expect(yield* Fiber.join(backgroundFiber)).toBeDefined(); |
| 730 | + yield* runtime.dispose; |
| 731 | + }), |
| 732 | + ), |
| 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 | } |