333 lines
9.7 KiB
TypeScript
333 lines
9.7 KiB
TypeScript
import { Component } from "@angular/core";
|
|
import { TestBed } from "@angular/core/testing";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { Mock } from "vitest";
|
|
import { ɵcreateMemoryStore } from "@copilotkit/core";
|
|
import type { ɵMemoryStore } from "@copilotkit/core";
|
|
import { CopilotKit } from "./copilotkit";
|
|
import { injectMemories, type MemoriesController } from "./memories";
|
|
|
|
const RUNTIME_URL = "https://runtime.example.com";
|
|
const WS_URL = "wss://gw.example.com/client";
|
|
|
|
type WireMemory = {
|
|
id: string;
|
|
kind: "topical";
|
|
scope: "user";
|
|
content: string;
|
|
sourceThreadIds: string[];
|
|
invalidatedAt: string | null;
|
|
};
|
|
|
|
/** Minimal wire memory matching the platform `/memories` list payload. */
|
|
function wireMemory(id: string, content = `content-${id}`): WireMemory {
|
|
return {
|
|
id,
|
|
kind: "topical",
|
|
scope: "user",
|
|
content,
|
|
sourceThreadIds: [],
|
|
invalidatedAt: null,
|
|
};
|
|
}
|
|
|
|
type FetchResponse = {
|
|
ok: boolean;
|
|
status?: number;
|
|
json?: () => Promise<unknown>;
|
|
};
|
|
|
|
/**
|
|
* Routes the store's fetches by URL + method. Activating the store fires two
|
|
* concurrent calls — the REST snapshot `GET /memories` and the realtime
|
|
* `POST /memories/subscribe` (socket join credentials). Routing by request
|
|
* (rather than call ordering) keeps the subscribe call from perturbing the
|
|
* snapshot/mutation assertions. `subscribe` always resolves to benign join
|
|
* credentials; the phoenix socket never connects under jsdom (covered by core's
|
|
* `memory.test.ts`).
|
|
*/
|
|
function routedFetch(handlers: {
|
|
snapshot: FetchResponse;
|
|
mutation?: FetchResponse;
|
|
}): Mock {
|
|
return vi.fn((url: string, init?: { method?: string }) => {
|
|
const method = init?.method ?? "GET";
|
|
if (url.endsWith("/memories/subscribe")) {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: async () => ({ joinToken: "jt-1", joinCode: "jc-1" }),
|
|
});
|
|
}
|
|
if (url.endsWith("/memories") && method === "GET") {
|
|
return Promise.resolve(handlers.snapshot);
|
|
}
|
|
// Any mutation (POST /memories, PATCH|DELETE /memories/:id).
|
|
return Promise.resolve(handlers.mutation ?? { ok: true });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stub of the ambient {@link CopilotKit} service. The binding reads only
|
|
* `core.getMemoryStore()`, so the stub holds one REAL `ɵcreateMemoryStore`
|
|
* (started) and hands it back — core owns the store's lifecycle, the binding
|
|
* just bridges it.
|
|
*/
|
|
class CopilotKitStub {
|
|
readonly store: ɵMemoryStore;
|
|
|
|
constructor(fetchMock: Mock) {
|
|
// rxjs `fromFetch` ultimately calls `globalThis.fetch`, so stub it with the
|
|
// same routed mock that is injected into the store (mirrors the React test).
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
this.store = ɵcreateMemoryStore({
|
|
fetch: fetchMock as unknown as typeof fetch,
|
|
});
|
|
this.store.start();
|
|
}
|
|
|
|
readonly core = {
|
|
getMemoryStore: (): ɵMemoryStore => this.store,
|
|
};
|
|
|
|
/**
|
|
* Activates the store the way core does once the runtime is connected:
|
|
* dispatches a real runtime context, which fires the snapshot fetch through
|
|
* the real reducer/effects/selectors.
|
|
*/
|
|
activate(): void {
|
|
this.store.setContext({
|
|
runtimeUrl: RUNTIME_URL,
|
|
wsUrl: WS_URL,
|
|
headers: {},
|
|
});
|
|
}
|
|
}
|
|
|
|
@Component({ standalone: true, template: "" })
|
|
class MemoriesHost {
|
|
controller: MemoriesController = injectMemories();
|
|
}
|
|
|
|
/**
|
|
* Sets up a TestBed with the stubbed CopilotKit service wrapping a real memory
|
|
* store driven by `fetchMock`, and mounts the host. Follows the SIFERS pattern.
|
|
*/
|
|
function setup(fetchMock: Mock): {
|
|
stub: CopilotKitStub;
|
|
fixture: ReturnType<typeof TestBed.createComponent<MemoriesHost>>;
|
|
controller: MemoriesController;
|
|
teardown: () => void;
|
|
} {
|
|
TestBed.resetTestingModule();
|
|
const stub = new CopilotKitStub(fetchMock);
|
|
TestBed.configureTestingModule({
|
|
providers: [{ provide: CopilotKit, useValue: stub }],
|
|
});
|
|
|
|
const fixture = TestBed.createComponent(MemoriesHost);
|
|
fixture.detectChanges();
|
|
|
|
// SIFERS teardown: stop the started store (frees its rxjs effects / pending
|
|
// fetches) and tear down the TestBed so nothing leaks across tests.
|
|
const teardown = (): void => {
|
|
stub.store.stop();
|
|
TestBed.resetTestingModule();
|
|
};
|
|
|
|
return {
|
|
stub,
|
|
fixture,
|
|
controller: fixture.componentInstance.controller,
|
|
teardown,
|
|
};
|
|
}
|
|
|
|
/** Runs change detection then drains microtasks so the rxjs pipeline + signals settle. */
|
|
async function flush(
|
|
fixture: ReturnType<typeof TestBed.createComponent<MemoriesHost>>,
|
|
): Promise<void> {
|
|
for (let i = 0; i < 5; i += 1) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
}
|
|
|
|
describe("injectMemories", () => {
|
|
let fetchMock: Mock;
|
|
|
|
beforeEach(() => {
|
|
fetchMock = routedFetch({
|
|
snapshot: { ok: true, json: async () => ({ memories: [] }) },
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("loads the snapshot once the store is activated", async () => {
|
|
fetchMock = routedFetch({
|
|
snapshot: {
|
|
ok: true,
|
|
json: async () => ({ memories: [wireMemory("m1"), wireMemory("m2")] }),
|
|
},
|
|
});
|
|
|
|
const { stub, fixture, controller, teardown } = setup(fetchMock);
|
|
stub.activate();
|
|
await flush(fixture);
|
|
|
|
expect(controller.memories().map((m) => m.id)).toEqual(["m1", "m2"]);
|
|
expect(controller.isLoading()).toBe(false);
|
|
expect(controller.error()).toBeNull();
|
|
expect(controller.isAvailable()).toBe(true);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
`${RUNTIME_URL}/memories`,
|
|
expect.objectContaining({ method: "GET" }),
|
|
);
|
|
|
|
teardown();
|
|
});
|
|
|
|
it("exposes realtimeStatus, defaulting to 'connecting'", async () => {
|
|
const { stub, fixture, controller, teardown } = setup(fetchMock);
|
|
stub.activate();
|
|
await flush(fixture);
|
|
|
|
// The phoenix socket never connects under the test harness, so the status
|
|
// stays at its "connecting" default; core's memory.test.ts covers the
|
|
// connected/unavailable transitions. This asserts the binding bridges the
|
|
// selector onto a signal.
|
|
expect(controller.realtimeStatus()).toBe("connecting");
|
|
|
|
teardown();
|
|
});
|
|
|
|
it("addMemory passes through the store, resolving to the created memory and adding it to the list", async () => {
|
|
fetchMock = routedFetch({
|
|
snapshot: { ok: true, json: async () => ({ memories: [] }) },
|
|
mutation: {
|
|
ok: true,
|
|
json: async () => ({ ...wireMemory("m1", "hi"), absorbed: false }),
|
|
},
|
|
});
|
|
|
|
const { stub, fixture, controller, teardown } = setup(fetchMock);
|
|
stub.activate();
|
|
await flush(fixture);
|
|
expect(controller.isLoading()).toBe(false);
|
|
|
|
const created = await controller.addMemory({
|
|
content: "hi",
|
|
kind: "topical",
|
|
});
|
|
await flush(fixture);
|
|
|
|
expect(created.id).toBe("m1");
|
|
expect(controller.memories().map((m) => m.id)).toEqual(["m1"]);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
`${RUNTIME_URL}/memories`,
|
|
expect.objectContaining({ method: "POST" }),
|
|
);
|
|
|
|
teardown();
|
|
});
|
|
|
|
it("updateMemory supersedes: resolves to the new id, old id gone from the list", async () => {
|
|
fetchMock = routedFetch({
|
|
snapshot: {
|
|
ok: true,
|
|
json: async () => ({ memories: [wireMemory("m1", "old")] }),
|
|
},
|
|
mutation: {
|
|
ok: true,
|
|
json: async () => ({ ...wireMemory("m2", "new"), retiredId: "m1" }),
|
|
},
|
|
});
|
|
|
|
const { stub, fixture, controller, teardown } = setup(fetchMock);
|
|
stub.activate();
|
|
await flush(fixture);
|
|
expect(controller.memories().map((m) => m.id)).toEqual(["m1"]);
|
|
|
|
const updated = await controller.updateMemory("m1", {
|
|
content: "new",
|
|
kind: "topical",
|
|
});
|
|
await flush(fixture);
|
|
|
|
expect(updated.id).toBe("m2");
|
|
expect(controller.memories().map((m) => m.id)).toEqual(["m2"]);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
`${RUNTIME_URL}/memories/m1`,
|
|
expect.objectContaining({ method: "PATCH" }),
|
|
);
|
|
|
|
teardown();
|
|
});
|
|
|
|
it("removeMemory DELETEs and removes the memory from the list", async () => {
|
|
fetchMock = routedFetch({
|
|
snapshot: {
|
|
ok: true,
|
|
json: async () => ({ memories: [wireMemory("m1")] }),
|
|
},
|
|
});
|
|
|
|
const { stub, fixture, controller, teardown } = setup(fetchMock);
|
|
stub.activate();
|
|
await flush(fixture);
|
|
expect(controller.memories().map((m) => m.id)).toEqual(["m1"]);
|
|
|
|
await controller.removeMemory("m1");
|
|
await flush(fixture);
|
|
|
|
expect(controller.memories()).toEqual([]);
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
`${RUNTIME_URL}/memories/m1`,
|
|
expect.objectContaining({ method: "DELETE" }),
|
|
);
|
|
|
|
teardown();
|
|
});
|
|
|
|
it("surfaces a mutation failure via the error signal and rejects the promise", async () => {
|
|
fetchMock = routedFetch({
|
|
snapshot: { ok: true, json: async () => ({ memories: [] }) },
|
|
mutation: { ok: false, status: 500 },
|
|
});
|
|
|
|
const { stub, fixture, controller, teardown } = setup(fetchMock);
|
|
stub.activate();
|
|
await flush(fixture);
|
|
|
|
await expect(
|
|
controller.addMemory({ content: "x", kind: "topical" }),
|
|
).rejects.toThrow();
|
|
await flush(fixture);
|
|
|
|
expect(controller.error()).toBeInstanceOf(Error);
|
|
|
|
teardown();
|
|
});
|
|
|
|
it("silently degrades to unavailable when the memory route is missing (404)", async () => {
|
|
fetchMock = routedFetch({
|
|
snapshot: { ok: false, status: 404 },
|
|
});
|
|
|
|
const { stub, fixture, controller, teardown } = setup(fetchMock);
|
|
stub.activate();
|
|
await flush(fixture);
|
|
|
|
expect(controller.isAvailable()).toBe(false);
|
|
expect(controller.error()).toBeNull();
|
|
expect(controller.isLoading()).toBe(false);
|
|
expect(controller.memories()).toEqual([]);
|
|
|
|
teardown();
|
|
});
|
|
});
|