Squashed 'third_party/pi/' content from commit d981de122
git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
34
packages/chord/test/boundary.test.ts
Normal file
34
packages/chord/test/boundary.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourceDirectory = resolve(packageDirectory, "src");
|
||||
const IMPORT_SPECIFIER = /(?:import|export)\s+(?:type\s+)?(?:[^;]*?\sfrom\s*)?["']([^"']+)["']/gu;
|
||||
|
||||
describe("package boundary", () => {
|
||||
test("does not depend on Pi packages or files outside Chord", async () => {
|
||||
const manifest = JSON.parse(await readFile(resolve(packageDirectory, "package.json"), "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
expect(Object.keys(manifest.dependencies ?? {}).filter((name) => name.startsWith("@earendil-works/pi-"))).toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
const paths = (await readdir(sourceDirectory, { recursive: true })).filter((path) => path.endsWith(".ts")).sort();
|
||||
const violations: string[] = [];
|
||||
for (const path of paths) {
|
||||
const file = resolve(sourceDirectory, path);
|
||||
const source = await readFile(file, "utf8");
|
||||
for (const match of source.matchAll(IMPORT_SPECIFIER)) {
|
||||
const specifier = match[1]!;
|
||||
if (specifier.startsWith("@earendil-works/pi-")) violations.push(`${path}: ${specifier}`);
|
||||
if (specifier.startsWith(".") && !resolve(dirname(file), specifier).startsWith(`${sourceDirectory}/`)) {
|
||||
violations.push(`${path}: ${specifier}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
259
packages/chord/test/bundle.test.ts
Normal file
259
packages/chord/test/bundle.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { bundleFacetPackage, bundleFacets } from "../src/bundler.ts";
|
||||
import { createFacetHost, defineFacet, defineService } from "../src/index.ts";
|
||||
import {
|
||||
createFacetBundleArtifactLoader,
|
||||
createFacetBundleLoader,
|
||||
readFacetBundleArtifact,
|
||||
readFacetBundleManifest,
|
||||
} from "../src/node.ts";
|
||||
|
||||
interface GenerationValue {
|
||||
read(): string;
|
||||
}
|
||||
|
||||
const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const temporaryDirectories: string[] = [];
|
||||
const GenerationValue = defineService<GenerationValue>("test.bundle.generation", { local: true });
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { force: true, recursive: true })));
|
||||
});
|
||||
|
||||
describe("facet bundles", () => {
|
||||
test("builds independent content-addressed entries and loads fresh reloadable generations", async () => {
|
||||
const directory = await mkdtemp(join(packageDirectory, ".bundle-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const sourceDirectory = join(directory, "src");
|
||||
const outputDirectory = join(directory, "bundle");
|
||||
await mkdir(sourceDirectory);
|
||||
await writeFile(
|
||||
join(sourceDirectory, "helper.ts"),
|
||||
'export const decorate = (value: string): string => "generation:" + value;\n',
|
||||
);
|
||||
const entryPath = join(sourceDirectory, "entry.ts");
|
||||
const presentationPath = join(sourceDirectory, "presentation.ts");
|
||||
await writeGeneration(entryPath, "A");
|
||||
await writeFile(presentationPath, 'export default { id: "bundle-presentation", setup() {} };\n');
|
||||
const facetEntries = { presentation: presentationPath, worker: entryPath };
|
||||
|
||||
const firstBuild = await bundleFacets({
|
||||
plugin: { id: "test-bundle", version: "1" },
|
||||
entries: facetEntries,
|
||||
outdir: outputDirectory,
|
||||
sourceMap: true,
|
||||
});
|
||||
const firstEntry = firstBuild.manifest.entries.worker!;
|
||||
expect(firstEntry.file).toMatch(/^facet-[a-f0-9]{12}-[A-Z0-9]+\.cjs$/u);
|
||||
expect(firstEntry.sourceMap).toBe(`${firstEntry.file}.map`);
|
||||
expect(firstEntry.externalImports).toEqual(["@earendil-works/chord"]);
|
||||
expect(firstBuild.manifest.entries.presentation!.file).not.toBe(firstEntry.file);
|
||||
expect((await readdir(outputDirectory)).filter((path) => path.endsWith(".cjs"))).toHaveLength(2);
|
||||
const firstSource = await readFile(join(outputDirectory, firstEntry.file), "utf8");
|
||||
expect(firstSource).toContain('require("@earendil-works/chord")');
|
||||
expect(firstSource).toContain("module.exports");
|
||||
expect((await readFile(firstBuild.manifestPath, "utf8")).endsWith("\n")).toBe(true);
|
||||
|
||||
const presentation = await createFacetBundleLoader({
|
||||
manifestPath: firstBuild.manifestPath,
|
||||
entry: "presentation",
|
||||
}).load();
|
||||
expect(presentation.facets.map(({ id }) => id)).toEqual(["bundle-presentation"]);
|
||||
await presentation.dispose();
|
||||
|
||||
const artifact = await readFacetBundleArtifact({
|
||||
manifestPath: firstBuild.manifestPath,
|
||||
entry: "worker",
|
||||
});
|
||||
const materializedDirectory = join(directory, "materialized");
|
||||
const transported = await createFacetBundleArtifactLoader({
|
||||
artifact: structuredClone(artifact),
|
||||
temporaryDirectory: materializedDirectory,
|
||||
}).load();
|
||||
expect(transported.facets.map(({ id }) => id)).toEqual(["bundle-provider"]);
|
||||
expect(await readdir(materializedDirectory)).toHaveLength(1);
|
||||
await transported.dispose();
|
||||
expect(await readdir(materializedDirectory)).toEqual([]);
|
||||
|
||||
const secondBuild = await bundleFacets({
|
||||
plugin: { id: "test-bundle", version: "1" },
|
||||
entries: facetEntries,
|
||||
outdir: outputDirectory,
|
||||
sourceMap: true,
|
||||
});
|
||||
expect(secondBuild.manifest.entries.worker).toEqual(firstEntry);
|
||||
|
||||
const loader = createFacetBundleLoader({ manifestPath: secondBuild.manifestPath, entry: "worker" });
|
||||
const loadedA = await loader.load();
|
||||
const loadedACopy = await loader.load();
|
||||
expect(loadedACopy.facets[0]).not.toBe(loadedA.facets[0]);
|
||||
await loadedACopy.dispose();
|
||||
|
||||
let retained: GenerationValue | undefined;
|
||||
const consumer = defineFacet({
|
||||
id: "bundle-consumer",
|
||||
setup(env) {
|
||||
retained = env.use(GenerationValue);
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, ...loadedA.facets] });
|
||||
expect(retained!.read()).toBe("generation:A");
|
||||
|
||||
await writeGeneration(entryPath, "B");
|
||||
const thirdBuild = await bundleFacets({
|
||||
plugin: { id: "test-bundle", version: "2" },
|
||||
entries: facetEntries,
|
||||
outdir: outputDirectory,
|
||||
sourceMap: true,
|
||||
});
|
||||
expect(thirdBuild.manifest.entries.worker!.file).not.toBe(firstEntry.file);
|
||||
const loadedB = await loader.load();
|
||||
await host.reload(loadedB.facets);
|
||||
await loadedA.dispose();
|
||||
expect(retained!.read()).toBe("generation:B");
|
||||
|
||||
await host.dispose();
|
||||
await loadedB.dispose();
|
||||
});
|
||||
|
||||
test("loads host externals through the controlled CommonJS require", async () => {
|
||||
const directory = await mkdtemp(join(packageDirectory, ".bundle-external-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const externalPath = join(directory, "host.mjs");
|
||||
const entryPath = join(directory, "entry.ts");
|
||||
const outputDirectory = join(directory, "bundle");
|
||||
await writeFile(externalPath, 'export const named = "host-named";\n');
|
||||
await writeFile(
|
||||
entryPath,
|
||||
'import { named } from "@example/host";\n' +
|
||||
'export const loadDynamic = () => import("@example/dynamic");\n' +
|
||||
'if (named !== "host-named") throw new Error("external mismatch");\n' +
|
||||
'export default { id: "external-facet", setup() {} };\n',
|
||||
);
|
||||
const result = await bundleFacets({
|
||||
plugin: { id: "external-bundle" },
|
||||
entries: { worker: entryPath },
|
||||
external: ["@example/host", "@example/dynamic"],
|
||||
outdir: outputDirectory,
|
||||
});
|
||||
const entry = result.manifest.entries.worker!;
|
||||
const source = await readFile(join(outputDirectory, entry.file), "utf8");
|
||||
expect(source).not.toContain("import(");
|
||||
expect(source).toContain('require("@example/dynamic")');
|
||||
const loaded = await createFacetBundleLoader({
|
||||
manifestPath: result.manifestPath,
|
||||
entry: "worker",
|
||||
resolveExternal: (specifier) =>
|
||||
specifier === "@example/host" || specifier === "@example/dynamic" ? pathToFileURL(externalPath) : undefined,
|
||||
}).load();
|
||||
expect(loaded.facets.map(({ id }) => id)).toEqual(["external-facet"]);
|
||||
await loaded.dispose();
|
||||
});
|
||||
|
||||
test("builds plugin packages from conventional and configured facet entries", async () => {
|
||||
const directory = await mkdtemp(join(packageDirectory, ".bundle-package-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const sourceDirectory = join(directory, "src");
|
||||
await mkdir(sourceDirectory);
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(directory, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "@example/conventional-plugin",
|
||||
version: "1.2.3",
|
||||
peerDependencies: { "@example/host": "^1.0.0" },
|
||||
})}\n`,
|
||||
),
|
||||
writeFile(
|
||||
join(sourceDirectory, "session.ts"),
|
||||
'import "@example/host/plugin"; export default { id: "package-session", setup() {} };\n',
|
||||
),
|
||||
writeFile(join(sourceDirectory, "tui.ts"), 'export default { id: "package-tui", setup() {} };\n'),
|
||||
writeFile(join(sourceDirectory, "contract.ts"), "export const ignored = true;\n"),
|
||||
writeFile(join(sourceDirectory, "presentation.ts"), 'export default { id: "configured-tui", setup() {} };\n'),
|
||||
]);
|
||||
|
||||
const conventional = await bundleFacetPackage({
|
||||
packagePath: directory,
|
||||
outdir: join(directory, "build"),
|
||||
defaultFacets: { session: "src/session.ts", tui: "src/tui.ts", browser: "src/browser.ts" },
|
||||
});
|
||||
expect(conventional.packageDirectory).toBe(directory);
|
||||
expect(conventional.manifest.plugin).toEqual({ id: "@example/conventional-plugin", version: "1.2.3" });
|
||||
expect(Object.keys(conventional.manifest.entries)).toEqual(["session", "tui"]);
|
||||
expect(conventional.manifest.entries.session?.externalImports).toEqual(["@example/host/plugin"]);
|
||||
expect(conventional.manifest.entries.tui?.sourceMap).toMatch(/\.cjs\.map$/u);
|
||||
|
||||
await writeFile(
|
||||
join(directory, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "@example/conventional-plugin",
|
||||
version: "2.0.0",
|
||||
chord: { facets: { session: false, tui: "src/presentation.ts" }, sourceMap: false },
|
||||
})}\n`,
|
||||
);
|
||||
const configured = await bundleFacetPackage({
|
||||
packagePath: join(directory, "package.json"),
|
||||
outdir: join(directory, "build"),
|
||||
defaultFacets: { session: "src/session.ts", tui: "src/tui.ts" },
|
||||
});
|
||||
expect(Object.keys(configured.manifest.entries)).toEqual(["tui"]);
|
||||
expect(configured.manifest.entries.tui?.sourceMap).toBeUndefined();
|
||||
const loaded = await createFacetBundleLoader({
|
||||
manifestPath: configured.manifestPath,
|
||||
entry: "tui",
|
||||
}).load();
|
||||
expect(loaded.facets.map(({ id }) => id)).toEqual(["configured-tui"]);
|
||||
await loaded.dispose();
|
||||
});
|
||||
|
||||
test("rejects invalid plugin package entry configuration", async () => {
|
||||
const directory = await mkdtemp(join(packageDirectory, ".bundle-package-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
await writeFile(
|
||||
join(directory, "package.json"),
|
||||
`${JSON.stringify({
|
||||
name: "invalid-plugin",
|
||||
version: "1.0.0",
|
||||
chord: { facets: { tui: "../outside.ts" } },
|
||||
})}\n`,
|
||||
);
|
||||
await expect(bundleFacetPackage({ packagePath: directory, outdir: join(directory, "build") })).rejects.toThrow(
|
||||
"escapes the package directory",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects corrupt entries and invalid module exports", async () => {
|
||||
const directory = await mkdtemp(join(packageDirectory, ".bundle-test-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const entryPath = join(directory, "entry.ts");
|
||||
const outputDirectory = join(directory, "bundle");
|
||||
await writeFile(entryPath, "export default { id: 'missing-setup' };\n");
|
||||
const result = await bundleFacets({
|
||||
plugin: { id: "invalid-bundle" },
|
||||
entries: { invalid: entryPath },
|
||||
outdir: outputDirectory,
|
||||
});
|
||||
const loader = createFacetBundleLoader({ manifestPath: result.manifestPath, entry: "invalid" });
|
||||
await expect(loader.load()).rejects.toThrow("has no setup function");
|
||||
|
||||
const manifest = await readFacetBundleManifest(result.manifestPath);
|
||||
await writeFile(join(outputDirectory, manifest.entries.invalid!.file), "export default {};\n");
|
||||
await expect(loader.load()).rejects.toThrow("integrity check failed");
|
||||
});
|
||||
});
|
||||
|
||||
async function writeGeneration(path: string, generation: string): Promise<void> {
|
||||
await writeFile(
|
||||
path,
|
||||
`import "@earendil-works/chord";\n` +
|
||||
`import { decorate } from "./helper.ts";\n` +
|
||||
`const Value = { id: "test.bundle.generation", local: true };\n` +
|
||||
`export default { id: "bundle-provider", setup(env) {\n` +
|
||||
` env.provide(Value, { read() { return decorate(${JSON.stringify(generation)}); } });\n` +
|
||||
`}};\n`,
|
||||
);
|
||||
}
|
||||
89
packages/chord/test/context.test.ts
Normal file
89
packages/chord/test/context.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
awaitWithContext,
|
||||
BACKGROUND_CONTEXT,
|
||||
createContextKey,
|
||||
TODO_CONTEXT,
|
||||
withAbortSignal,
|
||||
withCancel,
|
||||
withContextValue,
|
||||
withoutAbortSignal,
|
||||
} from "../src/context/index.ts";
|
||||
|
||||
describe("Context", () => {
|
||||
it("provides distinct empty root contexts", () => {
|
||||
const key = createContextKey<string>("value");
|
||||
|
||||
expect(TODO_CONTEXT).not.toBe(BACKGROUND_CONTEXT);
|
||||
expect(TODO_CONTEXT.abortSignal).toBeUndefined();
|
||||
expect(TODO_CONTEXT.value(key)).toBeUndefined();
|
||||
expect(String(BACKGROUND_CONTEXT)).toBe("[Context BACKGROUND_CONTEXT]");
|
||||
expect(String(TODO_CONTEXT)).toBe("[Context TODO_CONTEXT]");
|
||||
});
|
||||
|
||||
it("layers typed values without modifying parents", () => {
|
||||
const firstKey = createContextKey<string>("first");
|
||||
const secondKey = createContextKey<number>("second");
|
||||
const first = withContextValue(firstKey, "one", BACKGROUND_CONTEXT);
|
||||
const second = withContextValue(secondKey, 2, first);
|
||||
const replaced = withContextValue(firstKey, "updated", second);
|
||||
|
||||
expect(BACKGROUND_CONTEXT.value(firstKey)).toBeUndefined();
|
||||
expect(first.value(firstKey)).toBe("one");
|
||||
expect(first.value(secondKey)).toBeUndefined();
|
||||
expect(second.value(firstKey)).toBe("one");
|
||||
expect(second.value(secondKey)).toBe(2);
|
||||
expect(replaced.value(firstKey)).toBe("updated");
|
||||
expect(second.value(firstKey)).toBe("one");
|
||||
expect(String(replaced)).toBe("[Context BACKGROUND_CONTEXT].WithValue(first).WithValue(second).WithValue(first)");
|
||||
});
|
||||
|
||||
it("inherits parent cancellation and isolates child cancellation", () => {
|
||||
const parentController = new AbortController();
|
||||
const parent = withAbortSignal(parentController.signal, BACKGROUND_CONTEXT);
|
||||
const child = withCancel(parent);
|
||||
const sibling = withCancel(parent);
|
||||
const childListener = vi.fn();
|
||||
child.context.abortSignal?.addEventListener("abort", childListener);
|
||||
|
||||
child.cancel("child");
|
||||
expect(child.context.abortSignal?.aborted).toBe(true);
|
||||
expect(child.context.abortSignal?.reason).toBe("child");
|
||||
expect(sibling.context.abortSignal?.aborted).toBe(false);
|
||||
expect(parent.abortSignal?.aborted).toBe(false);
|
||||
expect(childListener).toHaveBeenCalledOnce();
|
||||
|
||||
parentController.abort("parent");
|
||||
expect(sibling.context.abortSignal?.aborted).toBe(true);
|
||||
expect(sibling.context.abortSignal?.reason).toBe("parent");
|
||||
});
|
||||
|
||||
it("masks caller cancellation for mandatory cleanup", () => {
|
||||
const controller = new AbortController();
|
||||
const key = createContextKey<string>("value");
|
||||
const context = withContextValue(key, "preserved", withAbortSignal(controller.signal, BACKGROUND_CONTEXT));
|
||||
const cleanup = withoutAbortSignal(context);
|
||||
|
||||
controller.abort();
|
||||
expect(context.abortSignal?.aborted).toBe(true);
|
||||
expect(cleanup.abortSignal).toBeUndefined();
|
||||
expect(cleanup.value(key)).toBe("preserved");
|
||||
});
|
||||
|
||||
it("stops waiting when the invocation is cancelled", async () => {
|
||||
const controller = new AbortController();
|
||||
const context = withAbortSignal(controller.signal, BACKGROUND_CONTEXT);
|
||||
let resolveWork!: (value: string) => void;
|
||||
const work = new Promise<string>((resolve) => {
|
||||
resolveWork = resolve;
|
||||
});
|
||||
const waiting = awaitWithContext(work, context);
|
||||
const cancellation = new Error("cancelled");
|
||||
|
||||
controller.abort(cancellation);
|
||||
await expect(waiting).rejects.toBe(cancellation);
|
||||
resolveWork("completed later");
|
||||
await expect(work).resolves.toBe("completed later");
|
||||
await expect(awaitWithContext(Promise.resolve("completed"), BACKGROUND_CONTEXT)).resolves.toBe("completed");
|
||||
});
|
||||
});
|
||||
1141
packages/chord/test/delta.test.ts
Normal file
1141
packages/chord/test/delta.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
394
packages/chord/test/facet-loader.test.ts
Normal file
394
packages/chord/test/facet-loader.test.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
||||
import {
|
||||
type Context,
|
||||
combineFacetLoaders,
|
||||
createFacetHost,
|
||||
createRemoteServiceBinding,
|
||||
createStaticFacetLoader,
|
||||
defineFacet,
|
||||
defineService,
|
||||
type FacetLoader,
|
||||
} from "../src/index.ts";
|
||||
import { createLoopbackServiceTransport } from "./helpers.ts";
|
||||
|
||||
interface GenerationValue {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
const LocalGenerationValue = defineService<GenerationValue>("test.experimental.local-generation-value", {
|
||||
local: true,
|
||||
});
|
||||
const RemoteGenerationValue = defineService<GenerationValue>("test.experimental.remote-generation-value");
|
||||
|
||||
const firstFacet = defineFacet({ id: "first", setup() {} });
|
||||
const secondFacet = defineFacet({ id: "second", setup() {} });
|
||||
|
||||
describe("facet loader", () => {
|
||||
test("combines loaded facets in loader order and disposes generations in reverse", async () => {
|
||||
const trace: string[] = [];
|
||||
const first: FacetLoader = {
|
||||
async load() {
|
||||
trace.push("load first");
|
||||
return {
|
||||
facets: [firstFacet],
|
||||
async dispose() {
|
||||
trace.push("dispose first");
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const second: FacetLoader = {
|
||||
async load() {
|
||||
trace.push("load second");
|
||||
return {
|
||||
facets: [secondFacet],
|
||||
async dispose() {
|
||||
trace.push("dispose second");
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const loaded = await combineFacetLoaders([first, second]).load();
|
||||
expect(loaded.facets).toEqual([firstFacet, secondFacet]);
|
||||
await loaded.dispose();
|
||||
await loaded.dispose();
|
||||
expect(trace).toEqual(["load first", "load second", "dispose second", "dispose first"]);
|
||||
});
|
||||
|
||||
test("cleans up loaded facets when a later loader fails", async () => {
|
||||
const dispose = vi.fn(async () => {});
|
||||
const failure = new Error("load failed");
|
||||
const first: FacetLoader = {
|
||||
async load() {
|
||||
return { facets: [firstFacet], dispose };
|
||||
},
|
||||
};
|
||||
const second: FacetLoader = {
|
||||
async load() {
|
||||
throw failure;
|
||||
},
|
||||
};
|
||||
|
||||
await expect(combineFacetLoaders([first, second]).load()).rejects.toBe(failure);
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("keeps local and RPC service handles stable when their provider facet reloads", async () => {
|
||||
const trace: string[] = [];
|
||||
let localValue: GenerationValue | undefined;
|
||||
let generation = 0;
|
||||
let reportReplacementStarted!: () => void;
|
||||
const replacementStarted = new Promise<void>((resolve) => {
|
||||
reportReplacementStarted = resolve;
|
||||
});
|
||||
let continueReplacement!: () => void;
|
||||
const replacementCanContinue = new Promise<void>((resolve) => {
|
||||
continueReplacement = resolve;
|
||||
});
|
||||
const consumer = defineFacet({
|
||||
id: "consumer",
|
||||
setup(env) {
|
||||
trace.push("setup consumer");
|
||||
localValue = env.use(LocalGenerationValue);
|
||||
env.onActivate(async () => {
|
||||
trace.push(`activate consumer:${await localValue!.read(BACKGROUND_CONTEXT)}`);
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push("deactivate consumer");
|
||||
});
|
||||
},
|
||||
});
|
||||
const loader: FacetLoader = {
|
||||
async load() {
|
||||
const name = generation++ === 0 ? "A" : "B";
|
||||
trace.push(`load ${name}`);
|
||||
return {
|
||||
facets: [
|
||||
defineFacet({
|
||||
id: "provider",
|
||||
setup(env) {
|
||||
trace.push(`setup provider ${name}`);
|
||||
const implementation: GenerationValue = {
|
||||
async read() {
|
||||
return name;
|
||||
},
|
||||
};
|
||||
env.provide(LocalGenerationValue, implementation);
|
||||
env.provide(RemoteGenerationValue, implementation);
|
||||
env.onActivate(async () => {
|
||||
trace.push(`activate provider ${name}`);
|
||||
if (name === "B") {
|
||||
reportReplacementStarted();
|
||||
await replacementCanContinue;
|
||||
}
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push(`deactivate provider ${name}`);
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
async dispose() {
|
||||
trace.push(`unload ${name}`);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const loadedA = await loader.load();
|
||||
const host = await createFacetHost({ facets: [consumer, ...loadedA.facets] });
|
||||
const originalLocalHandle = localValue!;
|
||||
const localRead = originalLocalHandle.read;
|
||||
const remoteServices = createRemoteServiceBinding({
|
||||
services: [RemoteGenerationValue],
|
||||
transport: createLoopbackServiceTransport(host.services),
|
||||
});
|
||||
const originalRemoteHandle = remoteServices.use(RemoteGenerationValue);
|
||||
const remoteRead = originalRemoteHandle.read;
|
||||
await remoteServices.ready(BACKGROUND_CONTEXT);
|
||||
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
|
||||
await expect(
|
||||
host.reload([
|
||||
defineFacet({
|
||||
id: "provider",
|
||||
setup(env) {
|
||||
env.provide(LocalGenerationValue, {
|
||||
async read() {
|
||||
return "invalid";
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
]),
|
||||
).rejects.toThrow("Reloaded facet provider must preserve its service requirements and provisions");
|
||||
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
|
||||
const loadedB = await loader.load();
|
||||
const reload = host.reload(loadedB.facets);
|
||||
await replacementStarted;
|
||||
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
continueReplacement();
|
||||
await reload;
|
||||
await loadedA.dispose();
|
||||
|
||||
expect(localValue).toBe(originalLocalHandle);
|
||||
expect(remoteServices.use(RemoteGenerationValue)).toBe(originalRemoteHandle);
|
||||
await expect(localRead(BACKGROUND_CONTEXT)).resolves.toBe("B");
|
||||
await expect(remoteRead(BACKGROUND_CONTEXT)).resolves.toBe("B");
|
||||
|
||||
await remoteServices.dispose(BACKGROUND_CONTEXT);
|
||||
await host.dispose();
|
||||
await loadedB.dispose();
|
||||
expect(trace).toEqual([
|
||||
"load A",
|
||||
"setup consumer",
|
||||
"setup provider A",
|
||||
"activate provider A",
|
||||
"activate consumer:A",
|
||||
"load B",
|
||||
"setup provider B",
|
||||
"activate provider B",
|
||||
"deactivate provider A",
|
||||
"unload A",
|
||||
"deactivate consumer",
|
||||
"deactivate provider B",
|
||||
"unload B",
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects remote singleton member shape changes before reload cutover", async () => {
|
||||
let retained: GenerationValue | undefined;
|
||||
let providerDisposed = false;
|
||||
const consumer = defineFacet({
|
||||
id: "shape-consumer",
|
||||
setup(env) {
|
||||
retained = env.use(RemoteGenerationValue);
|
||||
},
|
||||
});
|
||||
const provider = defineFacet({
|
||||
id: "shape-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async read() {
|
||||
return "A";
|
||||
},
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
providerDisposed = true;
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider] });
|
||||
|
||||
await expect(
|
||||
host.reload([
|
||||
defineFacet({
|
||||
id: "shape-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async renamed() {
|
||||
return "B";
|
||||
},
|
||||
} as unknown as GenerationValue);
|
||||
},
|
||||
}),
|
||||
]),
|
||||
).rejects.toThrow("replacement must preserve its member shape");
|
||||
expect(providerDisposed).toBe(false);
|
||||
await expect(retained!.read(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
|
||||
await host.dispose();
|
||||
expect(providerDisposed).toBe(true);
|
||||
});
|
||||
|
||||
test("terminates the host when old cleanup fails after cutover", async () => {
|
||||
const cleanupFailure = new Error("cleanup failed");
|
||||
const provider = (name: string, failCleanup: boolean) =>
|
||||
defineFacet({
|
||||
id: "cleanup-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async read() {
|
||||
return name;
|
||||
},
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
if (failCleanup) throw cleanupFailure;
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [provider("A", true)] });
|
||||
|
||||
await expect(host.reload([provider("B", false)])).rejects.toThrow("Facet reload failed after cutover");
|
||||
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("cleans failed candidate activation in reverse dependency order", async () => {
|
||||
const failure = new Error("consumer activation failed");
|
||||
const trace: string[] = [];
|
||||
const provider = (name: string) =>
|
||||
defineFacet({
|
||||
id: "ordered-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async read() {
|
||||
return name;
|
||||
},
|
||||
});
|
||||
env.onActivate(() => {
|
||||
trace.push(`activate provider ${name}`);
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push(`deactivate provider ${name}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const consumer = (name: string, fail: boolean) =>
|
||||
defineFacet({
|
||||
id: "ordered-consumer",
|
||||
setup(env) {
|
||||
env.use(RemoteGenerationValue);
|
||||
env.onActivate(() => {
|
||||
trace.push(`activate consumer ${name}`);
|
||||
if (fail) throw failure;
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push(`deactivate consumer ${name}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer("A", false), provider("A")] });
|
||||
trace.length = 0;
|
||||
|
||||
await expect(host.reload([consumer("B", true), provider("B")])).rejects.toBe(failure);
|
||||
expect(trace).toEqual([
|
||||
"activate provider B",
|
||||
"activate consumer B",
|
||||
"deactivate consumer B",
|
||||
"deactivate provider B",
|
||||
]);
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("terminates the host when replacement publication fails after cutover", async () => {
|
||||
const publicationFailure = new Error("publication failed");
|
||||
const provider = (name: string) =>
|
||||
defineFacet({
|
||||
id: "publication-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async read() {
|
||||
return name;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [provider("A")] });
|
||||
const subscription = host.services.subscribe(RemoteGenerationValue.id, "singleton", () => {
|
||||
throw publicationFailure;
|
||||
});
|
||||
subscription.activate();
|
||||
|
||||
await expect(host.reload([provider("B")])).rejects.toThrow("Facet reload failed after cutover");
|
||||
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("keeps the old generation active when replacement activation fails before cutover", async () => {
|
||||
const failure = new Error("replacement activation failed");
|
||||
const trace: string[] = [];
|
||||
let retained: GenerationValue | undefined;
|
||||
const consumer = defineFacet({
|
||||
id: "terminal-consumer",
|
||||
setup(env) {
|
||||
retained = env.use(RemoteGenerationValue);
|
||||
env.onDeactivate(() => {
|
||||
trace.push("deactivate consumer");
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = (name: string, fail: boolean) =>
|
||||
defineFacet({
|
||||
id: "terminal-provider",
|
||||
setup(env) {
|
||||
env.provide(RemoteGenerationValue, {
|
||||
async read() {
|
||||
return name;
|
||||
},
|
||||
});
|
||||
env.onActivate(() => {
|
||||
trace.push(`activate ${name}`);
|
||||
if (fail) throw failure;
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push(`deactivate ${name}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider("A", false)] });
|
||||
await expect(retained!.read(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
|
||||
await expect(host.reload([provider("B", true)])).rejects.toBe(failure);
|
||||
expect(trace).toEqual(["activate A", "activate B", "deactivate B"]);
|
||||
await expect(retained!.read(BACKGROUND_CONTEXT)).resolves.toBe("A");
|
||||
await host.reload([]);
|
||||
await host.dispose();
|
||||
expect(trace).toEqual(["activate A", "activate B", "deactivate B", "deactivate consumer", "deactivate A"]);
|
||||
});
|
||||
|
||||
test("creates a reusable static loader", async () => {
|
||||
const loader = createStaticFacetLoader([firstFacet]);
|
||||
const first = await loader.load();
|
||||
const second = await loader.load();
|
||||
expect(first.facets).toEqual([firstFacet]);
|
||||
expect(second.facets).toEqual([firstFacet]);
|
||||
await Promise.all([first.dispose(), second.dispose()]);
|
||||
});
|
||||
});
|
||||
673
packages/chord/test/facets.test.ts
Normal file
673
packages/chord/test/facets.test.ts
Normal file
@@ -0,0 +1,673 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
||||
import {
|
||||
type Context,
|
||||
createFacetHost,
|
||||
createRemoteServiceBinding,
|
||||
defineFacet,
|
||||
defineService,
|
||||
type MutableReplicatedState,
|
||||
RemoteServiceProvider,
|
||||
type RemoteServiceSource,
|
||||
type ReplicatedState,
|
||||
} from "../src/index.ts";
|
||||
import { createLoopbackServiceTransport } from "./helpers.ts";
|
||||
|
||||
interface Source {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
interface Projection {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
interface KeyedValue {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
interface Watched {
|
||||
readonly state: ReplicatedState<{ value: number }>;
|
||||
}
|
||||
|
||||
interface HostValues {
|
||||
readonly name: string;
|
||||
readonly use: string;
|
||||
}
|
||||
|
||||
interface LocalKeyedValue {
|
||||
readonly metadata: Map<string, string>;
|
||||
read(): string;
|
||||
}
|
||||
|
||||
interface LeftValue {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
interface RightValue {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
interface CombinedValue {
|
||||
read(context: Context): Promise<string>;
|
||||
}
|
||||
|
||||
const Source = defineService<Source>("test.experimental.source");
|
||||
const Projection = defineService<Projection>("test.experimental.projection");
|
||||
const KeyedValue = defineService<KeyedValue>("test.experimental.keyed-value");
|
||||
const Watched = defineService<Watched>("test.experimental.watched");
|
||||
const HostValues = defineService<HostValues>("test.experimental.host-values", { local: true });
|
||||
const LocalKeyedValue = defineService<LocalKeyedValue>("test.experimental.local-keyed-value", { local: true });
|
||||
const LeftValue = defineService<LeftValue>("test.experimental.left-value");
|
||||
const RightValue = defineService<RightValue>("test.experimental.right-value");
|
||||
const CombinedValue = defineService<CombinedValue>("test.experimental.combined-value");
|
||||
|
||||
describe("facet host", () => {
|
||||
test("discovers setup dependencies before connecting stable service handles", async () => {
|
||||
const trace: string[] = [];
|
||||
let sourceHandle: Source | undefined;
|
||||
const projection = defineFacet({
|
||||
id: "projection",
|
||||
setup(env) {
|
||||
trace.push("setup projection");
|
||||
sourceHandle = env.use(Source);
|
||||
expect(() => sourceHandle!.read(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet projection service handles cannot be used while setting_up",
|
||||
);
|
||||
env.provide(Projection, {
|
||||
read: (context) => sourceHandle!.read(context),
|
||||
});
|
||||
env.onActivate(() => {
|
||||
trace.push("activate projection");
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push("dispose projection");
|
||||
});
|
||||
},
|
||||
});
|
||||
const source = defineFacet({
|
||||
id: "source",
|
||||
setup(env) {
|
||||
trace.push("setup source");
|
||||
env.provide(Source, {
|
||||
async read() {
|
||||
return "value";
|
||||
},
|
||||
});
|
||||
env.onActivate(() => {
|
||||
trace.push("activate source");
|
||||
});
|
||||
env.onDeactivate(() => {
|
||||
trace.push("dispose source");
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [projection, source] });
|
||||
|
||||
expect(trace).toEqual(["setup projection", "setup source", "activate source", "activate projection"]);
|
||||
expect(await sourceHandle!.read(BACKGROUND_CONTEXT)).toBe("value");
|
||||
expect(await host.services.use(Projection).read(BACKGROUND_CONTEXT)).toBe("value");
|
||||
|
||||
await host.dispose();
|
||||
expect(trace.slice(-2)).toEqual(["dispose projection", "dispose source"]);
|
||||
});
|
||||
|
||||
test("connects keyed observations only when the observing facet activates", async () => {
|
||||
const trace: string[] = [];
|
||||
const observer = defineFacet({
|
||||
id: "observer",
|
||||
setup(env) {
|
||||
env.observe(KeyedValue, async (service, context) => {
|
||||
trace.push(`observe ${await service.read(context)}`);
|
||||
});
|
||||
env.onActivate(() => {
|
||||
trace.push("activate observer");
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = defineFacet({
|
||||
id: "provider",
|
||||
setup(env) {
|
||||
const values = env.provideMany(KeyedValue);
|
||||
env.onActivate(() => {
|
||||
trace.push("activate provider");
|
||||
values.spawn("one", {
|
||||
async read() {
|
||||
return "one";
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [observer, provider] });
|
||||
await vi.waitFor(() => expect(trace).toContain("observe one"));
|
||||
|
||||
expect(trace).toEqual(["activate provider", "activate observer", "observe one"]);
|
||||
const remoteServices = createRemoteServiceBinding({
|
||||
services: [KeyedValue],
|
||||
transport: createLoopbackServiceTransport(host.services),
|
||||
});
|
||||
const remoteValues: string[] = [];
|
||||
remoteServices.observe(KeyedValue, async (service, context) => {
|
||||
remoteValues.push(await service.read(context));
|
||||
});
|
||||
await remoteServices.ready(BACKGROUND_CONTEXT);
|
||||
await vi.waitFor(() => expect(remoteValues).toEqual(["one"]));
|
||||
|
||||
await remoteServices.dispose(BACKGROUND_CONTEXT);
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("routes remotely exposable keyed services through the host provider", async () => {
|
||||
const observed: Array<{ service: KeyedValue; context: Context }> = [];
|
||||
const consumer = defineFacet({
|
||||
id: "remote-keyed-consumer",
|
||||
setup(env) {
|
||||
env.observe(KeyedValue, (service, context) => {
|
||||
observed.push({ service, context });
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = (value: string) =>
|
||||
defineFacet({
|
||||
id: "remote-keyed-provider",
|
||||
setup(env) {
|
||||
const values = env.provideMany(KeyedValue);
|
||||
env.onActivate(() => {
|
||||
values.spawn("current", {
|
||||
async read() {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider("A")] });
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(1));
|
||||
const first = observed[0]!;
|
||||
await expect(first.service.read(first.context)).resolves.toBe("A");
|
||||
|
||||
await host.reload([provider("B")]);
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(2));
|
||||
expect(first.context.abortSignal?.aborted).toBe(true);
|
||||
expect(() => first.service.read(first.context)).toThrow("observation is closed");
|
||||
await expect(observed[1]!.service.read(observed[1]!.context)).resolves.toBe("B");
|
||||
|
||||
await host.dispose();
|
||||
expect(observed[1]!.context.abortSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("terminates the host when keyed replacement publication fails", async () => {
|
||||
const failure = new Error("spawn publication failed");
|
||||
const provider = (value: string) =>
|
||||
defineFacet({
|
||||
id: "failing-keyed-provider",
|
||||
setup(env) {
|
||||
const values = env.provideMany(KeyedValue);
|
||||
env.onActivate(() => {
|
||||
values.spawn("current", {
|
||||
async read() {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [provider("A")] });
|
||||
const subscription = host.services.subscribe(KeyedValue.id, "keyed", (update) => {
|
||||
if (update.type === "spawned") throw failure;
|
||||
});
|
||||
subscription.activate();
|
||||
|
||||
await expect(host.reload([provider("B")])).rejects.toThrow("Facet reload failed after cutover");
|
||||
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
|
||||
expect(() => host.services.use(KeyedValue)).toThrow("Remote service provider is disposed");
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("terminates the host when keyed retirement publication fails", async () => {
|
||||
const failure = new Error("close publication failed");
|
||||
const provider = (value: string) =>
|
||||
defineFacet({
|
||||
id: "failing-keyed-retirement-provider",
|
||||
setup(env) {
|
||||
const values = env.provideMany(KeyedValue);
|
||||
env.onActivate(() => {
|
||||
values.spawn("current", {
|
||||
async read() {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [provider("A")] });
|
||||
const subscription = host.services.subscribe(KeyedValue.id, "keyed", (update) => {
|
||||
if (update.type === "closed") throw failure;
|
||||
});
|
||||
subscription.activate();
|
||||
|
||||
await expect(host.reload([provider("B")])).rejects.toThrow("Facet reload failed after cutover");
|
||||
await expect(host.reload([])).rejects.toThrow("Facet host cannot reload while dead");
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("keeps unrestricted local keyed services process-local across provider reloads", async () => {
|
||||
const observed: Array<{ service: LocalKeyedValue; context: Context }> = [];
|
||||
const consumer = defineFacet({
|
||||
id: "local-keyed-consumer",
|
||||
setup(env) {
|
||||
env.observe(LocalKeyedValue, (service, context) => {
|
||||
observed.push({ service, context });
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = (value: string) =>
|
||||
defineFacet({
|
||||
id: "local-keyed-provider",
|
||||
setup(env) {
|
||||
const values = env.provideMany(LocalKeyedValue);
|
||||
env.onActivate(() => {
|
||||
values.spawn("current", {
|
||||
metadata: new Map([["value", value]]),
|
||||
read: () => value,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider("A")] });
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(1));
|
||||
const first = observed[0]!;
|
||||
expect(first.service.read()).toBe("A");
|
||||
expect(first.service.metadata.get("value")).toBe("A");
|
||||
expect(host.services.catalogue).not.toContainEqual({ serviceId: LocalKeyedValue.id, mode: "keyed" });
|
||||
expect(() => host.services.use(LocalKeyedValue)).toThrow("process-local");
|
||||
|
||||
await host.reload([provider("B")]);
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(2));
|
||||
expect(first.context.abortSignal?.aborted).toBe(true);
|
||||
expect(() => first.service.read()).toThrow(`Keyed service ${LocalKeyedValue.id} observation is closed`);
|
||||
expect(observed[1]!.service.read()).toBe("B");
|
||||
expect(observed[1]!.service.metadata.get("value")).toBe("B");
|
||||
|
||||
await host.dispose();
|
||||
expect(observed[1]!.context.abortSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps remotely exposable local state replicas stable across provider reloads", async () => {
|
||||
const sources: MutableReplicatedState<{ value: number }>[] = [];
|
||||
const revisions: number[] = [];
|
||||
let watched: Watched | undefined;
|
||||
const consumer = defineFacet({
|
||||
id: "state-consumer",
|
||||
setup(env) {
|
||||
watched = env.use(Watched);
|
||||
env.onActivate(() => {
|
||||
env.own(watched!.state.subscribe(({ value }) => revisions.push(value)));
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = (value: number) =>
|
||||
defineFacet({
|
||||
id: "state-provider",
|
||||
setup(env) {
|
||||
const state = env.replicatedState({ value });
|
||||
sources.push(state);
|
||||
env.provide(Watched, { state });
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider(1)] });
|
||||
const retainedState = watched!.state;
|
||||
expect(retainedState.value).toEqual({ value: 1 });
|
||||
expect(revisions).toEqual([1]);
|
||||
|
||||
await host.reload([provider(2)]);
|
||||
expect(watched!.state).toBe(retainedState);
|
||||
expect(retainedState.value).toEqual({ value: 2 });
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
sources[0]!.state.value = 3;
|
||||
sources[0]!.publish(BACKGROUND_CONTEXT);
|
||||
expect(retainedState.value).toEqual({ value: 2 });
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
|
||||
await host.dispose();
|
||||
expect(() => retainedState.value).toThrow("Facet state-consumer service handles cannot be used while dead");
|
||||
});
|
||||
|
||||
test("scopes singleton service views to each facet lifecycle", async () => {
|
||||
const consumerHandles: Source[] = [];
|
||||
const cleanupValues: string[] = [];
|
||||
let peerHandle: Source | undefined;
|
||||
const consumer = (generation: string) =>
|
||||
defineFacet({
|
||||
id: "scoped-consumer",
|
||||
setup(env) {
|
||||
const source = env.use(Source);
|
||||
expect(env.use(Source)).toBe(source);
|
||||
consumerHandles.push(source);
|
||||
expect(() => source.read(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet scoped-consumer service handles cannot be used while setting_up",
|
||||
);
|
||||
env.onDeactivate(async () => {
|
||||
cleanupValues.push(`${generation}:${await source.read(BACKGROUND_CONTEXT)}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const peer = defineFacet({
|
||||
id: "peer-consumer",
|
||||
setup(env) {
|
||||
peerHandle = env.use(Source);
|
||||
},
|
||||
});
|
||||
const provider = defineFacet({
|
||||
id: "scoped-provider",
|
||||
setup(env) {
|
||||
env.provide(Source, {
|
||||
async read() {
|
||||
return "value";
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer("A"), peer, provider] });
|
||||
expect(consumerHandles[0]).not.toBe(peerHandle);
|
||||
const retainedOldRead = consumerHandles[0]!.read;
|
||||
|
||||
await host.reload([consumer("B")]);
|
||||
expect(cleanupValues).toEqual(["A:value"]);
|
||||
expect(Object.is(consumerHandles[1], consumerHandles[0])).toBe(false);
|
||||
expect(() => consumerHandles[0]!.read(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet scoped-consumer service handles cannot be used while dead",
|
||||
);
|
||||
expect(() => retainedOldRead(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet scoped-consumer service handles cannot be used while dead",
|
||||
);
|
||||
await expect(consumerHandles[1]!.read(BACKGROUND_CONTEXT)).resolves.toBe("value");
|
||||
await expect(peerHandle!.read(BACKGROUND_CONTEXT)).resolves.toBe("value");
|
||||
|
||||
await host.dispose();
|
||||
expect(cleanupValues).toEqual(["A:value", "B:value"]);
|
||||
expect(() => consumerHandles[1]!.read(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet scoped-consumer service handles cannot be used while dead",
|
||||
);
|
||||
expect(() => peerHandle!.read(BACKGROUND_CONTEXT)).toThrow(
|
||||
"Facet peer-consumer service handles cannot be used while dead",
|
||||
);
|
||||
});
|
||||
|
||||
test("owns resources registered during activation", async () => {
|
||||
let state: MutableReplicatedState<{ value: number }> | undefined;
|
||||
let deliveries = 0;
|
||||
const consumer = defineFacet({
|
||||
id: "consumer",
|
||||
setup(env) {
|
||||
const watched = env.use(Watched);
|
||||
env.onActivate(() => {
|
||||
env.own(
|
||||
watched.state.subscribe(() => {
|
||||
deliveries += 1;
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = defineFacet({
|
||||
id: "provider",
|
||||
setup(env) {
|
||||
state = env.replicatedState({ value: 0 });
|
||||
env.provide(Watched, { state });
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider] });
|
||||
expect(deliveries).toBe(1);
|
||||
state!.state.value = 1;
|
||||
state!.publish(BACKGROUND_CONTEXT);
|
||||
expect(deliveries).toBe(2);
|
||||
|
||||
await host.dispose();
|
||||
state!.state.value = 2;
|
||||
state!.publish(BACKGROUND_CONTEXT);
|
||||
expect(deliveries).toBe(2);
|
||||
});
|
||||
|
||||
test("provides arbitrary host services through the facet graph", async () => {
|
||||
const values: HostValues = { name: "session", use: "host value" };
|
||||
const consumer = defineFacet({
|
||||
id: "host-service-consumer",
|
||||
setup(env) {
|
||||
const hostValues = env.use(HostValues);
|
||||
expect(() => hostValues.use).toThrow(
|
||||
"Facet host-service-consumer service handles cannot be used while setting_up",
|
||||
);
|
||||
env.onActivate(() => {
|
||||
expect(hostValues).not.toBe(values);
|
||||
expect(hostValues.name).toBe("session");
|
||||
expect(hostValues.use).toBe("host value");
|
||||
});
|
||||
},
|
||||
});
|
||||
const provider = defineFacet({
|
||||
id: "host-service-provider",
|
||||
setup(env) {
|
||||
env.provide(HostValues, values);
|
||||
},
|
||||
});
|
||||
const host = await createFacetHost({ facets: [consumer, provider] });
|
||||
expect(() => host.services.use(HostValues)).toThrow("Service test.experimental.host-values is process-local");
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
test("combines connected services and facet-provided services in one host", async () => {
|
||||
const leftProvider = new RemoteServiceProvider([LeftValue]);
|
||||
leftProvider.provide(LeftValue, {
|
||||
async read() {
|
||||
return "left";
|
||||
},
|
||||
});
|
||||
const rightProvider = new RemoteServiceProvider([RightValue]);
|
||||
rightProvider.provide(RightValue, {
|
||||
async read() {
|
||||
return "right";
|
||||
},
|
||||
});
|
||||
const leftNamespace = createRemoteServiceBinding({
|
||||
services: [LeftValue],
|
||||
transport: createLoopbackServiceTransport(leftProvider),
|
||||
bound: false,
|
||||
});
|
||||
const rightNamespace = createRemoteServiceBinding({
|
||||
services: [RightValue],
|
||||
transport: createLoopbackServiceTransport(rightProvider),
|
||||
bound: false,
|
||||
});
|
||||
const serviceSources = [
|
||||
{ namespace: leftNamespace, provider: leftProvider },
|
||||
{ namespace: rightNamespace, provider: rightProvider },
|
||||
].map(({ namespace, provider }) => {
|
||||
const ready = namespace.ready.bind(namespace);
|
||||
return Object.assign(namespace, {
|
||||
acceptsUnavailableServices: false,
|
||||
async catalogue() {
|
||||
return provider.catalogue;
|
||||
},
|
||||
open() {
|
||||
return namespace;
|
||||
},
|
||||
async ready(context: Context) {
|
||||
await namespace.rebind(true, context);
|
||||
await ready(context);
|
||||
},
|
||||
});
|
||||
});
|
||||
const facet = defineFacet({
|
||||
id: "combined",
|
||||
setup(env) {
|
||||
const left = env.use(LeftValue);
|
||||
const right = env.use(RightValue);
|
||||
env.provide(CombinedValue, {
|
||||
async read(context) {
|
||||
return `${await left.read(context)} ${await right.read(context)}`;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const host = await createFacetHost({ facets: [facet], serviceSources });
|
||||
await expect(host.services.use(CombinedValue).read(BACKGROUND_CONTEXT)).resolves.toBe("left right");
|
||||
|
||||
await host.dispose();
|
||||
await Promise.all([leftNamespace.dispose(BACKGROUND_CONTEXT), rightNamespace.dispose(BACKGROUND_CONTEXT)]);
|
||||
leftProvider.dispose();
|
||||
rightProvider.dispose();
|
||||
});
|
||||
|
||||
test("rejects a service offered by multiple sources", async () => {
|
||||
const duplicate = {
|
||||
acceptsUnavailableServices: false,
|
||||
async catalogue() {
|
||||
return [{ serviceId: LeftValue.id, mode: "singleton" as const }];
|
||||
},
|
||||
open() {
|
||||
throw new Error("Ambiguous sources must not open");
|
||||
},
|
||||
} satisfies RemoteServiceSource;
|
||||
const consumer = defineFacet({
|
||||
id: "duplicate-consumer",
|
||||
setup(env) {
|
||||
env.use(LeftValue);
|
||||
},
|
||||
});
|
||||
await expect(createFacetHost({ facets: [consumer], serviceSources: [duplicate, duplicate] })).rejects.toThrow(
|
||||
`Facet host service ${LeftValue.id} is offered by more than one source`,
|
||||
);
|
||||
});
|
||||
|
||||
test("reopens source bindings from changed catalogues for a replacement generation", async () => {
|
||||
const leftProvider = new RemoteServiceProvider([LeftValue]);
|
||||
leftProvider.provide(LeftValue, {
|
||||
async read() {
|
||||
return "left";
|
||||
},
|
||||
});
|
||||
const rightProvider = new RemoteServiceProvider([RightValue]);
|
||||
rightProvider.provide(RightValue, {
|
||||
async read() {
|
||||
return "right";
|
||||
},
|
||||
});
|
||||
let currentProvider = leftProvider;
|
||||
let opened = 0;
|
||||
let disposed = 0;
|
||||
const source = {
|
||||
acceptsUnavailableServices: false,
|
||||
async catalogue() {
|
||||
return currentProvider.catalogue;
|
||||
},
|
||||
open(options) {
|
||||
opened += 1;
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: options.services,
|
||||
transport: createLoopbackServiceTransport(currentProvider),
|
||||
bound: false,
|
||||
assertAccess: options.assertAccess,
|
||||
onError: options.onError,
|
||||
});
|
||||
const ready = namespace.ready.bind(namespace);
|
||||
const dispose = namespace.dispose.bind(namespace);
|
||||
return Object.assign(namespace, {
|
||||
async ready(context: Context) {
|
||||
await namespace.rebind(true, context);
|
||||
await ready(context);
|
||||
},
|
||||
async dispose(context: Context) {
|
||||
disposed += 1;
|
||||
await dispose(context);
|
||||
},
|
||||
});
|
||||
},
|
||||
} satisfies RemoteServiceSource;
|
||||
const values: string[] = [];
|
||||
const leftConsumer = defineFacet({
|
||||
id: "left-consumer",
|
||||
setup(env) {
|
||||
const left = env.use(LeftValue);
|
||||
env.onActivate(async () => {
|
||||
values.push(await left.read(BACKGROUND_CONTEXT));
|
||||
});
|
||||
},
|
||||
});
|
||||
const first = await createFacetHost({ facets: [leftConsumer], serviceSources: [source] });
|
||||
await first.dispose();
|
||||
|
||||
currentProvider = rightProvider;
|
||||
const rightConsumer = defineFacet({
|
||||
id: "right-consumer",
|
||||
setup(env) {
|
||||
const right = env.use(RightValue);
|
||||
env.onActivate(async () => {
|
||||
values.push(await right.read(BACKGROUND_CONTEXT));
|
||||
});
|
||||
},
|
||||
});
|
||||
const second = await createFacetHost({ facets: [rightConsumer], serviceSources: [source] });
|
||||
await second.dispose();
|
||||
|
||||
expect(values).toEqual(["left", "right"]);
|
||||
expect(opened).toBe(2);
|
||||
expect(disposed).toBe(2);
|
||||
leftProvider.dispose();
|
||||
rightProvider.dispose();
|
||||
});
|
||||
|
||||
test("rejects missing dependencies, cycles, and asynchronous setup", async () => {
|
||||
let activated = false;
|
||||
const missing = defineFacet({
|
||||
id: "missing",
|
||||
setup(env) {
|
||||
env.use(Source);
|
||||
env.onActivate(() => {
|
||||
activated = true;
|
||||
});
|
||||
},
|
||||
});
|
||||
await expect(createFacetHost({ facets: [missing] })).rejects.toThrow(
|
||||
"Facet missing requires local/test.experimental.source/singleton, but no facet provides it",
|
||||
);
|
||||
expect(activated).toBe(false);
|
||||
|
||||
const first = defineFacet({
|
||||
id: "first",
|
||||
setup(env) {
|
||||
env.use(Projection);
|
||||
env.provide(Source, {
|
||||
async read() {
|
||||
return "first";
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const second = defineFacet({
|
||||
id: "second",
|
||||
setup(env) {
|
||||
env.use(Source);
|
||||
env.provide(Projection, {
|
||||
async read() {
|
||||
return "second";
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
await expect(createFacetHost({ facets: [first, second] })).rejects.toThrow(
|
||||
"Facet dependency cycle: first, second",
|
||||
);
|
||||
|
||||
const asynchronous = defineFacet({
|
||||
id: "asynchronous",
|
||||
async setup() {
|
||||
await Promise.resolve();
|
||||
},
|
||||
});
|
||||
await expect(createFacetHost({ facets: [asynchronous] })).rejects.toThrow(
|
||||
"Facet asynchronous setup must be synchronous",
|
||||
);
|
||||
});
|
||||
});
|
||||
1
packages/chord/test/helpers.ts
Normal file
1
packages/chord/test/helpers.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createLoopbackServiceTransport } from "../src/services/loopback.ts";
|
||||
14
packages/chord/test/json.test.ts
Normal file
14
packages/chord/test/json.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isJsonValue } from "../src/index.ts";
|
||||
|
||||
describe("isJsonValue", () => {
|
||||
test("checks strict JSON without normalizing it", () => {
|
||||
expect(isJsonValue({ nested: [1, true, null] })).toBe(true);
|
||||
expect(isJsonValue({ omitted: undefined })).toBe(false);
|
||||
expect(isJsonValue(new Uint8Array([1]))).toBe(false);
|
||||
expect(isJsonValue(Number.POSITIVE_INFINITY)).toBe(false);
|
||||
const cyclic: { self?: unknown } = {};
|
||||
cyclic.self = cyclic;
|
||||
expect(isJsonValue(cyclic)).toBe(false);
|
||||
});
|
||||
});
|
||||
269
packages/chord/test/service-wire.test.ts
Normal file
269
packages/chord/test/service-wire.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
||||
import {
|
||||
createRemoteServiceEndpoint,
|
||||
createServiceCatalogueCall,
|
||||
createServiceStateDecoder,
|
||||
createServiceStateEncoder,
|
||||
createServiceSubscribeCall,
|
||||
createServiceUnsubscribeCall,
|
||||
decodeServiceControlCall,
|
||||
defineService,
|
||||
parseServiceCall,
|
||||
parseServiceCatalogue,
|
||||
parseServiceProviderUpdate,
|
||||
parseServiceSubscriptionSnapshot,
|
||||
parseWireServiceProviderUpdate,
|
||||
parseWireServiceSubscriptionSnapshot,
|
||||
RemoteServiceProvider,
|
||||
type ReplicatedState,
|
||||
replicatedState,
|
||||
type ServiceProviderUpdate,
|
||||
type ServiceSubscriptionSnapshot,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("service wire protocol", () => {
|
||||
test("encodes control calls and validates service values", () => {
|
||||
expect(decodeServiceControlCall(createServiceCatalogueCall())).toEqual({ type: "catalogue" });
|
||||
expect(
|
||||
parseServiceCatalogue([
|
||||
{ serviceId: "pi.models", mode: "singleton" },
|
||||
{ serviceId: "pi.dialogs", mode: "keyed" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ serviceId: "pi.models", mode: "singleton" },
|
||||
{ serviceId: "pi.dialogs", mode: "keyed" },
|
||||
]);
|
||||
const subscribe = createServiceSubscribeCall("subscription-1", "pi.models", "singleton");
|
||||
expect(decodeServiceControlCall(subscribe)).toEqual({
|
||||
type: "subscribe",
|
||||
subscriptionId: "subscription-1",
|
||||
serviceId: "pi.models",
|
||||
mode: "singleton",
|
||||
});
|
||||
expect(decodeServiceControlCall(createServiceUnsubscribeCall("subscription-1"))).toEqual({
|
||||
type: "unsubscribe",
|
||||
subscriptionId: "subscription-1",
|
||||
});
|
||||
expect(
|
||||
parseServiceCall({
|
||||
serviceId: "pi.question-dialog",
|
||||
instance: { key: "invocation-1", generation: 2 },
|
||||
member: "submit",
|
||||
args: [{ outcome: "selected", index: 0 }],
|
||||
}),
|
||||
).toMatchObject({ member: "submit" });
|
||||
});
|
||||
|
||||
test("rejects malformed service values", () => {
|
||||
expect(() => parseServiceCall({ serviceId: "pi.models", member: "list", args: [], extra: true })).toThrow(
|
||||
"Invalid service call",
|
||||
);
|
||||
expect(() => parseServiceCatalogue([{ serviceId: "pi.models", mode: "unknown" }])).toThrow(
|
||||
"Invalid service catalogue",
|
||||
);
|
||||
expect(() => parseServiceProviderUpdate({ type: "state", member: "state", sequence: 0, ops: [] })).toThrow(
|
||||
"Invalid service state update",
|
||||
);
|
||||
expect(() =>
|
||||
parseWireServiceProviderUpdate({ type: "state", member: "state", sequence: 1, ops: [["?", 0]] }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("validates decoded and wire snapshots and updates", () => {
|
||||
const snapshot: ServiceSubscriptionSnapshot = {
|
||||
serviceId: "pi.models",
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 1 }]] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(parseServiceSubscriptionSnapshot(snapshot)).toBe(snapshot);
|
||||
const encoder = createServiceStateEncoder();
|
||||
const wireSnapshot = encoder.encodeSnapshot(snapshot);
|
||||
expect(parseWireServiceSubscriptionSnapshot(wireSnapshot)).toBe(wireSnapshot);
|
||||
const update: ServiceProviderUpdate = {
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 1,
|
||||
ops: [["s", ["revision"], 2]],
|
||||
};
|
||||
expect(parseServiceProviderUpdate(update)).toBe(update);
|
||||
const wireUpdate = encoder.encodeUpdate(update);
|
||||
expect(parseWireServiceProviderUpdate(wireUpdate)).toBe(wireUpdate);
|
||||
});
|
||||
|
||||
test("keeps one operation codec pair for one subscription state", () => {
|
||||
const enc = createServiceStateEncoder();
|
||||
const dec = createServiceStateDecoder();
|
||||
const snapshot: ServiceSubscriptionSnapshot = {
|
||||
serviceId: "pi.models",
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [{ name: "state", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(dec.decodeSnapshot(enc.encodeSnapshot(snapshot))).toEqual(snapshot);
|
||||
|
||||
const first: ServiceProviderUpdate = {
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 1,
|
||||
ops: [["s", ["revision"], 1]],
|
||||
};
|
||||
const second: ServiceProviderUpdate = {
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 2,
|
||||
ops: [["s", ["revision"], 2]],
|
||||
};
|
||||
const firstWire = enc.encodeUpdate(first);
|
||||
const secondWire = enc.encodeUpdate(second);
|
||||
expect(firstWire).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(secondWire).toMatchObject({
|
||||
ops: [
|
||||
["#", 0, ["revision"]],
|
||||
["s", 0, 2],
|
||||
],
|
||||
});
|
||||
expect(dec.decodeUpdate(firstWire)).toEqual(first);
|
||||
expect(dec.decodeUpdate(secondWire)).toEqual(second);
|
||||
});
|
||||
|
||||
test("isolates operation dictionaries between states and subscriptions", () => {
|
||||
const snapshot: ServiceSubscriptionSnapshot = {
|
||||
serviceId: "pi.states",
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [
|
||||
{ name: "left", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] },
|
||||
{ name: "right", kind: "state", sequence: 0, ops: [["r", { revision: 0 }]] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const firstEncoder = createServiceStateEncoder();
|
||||
const firstDecoder = createServiceStateDecoder();
|
||||
const secondEncoder = createServiceStateEncoder();
|
||||
const secondDecoder = createServiceStateDecoder();
|
||||
firstDecoder.decodeSnapshot(firstEncoder.encodeSnapshot(snapshot));
|
||||
secondDecoder.decodeSnapshot(secondEncoder.encodeSnapshot(snapshot));
|
||||
|
||||
const update = (member: string, sequence: number, revision: number): ServiceProviderUpdate => ({
|
||||
type: "state",
|
||||
member,
|
||||
sequence,
|
||||
ops: [["s", ["revision"], revision]],
|
||||
});
|
||||
const firstLeft = firstEncoder.encodeUpdate(update("left", 1, 1));
|
||||
const firstRight = firstEncoder.encodeUpdate(update("right", 1, 1));
|
||||
const secondLeft = firstEncoder.encodeUpdate(update("left", 2, 2));
|
||||
const secondRight = firstEncoder.encodeUpdate(update("right", 2, 2));
|
||||
expect(firstLeft).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(firstRight).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(secondLeft).toMatchObject({
|
||||
ops: [
|
||||
["#", 0, ["revision"]],
|
||||
["s", 0, 2],
|
||||
],
|
||||
});
|
||||
expect(secondRight).toMatchObject({
|
||||
ops: [
|
||||
["#", 0, ["revision"]],
|
||||
["s", 0, 2],
|
||||
],
|
||||
});
|
||||
expect(firstDecoder.decodeUpdate(firstLeft)).toEqual(update("left", 1, 1));
|
||||
expect(firstDecoder.decodeUpdate(firstRight)).toEqual(update("right", 1, 1));
|
||||
expect(firstDecoder.decodeUpdate(secondLeft)).toEqual(update("left", 2, 2));
|
||||
expect(firstDecoder.decodeUpdate(secondRight)).toEqual(update("right", 2, 2));
|
||||
|
||||
const independentLeft = secondEncoder.encodeUpdate(update("left", 1, 1));
|
||||
expect(independentLeft).toMatchObject({ ops: [["s", ["revision"], 1]] });
|
||||
expect(secondDecoder.decodeUpdate(independentLeft)).toEqual(update("left", 1, 1));
|
||||
|
||||
const leftBase: ServiceProviderUpdate = {
|
||||
type: "state",
|
||||
member: "left",
|
||||
sequence: 3,
|
||||
ops: [["r", { revision: 3 }]],
|
||||
};
|
||||
expect(firstDecoder.decodeUpdate(firstEncoder.encodeUpdate(leftBase))).toEqual(leftBase);
|
||||
const thirdRight = firstEncoder.encodeUpdate(update("right", 3, 3));
|
||||
expect(thirdRight).toMatchObject({ ops: [["s", 0, 3]] });
|
||||
expect(firstDecoder.decodeUpdate(thirdRight)).toEqual(update("right", 3, 3));
|
||||
});
|
||||
|
||||
test("creates and removes keyed instance codecs with their lifecycle", () => {
|
||||
const enc = createServiceStateEncoder();
|
||||
const dec = createServiceStateDecoder();
|
||||
const snapshot: ServiceSubscriptionSnapshot = {
|
||||
serviceId: "pi.dialogs",
|
||||
mode: "keyed",
|
||||
instances: [],
|
||||
};
|
||||
expect(dec.decodeSnapshot(enc.encodeSnapshot(snapshot))).toEqual(snapshot);
|
||||
const address = { key: "dialog-1", generation: 1 };
|
||||
const spawned: ServiceProviderUpdate = {
|
||||
type: "spawned",
|
||||
instance: {
|
||||
instance: address,
|
||||
members: [{ name: "request", kind: "state", sequence: 0, ops: [["r", { value: 0 }]] }],
|
||||
},
|
||||
};
|
||||
expect(dec.decodeUpdate(enc.encodeUpdate(spawned))).toEqual(spawned);
|
||||
const update: ServiceProviderUpdate = {
|
||||
type: "state",
|
||||
instance: address,
|
||||
member: "request",
|
||||
sequence: 1,
|
||||
ops: [["s", ["value"], 1]],
|
||||
};
|
||||
expect(dec.decodeUpdate(enc.encodeUpdate(update))).toEqual(update);
|
||||
const closed: ServiceProviderUpdate = { type: "closed", instance: address };
|
||||
expect(dec.decodeUpdate(enc.encodeUpdate(closed))).toEqual(closed);
|
||||
expect(() => enc.encodeUpdate({ ...update, sequence: 2 })).toThrow("Unknown service state");
|
||||
});
|
||||
});
|
||||
|
||||
interface Counter {
|
||||
readonly state: ReplicatedState<{ value: number }>;
|
||||
}
|
||||
|
||||
const Counter = defineService<Counter>("test.counter");
|
||||
|
||||
test("remote service endpoints publish and clean up provider subscriptions", async () => {
|
||||
const provider = new RemoteServiceProvider([Counter]);
|
||||
const state = replicatedState({ value: 0 });
|
||||
provider.provide(Counter, { state });
|
||||
const endpoint = createRemoteServiceEndpoint(provider);
|
||||
const updates: ServiceProviderUpdate[] = [];
|
||||
const publish = (_subscriptionId: string, update: ServiceProviderUpdate): void => {
|
||||
updates.push(update);
|
||||
};
|
||||
|
||||
await expect(endpoint.invoke(createServiceCatalogueCall(), publish, BACKGROUND_CONTEXT)).resolves.toEqual([
|
||||
{ serviceId: Counter.id, mode: "singleton" },
|
||||
]);
|
||||
await expect(
|
||||
endpoint.invoke(
|
||||
createServiceSubscribeCall("subscription-1", Counter.id, "singleton"),
|
||||
publish,
|
||||
BACKGROUND_CONTEXT,
|
||||
),
|
||||
).resolves.toMatchObject({ serviceId: Counter.id, mode: "singleton" });
|
||||
|
||||
state.state.value = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(updates).toEqual([{ type: "state", member: "state", sequence: 1, ops: [["s", ["value"], 1]] }]);
|
||||
endpoint.dispose();
|
||||
state.state.value = 2;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(updates).toHaveLength(1);
|
||||
provider.dispose();
|
||||
});
|
||||
679
packages/chord/test/services.test.ts
Normal file
679
packages/chord/test/services.test.ts
Normal file
@@ -0,0 +1,679 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { BACKGROUND_CONTEXT } from "../src/context/index.ts";
|
||||
import {
|
||||
type Context,
|
||||
createRemoteServiceBinding,
|
||||
defineService,
|
||||
type JsonValue,
|
||||
RemoteServiceProvider,
|
||||
type RemoteServiceTransport,
|
||||
type ReplicatedState,
|
||||
replicatedState,
|
||||
} from "../src/index.ts";
|
||||
import { createLoopbackServiceTransport } from "./helpers.ts";
|
||||
|
||||
type ModelRef = { provider: string; modelId: string };
|
||||
type ModelsState = {
|
||||
selected: ModelRef | null;
|
||||
revision: number;
|
||||
};
|
||||
interface Models {
|
||||
readonly state: ReplicatedState<ModelsState>;
|
||||
select(model: ModelRef, context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
const Models = defineService<Models>("test.models");
|
||||
|
||||
type Question = { question: string };
|
||||
interface QuestionDialogs {
|
||||
readonly request: ReplicatedState<Question>;
|
||||
submit(answer: string, context: Context): Promise<{ accepted: boolean }>;
|
||||
}
|
||||
|
||||
const QuestionDialogs = defineService<QuestionDialogs>("test.question-dialog");
|
||||
|
||||
type EchoPayload = { value: string };
|
||||
interface Echo {
|
||||
echo(payload: EchoPayload, context: Context): Promise<EchoPayload>;
|
||||
}
|
||||
|
||||
const Echo = defineService<Echo>("test.echo");
|
||||
|
||||
interface Timeline {
|
||||
readonly state: ReplicatedState<{ entries: { id: string }[]; retained: { value: number } }>;
|
||||
}
|
||||
|
||||
const Timeline = defineService<Timeline>("test.timeline");
|
||||
|
||||
interface JsonPassthrough {
|
||||
call(value: JsonValue, context: Context): Promise<JsonValue>;
|
||||
}
|
||||
|
||||
interface NonJsonArgument {
|
||||
call(value: Date, context: Context): Promise<void>;
|
||||
}
|
||||
|
||||
interface NonJsonResult {
|
||||
call(context: Context): Promise<bigint>;
|
||||
}
|
||||
|
||||
interface NonJsonState {
|
||||
readonly state: ReplicatedState<{ value: undefined }>;
|
||||
}
|
||||
|
||||
describe("remote services", () => {
|
||||
test("checks remote JSON contracts only at compile time", () => {
|
||||
expect(defineService<JsonPassthrough>("test.json-passthrough").local).toBe(false);
|
||||
const defineInvalidContracts = (): void => {
|
||||
// @ts-expect-error Remote service arguments must be JSON-compatible.
|
||||
defineService<NonJsonArgument>("test.non-json-argument");
|
||||
// @ts-expect-error Remote service results must be JSON-compatible.
|
||||
defineService<NonJsonResult>("test.non-json-result");
|
||||
// @ts-expect-error Replicated state must be JSON-compatible.
|
||||
defineService<NonJsonState>("test.non-json-state");
|
||||
};
|
||||
expect(defineInvalidContracts).not.toThrow();
|
||||
expect(defineService<NonJsonArgument>("test.local-non-json", { local: true }).local).toBe(true);
|
||||
});
|
||||
|
||||
test("marks services remotable by default and reserves Chord service IDs", () => {
|
||||
const local = defineService<{ readonly value: string }>("test.local", { local: true });
|
||||
expect(Models.local).toBe(false);
|
||||
expect(local.local).toBe(true);
|
||||
expect(() => defineService("$chord.internal", { local: true })).toThrow(
|
||||
"Service IDs beginning with $chord. are reserved",
|
||||
);
|
||||
expect(() => new RemoteServiceProvider([local])).toThrow("cannot be published remotely");
|
||||
});
|
||||
|
||||
test("tracks mutable source state while publishing immutable revisions", () => {
|
||||
const initial: ModelsState = { selected: null, revision: 0 };
|
||||
const state = replicatedState(initial);
|
||||
let delivered: ModelsState | undefined;
|
||||
const deliveries: string[] = [];
|
||||
const unsubscribe = state.subscribe((value, _context, delivery) => {
|
||||
delivered = value;
|
||||
deliveries.push(delivery.kind);
|
||||
});
|
||||
expect(state.value).toEqual(initial);
|
||||
expect(state.value).not.toBe(initial);
|
||||
expect(delivered).toBe(state.value);
|
||||
const hydrated = delivered;
|
||||
|
||||
state.state.selected = { provider: "test", modelId: "one" };
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(state.value).toEqual({ selected: { provider: "test", modelId: "one" }, revision: 1 });
|
||||
expect(state.value).not.toBe(initial);
|
||||
expect(delivered).toBe(state.value);
|
||||
expect(hydrated).toEqual({ selected: null, revision: 0 });
|
||||
expect(deliveries).toEqual(["hydrate", "update"]);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("flushes pending mutations before hydrating a new state subscriber", () => {
|
||||
const state = replicatedState({ entries: [{ id: "one" }] });
|
||||
const first: { entries: { id: string }[] }[] = [];
|
||||
state.subscribe((value) => first.push(value));
|
||||
state.state.entries.push({ id: "two" });
|
||||
|
||||
const second: { entries: { id: string }[] }[] = [];
|
||||
state.subscribe((value) => second.push(value));
|
||||
|
||||
expect(first).toEqual([{ entries: [{ id: "one" }] }, { entries: [{ id: "one" }, { id: "two" }] }]);
|
||||
expect(second).toEqual([{ entries: [{ id: "one" }, { id: "two" }] }]);
|
||||
});
|
||||
|
||||
test("does not defensively clone method arguments or results", async () => {
|
||||
const provider = new RemoteServiceProvider([Echo]);
|
||||
let received: EchoPayload | undefined;
|
||||
const response: EchoPayload = { value: "response" };
|
||||
provider.provide(Echo, {
|
||||
async echo(payload) {
|
||||
received = payload;
|
||||
return response;
|
||||
},
|
||||
});
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Echo],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const echo = namespace.use(Echo);
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
const request: EchoPayload = { value: "request" };
|
||||
|
||||
await expect(echo.echo(request, BACKGROUND_CONTEXT)).resolves.toBe(response);
|
||||
expect(received).toBe(request);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("provides and consumes one singleton with replicated state", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
expect(provider.catalogue).toEqual([{ serviceId: Models.id, mode: "singleton" }]);
|
||||
const initialState: ModelsState = { selected: null, revision: 0 };
|
||||
const state = replicatedState(initialState);
|
||||
let publishedState: ModelsState | undefined;
|
||||
provider.provide(Models, {
|
||||
state,
|
||||
async select(model, context) {
|
||||
state.state.selected = model;
|
||||
state.state.revision += 1;
|
||||
state.publish(context);
|
||||
publishedState = state.value;
|
||||
},
|
||||
});
|
||||
const errors: Error[] = [];
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
|
||||
const first = namespace.use(Models);
|
||||
const second = namespace.use(Models);
|
||||
expect(first).toBe(second);
|
||||
expect(first.state.value).toBeUndefined();
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(first.state.value).toEqual(initialState);
|
||||
|
||||
const updates: ModelsState[] = [];
|
||||
const unsubscribe = second.state.subscribe((value) => updates.push(value));
|
||||
await first.select({ provider: "test", modelId: "one" }, BACKGROUND_CONTEXT);
|
||||
expect(first.state.value).toEqual(publishedState);
|
||||
expect(first.state.value).toEqual({
|
||||
selected: { provider: "test", modelId: "one" },
|
||||
revision: 1,
|
||||
});
|
||||
expect(updates).toEqual([
|
||||
{ selected: null, revision: 0 },
|
||||
{ selected: { provider: "test", modelId: "one" }, revision: 1 },
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
const lateNamespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const lateModels = lateNamespace.use(Models);
|
||||
await lateNamespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(lateModels.state.value?.revision).toBe(1);
|
||||
|
||||
unsubscribe();
|
||||
await Promise.all([namespace.dispose(BACKGROUND_CONTEXT), lateNamespace.dispose(BACKGROUND_CONTEXT)]);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("publishes compact tracked operations through the remote provider", async () => {
|
||||
const provider = new RemoteServiceProvider([Timeline]);
|
||||
const initial = { entries: [{ id: "one" }], retained: { value: 1 } };
|
||||
const source = replicatedState(initial);
|
||||
provider.provide(Timeline, { state: source });
|
||||
const updates: Array<Parameters<Parameters<typeof provider.subscribe>[2]>[0]> = [];
|
||||
const raw = provider.subscribe(Timeline.id, "singleton", (update) => updates.push(update));
|
||||
expect(raw.snapshot.instances[0]?.members).toEqual([
|
||||
{ name: "state", kind: "state", sequence: 0, ops: [["r", initial]] },
|
||||
]);
|
||||
raw.activate();
|
||||
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Timeline],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const timeline = namespace.use(Timeline);
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
const previous = timeline.state.value;
|
||||
const next = { entries: [{ id: "one" }, { id: "two" }], retained: initial.retained };
|
||||
source.state.entries.push({ id: "two" });
|
||||
source.publish(BACKGROUND_CONTEXT);
|
||||
|
||||
expect(updates).toContainEqual({
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 1,
|
||||
ops: [["p", ["entries"], 1, 0, [{ id: "two" }]]],
|
||||
});
|
||||
expect(previous).toEqual({ entries: [{ id: "one" }], retained: { value: 1 } });
|
||||
expect(timeline.state.value).toEqual(next);
|
||||
|
||||
source.state.entries.push({ id: "three" });
|
||||
const late = provider.subscribe(Timeline.id, "singleton", () => {});
|
||||
expect(updates.at(-1)).toEqual({
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence: 2,
|
||||
ops: [["p", ["entries"], 2, 0, [{ id: "three" }]]],
|
||||
});
|
||||
expect(late.snapshot.instances[0]?.members).toEqual([
|
||||
{
|
||||
name: "state",
|
||||
kind: "state",
|
||||
sequence: 2,
|
||||
ops: [["r", { entries: [{ id: "one" }, { id: "two" }, { id: "three" }], retained: { value: 1 } }]],
|
||||
},
|
||||
]);
|
||||
late.close();
|
||||
raw.close();
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("keeps singleton facades stable when their provider is replaced", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
|
||||
async select() {},
|
||||
});
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
const state = models.state;
|
||||
const select = models.select;
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(state.value?.revision).toBe(1);
|
||||
|
||||
provider.withdraw(Models);
|
||||
expect(state.value).toBeUndefined();
|
||||
await expect(select({ provider: "test", modelId: "unavailable" }, BACKGROUND_CONTEXT)).rejects.toMatchObject({
|
||||
code: "service_not_found",
|
||||
});
|
||||
|
||||
const replacementSelect = vi.fn(async () => {});
|
||||
provider.replace(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 2 }),
|
||||
select: replacementSelect,
|
||||
});
|
||||
|
||||
expect(namespace.use(Models)).toBe(models);
|
||||
expect(models.state).toBe(state);
|
||||
expect(state.value?.revision).toBe(2);
|
||||
await select({ provider: "test", modelId: "replacement" }, BACKGROUND_CONTEXT);
|
||||
expect(replacementSelect).toHaveBeenCalledOnce();
|
||||
expect(() =>
|
||||
provider.replace(Models, {
|
||||
async select() {},
|
||||
} as unknown as Models),
|
||||
).toThrow("replacement must preserve its member shape");
|
||||
expect(() =>
|
||||
provider.replace(Models, {
|
||||
async state() {},
|
||||
async select() {},
|
||||
} as unknown as Models),
|
||||
).toThrow("replacement must preserve its member shape");
|
||||
expect(state.value?.revision).toBe(2);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("delivers active subscriber updates before reporting listener failures", () => {
|
||||
const failure = new Error("listener failed");
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
|
||||
async select() {},
|
||||
});
|
||||
let delivered = 0;
|
||||
const failing = provider.subscribe(Models.id, "singleton", () => {
|
||||
throw failure;
|
||||
});
|
||||
const succeeding = provider.subscribe(Models.id, "singleton", () => {
|
||||
delivered += 1;
|
||||
});
|
||||
failing.activate();
|
||||
succeeding.activate();
|
||||
|
||||
expect(() =>
|
||||
provider.replace(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 2 }),
|
||||
async select() {},
|
||||
}),
|
||||
).toThrow(failure);
|
||||
expect(delivered).toBe(1);
|
||||
|
||||
failing.close();
|
||||
succeeding.close();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("replays every buffered update before reporting listener failures", () => {
|
||||
const failure = new Error("listener failed");
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
const state = replicatedState<ModelsState>({ selected: null, revision: 0 });
|
||||
provider.provide(Models, { state, async select() {} });
|
||||
let delivered = 0;
|
||||
const subscription = provider.subscribe(Models.id, "singleton", () => {
|
||||
delivered += 1;
|
||||
throw failure;
|
||||
});
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
state.state.revision = 2;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
|
||||
expect(() => subscription.activate()).toThrow("Failed to activate remote service subscription");
|
||||
expect(delivered).toBe(2);
|
||||
subscription.close();
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("clears retained facades when providers and bindings are disposed", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
|
||||
async select() {},
|
||||
});
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
const state = models.state;
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(state.value?.revision).toBe(1);
|
||||
|
||||
provider.dispose();
|
||||
expect(state.value).toBeUndefined();
|
||||
await expect(models.select({ provider: "test", modelId: "one" }, BACKGROUND_CONTEXT)).rejects.toThrow(
|
||||
"Remote service provider is disposed",
|
||||
);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
expect(() => state.value).toThrow("Remote service binding is disposed");
|
||||
});
|
||||
|
||||
test("applies provider disposal buffered while subscriptions are starting", async () => {
|
||||
const provider = new RemoteServiceProvider([Models, { service: QuestionDialogs, mode: "keyed" }]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 1 }),
|
||||
async select() {},
|
||||
});
|
||||
provider.spawn(QuestionDialogs, "pending", {
|
||||
request: replicatedState<Question>({ question: "Pending?" }),
|
||||
async submit() {
|
||||
return { accepted: true };
|
||||
},
|
||||
});
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models, QuestionDialogs],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
const observed: QuestionDialogs[] = [];
|
||||
namespace.observe(QuestionDialogs, (service) => {
|
||||
observed.push(service);
|
||||
});
|
||||
|
||||
provider.dispose();
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(models.state.value).toBeUndefined();
|
||||
expect(observed).toEqual([]);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
test("keeps deferred service handles inaccessible until host activation", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 0 }),
|
||||
async select() {},
|
||||
});
|
||||
let active = false;
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
bound: false,
|
||||
assertAccess() {
|
||||
if (!active) throw new Error("Service handles are not active");
|
||||
},
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
|
||||
expect(() => models.state.value).toThrow("Service handles are not active");
|
||||
expect(() => models.state.subscribe(() => {})).toThrow("Service handles are not active");
|
||||
expect(() => models.select({ provider: "test", modelId: "one" }, BACKGROUND_CONTEXT)).toThrow(
|
||||
"Service handles are not active",
|
||||
);
|
||||
|
||||
await namespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
active = true;
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(0);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("rejects namespace readiness when initial hydration fails", async () => {
|
||||
const failure = new Error("initial hydration failed");
|
||||
const errors: Error[] = [];
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: {
|
||||
invoke: () => Promise.reject(new Error("unexpected invocation")),
|
||||
subscribe: () => Promise.reject(failure),
|
||||
},
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
|
||||
await expect(namespace.ready(BACKGROUND_CONTEXT)).rejects.toBe(failure);
|
||||
expect(models.state.value).toBeUndefined();
|
||||
expect(errors).toEqual([failure]);
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
test("buffers state updates that race subscription hydration", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
const state = replicatedState<ModelsState>({ selected: null, revision: 0 });
|
||||
provider.provide(Models, {
|
||||
state,
|
||||
async select() {},
|
||||
});
|
||||
const transport: RemoteServiceTransport = {
|
||||
invoke: (call, context) => provider.invoke(call, context),
|
||||
subscribe: async (serviceId, mode, listener) => {
|
||||
const subscription = provider.subscribe(serviceId, mode, listener);
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
return {
|
||||
snapshot: subscription.snapshot,
|
||||
activate: () => subscription.activate(),
|
||||
close: () => subscription.close(),
|
||||
};
|
||||
},
|
||||
};
|
||||
const namespace = createRemoteServiceBinding({ services: [Models], transport });
|
||||
const models = namespace.use(Models);
|
||||
const revisions: number[] = [];
|
||||
models.state.subscribe((value) => revisions.push(value.revision));
|
||||
|
||||
await vi.waitFor(() => expect(revisions).toEqual([0, 1]));
|
||||
expect(models.state.value?.revision).toBe(1);
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["duplicate", 0],
|
||||
["gap", 2],
|
||||
] as const)("clears replicated state after a %s operation sequence", async (_kind, sequence) => {
|
||||
let sendUpdate: ((update: Parameters<Parameters<RemoteServiceTransport["subscribe"]>[2]>[0]) => void) | undefined;
|
||||
const errors: Error[] = [];
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: {
|
||||
invoke: () => Promise.reject(new Error("unexpected invocation")),
|
||||
async subscribe(_serviceId, _mode, listener) {
|
||||
sendUpdate = (update) => listener(update, BACKGROUND_CONTEXT);
|
||||
return {
|
||||
snapshot: {
|
||||
serviceId: Models.id,
|
||||
mode: "singleton",
|
||||
instances: [
|
||||
{
|
||||
members: [
|
||||
{ name: "select", kind: "method" },
|
||||
{
|
||||
name: "state",
|
||||
kind: "state",
|
||||
sequence: 0,
|
||||
ops: [["r", { selected: null, revision: 0 }]],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
activate() {},
|
||||
close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
await namespace.ready(BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(0);
|
||||
|
||||
sendUpdate?.({
|
||||
type: "state",
|
||||
member: "state",
|
||||
sequence,
|
||||
ops: [["r", { selected: null, revision: sequence }]],
|
||||
});
|
||||
expect(models.state.value).toBeUndefined();
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.message).toContain("sequence has a gap");
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
});
|
||||
|
||||
test("hydrates cold ReplicatedState replicas and replaces them across rebinds", async () => {
|
||||
const provider = new RemoteServiceProvider([Models]);
|
||||
const state = replicatedState<ModelsState>({ selected: null, revision: 0 });
|
||||
provider.provide(Models, {
|
||||
state,
|
||||
async select() {},
|
||||
});
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [Models],
|
||||
transport: createLoopbackServiceTransport(provider),
|
||||
bound: false,
|
||||
});
|
||||
const models = namespace.use(Models);
|
||||
const revisions: number[] = [];
|
||||
models.state.subscribe((value) => revisions.push(value.revision));
|
||||
expect(models.state.value).toBeUndefined();
|
||||
expect(revisions).toEqual([]);
|
||||
|
||||
state.state.revision = 1;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
await namespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(1);
|
||||
expect(revisions).toEqual([1]);
|
||||
state.state.revision = 2;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
|
||||
await namespace.rebind(false, BACKGROUND_CONTEXT);
|
||||
expect(models.state.value).toBeUndefined();
|
||||
state.state.revision = 3;
|
||||
state.publish(BACKGROUND_CONTEXT);
|
||||
expect(revisions).toEqual([1, 2]);
|
||||
await namespace.rebind(true, BACKGROUND_CONTEXT);
|
||||
expect(models.state.value?.revision).toBe(3);
|
||||
expect(revisions).toEqual([1, 2, 3]);
|
||||
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("hydrates keyed state before observe handlers and fences reused keys", async () => {
|
||||
const provider = new RemoteServiceProvider([{ service: QuestionDialogs, mode: "keyed" }]);
|
||||
expect(provider.catalogue).toEqual([{ serviceId: QuestionDialogs.id, mode: "keyed" }]);
|
||||
const transport = createLoopbackServiceTransport(provider);
|
||||
const errors: Error[] = [];
|
||||
const namespace = createRemoteServiceBinding({
|
||||
services: [QuestionDialogs],
|
||||
transport,
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
const observed: {
|
||||
question: Question | undefined;
|
||||
service: QuestionDialogs;
|
||||
context: Context;
|
||||
}[] = [];
|
||||
const stop = namespace.observe(QuestionDialogs, (service, context) => {
|
||||
observed.push({
|
||||
question: service.request.value,
|
||||
service,
|
||||
context,
|
||||
});
|
||||
});
|
||||
await vi.waitFor(() => expect(errors).toEqual([]));
|
||||
|
||||
const firstRequest = replicatedState<Question>({ question: "First?" });
|
||||
const firstSubmit = vi.fn(async () => ({ accepted: true }));
|
||||
const closeFirst = provider.spawn(QuestionDialogs, "invocation-1", {
|
||||
request: firstRequest,
|
||||
submit: firstSubmit,
|
||||
});
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(1));
|
||||
expect(observed[0]).toMatchObject({ question: { question: "First?" } });
|
||||
|
||||
const firstService = observed[0]!.service;
|
||||
firstRequest.state.question = "Updated?";
|
||||
firstRequest.publish(BACKGROUND_CONTEXT);
|
||||
expect(firstService.request.value).toEqual({ question: "Updated?" });
|
||||
await expect(firstService.submit("yes", BACKGROUND_CONTEXT)).resolves.toEqual({ accepted: true });
|
||||
expect(firstSubmit).toHaveBeenCalledWith("yes", expect.objectContaining({ abortSignal: undefined }));
|
||||
|
||||
const retainedFirstSubmit = firstService.submit;
|
||||
closeFirst();
|
||||
expect(observed[0]!.context.abortSignal?.aborted).toBe(true);
|
||||
expect(() => firstService.request.value).toThrow("observation is closed");
|
||||
expect(() => retainedFirstSubmit("late", BACKGROUND_CONTEXT)).toThrow("observation is closed");
|
||||
|
||||
const secondRequest = replicatedState<Question>({ question: "Again?" });
|
||||
const closeSecond = provider.spawn(QuestionDialogs, "invocation-1", {
|
||||
request: secondRequest,
|
||||
async submit() {
|
||||
return { accepted: false };
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(observed).toHaveLength(2));
|
||||
expect(observed[1]).toMatchObject({ question: { question: "Again?" } });
|
||||
expect(Object.is(observed[1]!.service, firstService)).toBe(false);
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
const secondService = observed[1]!.service;
|
||||
const retainedSecondSubmit = secondService.submit;
|
||||
stop();
|
||||
expect(observed[1]!.context.abortSignal?.aborted).toBe(true);
|
||||
expect(() => secondService.request.value).toThrow("observation is closed");
|
||||
expect(() => retainedSecondSubmit("late", BACKGROUND_CONTEXT)).toThrow("observation is closed");
|
||||
closeSecond();
|
||||
await namespace.dispose(BACKGROUND_CONTEXT);
|
||||
provider.dispose();
|
||||
});
|
||||
|
||||
test("rejects mode mixing and unsupported members", () => {
|
||||
const provider = new RemoteServiceProvider([Models, { service: QuestionDialogs, mode: "keyed" }]);
|
||||
provider.provide(Models, {
|
||||
state: replicatedState<ModelsState>({ selected: null, revision: 0 }),
|
||||
async select() {},
|
||||
});
|
||||
expect(() => provider.spawn(Models, "wrong", {} as Models)).toThrow(/singleton/);
|
||||
expect(() =>
|
||||
provider.spawn(QuestionDialogs, "invalid", {
|
||||
request: new Date() as unknown as ReplicatedState<Question>,
|
||||
async submit() {
|
||||
return { accepted: true };
|
||||
},
|
||||
}),
|
||||
).toThrow(/not remotely exposable/);
|
||||
provider.dispose();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user