clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/184-task-notice-migration
lofi / package / runtime / resource-lifecycle_test.ts
resource-lifecycle_test.ts 4.3K
1import {2 // Package contract tests.3 AdapterDisposedError,4 AdapterLifecycle,5 createSerializedResourceState,6 getResource,7 recreateResource,8 replaceResource,9 shutdownResource,10} from "./resource-lifecycle.ts";11import { assert, assertCount } from "./test-assert.ts";1213const test = (globalThis as unknown as {14 Deno: { test(name: string, body: () => void | Promise<void>): void };15}).Deno.test;1617test("concurrent recreation is single-flight and shutdown is idempotent", async () => {18 const state = createSerializedResourceState<{ id: number }>();19 let created = 0;20 let destroyed = 0;21 const create = () => Promise.resolve({ id: ++created });22 const destroy = (_value: { id: number }) => {23 destroyed += 1;24 return Promise.resolve();25 };2627 const initial = await getResource(state, create);28 assert(initial.id === 1, "the initial client must be created once");29 const [first, second] = await Promise.all([30 recreateResource(state, create, destroy),31 recreateResource(state, create, destroy),32 ]);33 assert(first === second, "concurrent callers must receive the same replacement");34 assertCount(created, 2, "one concurrent recreation must create one replacement");35 assertCount(destroyed, 1, "one concurrent recreation must destroy the old client once");3637 await Promise.all([38 shutdownResource(state, destroy),39 shutdownResource(state, destroy),40 ]);41 assertCount(destroyed, 2, "concurrent shutdown must destroy the replacement once");42});4344test("principal replacement prepares, destroys, then creates behind one serialized gate", async () => {45 const state = createSerializedResourceState<{ id: number }>();46 const events: string[] = [];47 await getResource(state, () => Promise.resolve({ id: 1 }));4849 const replaced = await replaceResource(50 state,51 () => {52 events.push("secret-saved");53 return Promise.resolve();54 },55 () => {56 events.push("client-created");57 return Promise.resolve({ id: 2 });58 },59 (value) => {60 events.push(`client-${value.id}-logged-out`);61 return Promise.resolve();62 },63 );6465 assert(replaced.id === 2, "replacement did not become active");66 assert(67 events.join(",") === "secret-saved,client-1-logged-out,client-created",68 `principal replacement ordering changed: ${events.join(",")}`,69 );70});7172test("replacement queues behind an in-flight recreation and still prepares", async () => {73 const state = createSerializedResourceState<{ id: number }>();74 const destroy = (_value: { id: number }) => Promise.resolve();75 await getResource(state, () => Promise.resolve({ id: 1 }));7677 let prepared = 0;78 const recreation = recreateResource(state, () => Promise.resolve({ id: 2 }), destroy);79 const replaced = await replaceResource(80 state,81 () => {82 prepared += 1;83 return Promise.resolve();84 },85 () => Promise.resolve({ id: 3 }),86 destroy,87 );8889 assertCount(prepared, 1, "replacement during recreation must still run its preparation");90 assert(replaced.id === 3, "replacement must resolve to its own resource, not the recreation's");91 assert((await recreation).id === 2, "the earlier recreation must keep its own result");92 assert(93 (await getResource(state, () => Promise.resolve({ id: 4 }))).id === 3,94 "the replacement must be the resource left active",95 );96});9798test("HMR disposal before dependency resolution never attaches an obsolete adapter", async () => {99 const lifecycle = new AdapterLifecycle<{ id: number }, { id: number }>();100 let resolveDependency: ((value: { id: number }) => void) | undefined;101 const deferred = new Promise<{ id: number }>((resolve) => resolveDependency = resolve);102 let attached = 0;103 let closed = 0;104 const pending = lifecycle.get(105 () => deferred,106 (dependency) => {107 attached += 1;108 return dependency;109 },110 );111112 lifecycle.dispose((_instance) => {113 closed += 1;114 return Promise.resolve();115 });116 resolveDependency?.({ id: 1 });117118 let rejected = false;119 try {120 await pending;121 } catch (error) {122 rejected = error instanceof AdapterDisposedError;123 }124 assert(rejected, "the obsolete adapter promise must reject as disposed");125 assert(attached === 0, "an obsolete module must not register an adapter or mutation listener");126 assert(closed === 0, "nothing should close when attachment never happened");127});