@copilotkit/web-inspector 1.62.2 → 1.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1671 -305
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +66 -0
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +66 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1673 -307
- package/dist/index.mjs.map +1 -1
- package/dist/index.umd.js +1715 -294
- package/dist/index.umd.js.map +1 -1
- package/dist/lib/telemetry.cjs +42 -2
- package/dist/lib/telemetry.cjs.map +1 -1
- package/dist/lib/telemetry.mjs +35 -3
- package/dist/lib/telemetry.mjs.map +1 -1
- package/dist/package.cjs +1 -1
- package/dist/package.mjs +1 -1
- package/dist/styles/generated.cjs +1 -1
- package/dist/styles/generated.cjs.map +1 -1
- package/dist/styles/generated.mjs +1 -1
- package/dist/styles/generated.mjs.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/web-inspector.spec.ts +1551 -7
- package/src/index.ts +1929 -225
- package/src/lib/telemetry.ts +79 -1
- package/src/styles/generated.css +1 -1
|
@@ -7,6 +7,7 @@ import type { ThreadDebuggerProvider } from "../index.js";
|
|
|
7
7
|
import type { CopilotKitCore } from "@copilotkit/core";
|
|
8
8
|
import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkit/core";
|
|
9
9
|
import type { CopilotKitCoreSubscriber } from "@copilotkit/core";
|
|
10
|
+
import type { Memory } from "@copilotkit/core";
|
|
10
11
|
import type { AbstractAgent, AgentSubscriber } from "@ag-ui/client";
|
|
11
12
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
12
13
|
|
|
@@ -23,7 +24,7 @@ type InspectorInternals = {
|
|
|
23
24
|
|
|
24
25
|
type InspectorThreadViewInternals = {
|
|
25
26
|
isOpen: boolean;
|
|
26
|
-
selectedMenu: "threads";
|
|
27
|
+
selectedMenu: "ag-ui-events" | "threads";
|
|
27
28
|
selectedThreadId: string | null;
|
|
28
29
|
_threads: Array<{
|
|
29
30
|
id: string;
|
|
@@ -31,6 +32,15 @@ type InspectorThreadViewInternals = {
|
|
|
31
32
|
agentId: string;
|
|
32
33
|
updatedAt?: string | null;
|
|
33
34
|
}>;
|
|
35
|
+
_threadsByAgent: Map<
|
|
36
|
+
string,
|
|
37
|
+
Array<{
|
|
38
|
+
id: string;
|
|
39
|
+
name?: string | null;
|
|
40
|
+
agentId: string;
|
|
41
|
+
updatedAt?: string | null;
|
|
42
|
+
}>
|
|
43
|
+
>;
|
|
34
44
|
};
|
|
35
45
|
|
|
36
46
|
type InspectorContextInternals = {
|
|
@@ -101,6 +111,46 @@ function createMockAgent(
|
|
|
101
111
|
|
|
102
112
|
// --- Mock core factory ---
|
|
103
113
|
|
|
114
|
+
// --- Minimal no-op memory store stub ---
|
|
115
|
+
// The inspector calls core.getMemoryStore() lazily, on first Memories-tab
|
|
116
|
+
// activation (NOT on attach). All mock cores still expose this method so that
|
|
117
|
+
// tests which do activate the tab don't hit a TypeError. The stub below seeds
|
|
118
|
+
// the store with empty memories and available=true, which is the right default
|
|
119
|
+
// for tests that don't exercise the memory feature.
|
|
120
|
+
|
|
121
|
+
type MockMemoryStoreState = {
|
|
122
|
+
memories: never[];
|
|
123
|
+
isLoading: boolean;
|
|
124
|
+
isMutating: boolean;
|
|
125
|
+
error: null;
|
|
126
|
+
context: null;
|
|
127
|
+
sessionId: number;
|
|
128
|
+
available: boolean;
|
|
129
|
+
realtimeStatus: "connecting" | "connected" | "unavailable";
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
function createNoopMemoryStore() {
|
|
133
|
+
const state: MockMemoryStoreState = {
|
|
134
|
+
memories: [],
|
|
135
|
+
isLoading: false,
|
|
136
|
+
isMutating: false,
|
|
137
|
+
error: null,
|
|
138
|
+
context: null,
|
|
139
|
+
sessionId: 0,
|
|
140
|
+
available: true,
|
|
141
|
+
realtimeStatus: "connecting",
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
getState: () => state,
|
|
145
|
+
select: <T>(selector: (s: MockMemoryStoreState) => T) => ({
|
|
146
|
+
subscribe: (cb: (v: T) => void) => {
|
|
147
|
+
cb(selector(state));
|
|
148
|
+
return { unsubscribe: () => undefined };
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
104
154
|
type MockCore = {
|
|
105
155
|
agents: Record<string, AbstractAgent>;
|
|
106
156
|
context: Record<string, unknown>;
|
|
@@ -111,6 +161,7 @@ type MockCore = {
|
|
|
111
161
|
};
|
|
112
162
|
getThreadStores: () => Record<string, never>;
|
|
113
163
|
getThreadStore: (agentId: string) => undefined;
|
|
164
|
+
getMemoryStore: () => ReturnType<typeof createNoopMemoryStore>;
|
|
114
165
|
};
|
|
115
166
|
|
|
116
167
|
function createMockCore(initialAgents: Record<string, AbstractAgent> = {}) {
|
|
@@ -130,6 +181,9 @@ function createMockCore(initialAgents: Record<string, AbstractAgent> = {}) {
|
|
|
130
181
|
getThreadStore(_agentId: string) {
|
|
131
182
|
return undefined;
|
|
132
183
|
},
|
|
184
|
+
getMemoryStore() {
|
|
185
|
+
return createNoopMemoryStore();
|
|
186
|
+
},
|
|
133
187
|
};
|
|
134
188
|
|
|
135
189
|
return {
|
|
@@ -1401,6 +1455,7 @@ type HeaderMockCore = {
|
|
|
1401
1455
|
getThreadStore: (agentId: string) => undefined;
|
|
1402
1456
|
registerThreadStore: (agentId: string, store: unknown) => void;
|
|
1403
1457
|
unregisterThreadStore: (agentId: string) => void;
|
|
1458
|
+
getMemoryStore: () => ReturnType<typeof createNoopMemoryStore>;
|
|
1404
1459
|
};
|
|
1405
1460
|
|
|
1406
1461
|
function createHeaderMockCore(
|
|
@@ -1437,6 +1492,9 @@ function createHeaderMockCore(
|
|
|
1437
1492
|
},
|
|
1438
1493
|
registerThreadStore() {},
|
|
1439
1494
|
unregisterThreadStore() {},
|
|
1495
|
+
getMemoryStore() {
|
|
1496
|
+
return createNoopMemoryStore();
|
|
1497
|
+
},
|
|
1440
1498
|
};
|
|
1441
1499
|
|
|
1442
1500
|
const asCore = () => core as unknown as CopilotKitCore;
|
|
@@ -1481,6 +1539,14 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => {
|
|
|
1481
1539
|
properties: Record<string, unknown>;
|
|
1482
1540
|
};
|
|
1483
1541
|
});
|
|
1542
|
+
const threadListText = (inspector: WebInspectorElement) =>
|
|
1543
|
+
inspector.shadowRoot?.querySelector("cpk-thread-list")?.shadowRoot
|
|
1544
|
+
?.textContent ?? "";
|
|
1545
|
+
const expectNoUtmParams = (url: URL) => {
|
|
1546
|
+
expect(url.searchParams.has("utm_source")).toBe(false);
|
|
1547
|
+
expect(url.searchParams.has("utm_medium")).toBe(false);
|
|
1548
|
+
expect(url.searchParams.has("utm_campaign")).toBe(false);
|
|
1549
|
+
};
|
|
1484
1550
|
|
|
1485
1551
|
beforeEach(() => {
|
|
1486
1552
|
document.body.innerHTML = "";
|
|
@@ -1497,6 +1563,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => {
|
|
|
1497
1563
|
});
|
|
1498
1564
|
|
|
1499
1565
|
afterEach(() => {
|
|
1566
|
+
vi.useRealTimers();
|
|
1500
1567
|
vi.unstubAllGlobals();
|
|
1501
1568
|
});
|
|
1502
1569
|
|
|
@@ -1649,13 +1716,17 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => {
|
|
|
1649
1716
|
"Talk to an Engineer",
|
|
1650
1717
|
"Sign up for Intelligence",
|
|
1651
1718
|
]);
|
|
1719
|
+
const engineer = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
|
|
1720
|
+
'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]',
|
|
1721
|
+
);
|
|
1722
|
+
expect(engineer?.closest("#cpk-main-scroll")).toBeNull();
|
|
1652
1723
|
expect(text).not.toContain("No threads yet");
|
|
1653
1724
|
expect(
|
|
1654
1725
|
fetchMock.mock.calls.some((call) => String(call[0]).includes("/threads")),
|
|
1655
1726
|
).toBe(false);
|
|
1656
1727
|
});
|
|
1657
1728
|
|
|
1658
|
-
it("adds
|
|
1729
|
+
it("adds inspector attribution to locked-state CTAs", async () => {
|
|
1659
1730
|
const { agent } = createMockAgent("alpha");
|
|
1660
1731
|
const harness = createHeaderMockCore(
|
|
1661
1732
|
{ alpha: agent },
|
|
@@ -1678,7 +1749,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => {
|
|
|
1678
1749
|
await inspector.updateComplete;
|
|
1679
1750
|
|
|
1680
1751
|
const signup = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
|
|
1681
|
-
'a[href^="https://
|
|
1752
|
+
'a[href^="https://dashboard.operations.copilotkit.ai/sign-in"]',
|
|
1682
1753
|
);
|
|
1683
1754
|
const engineer = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
|
|
1684
1755
|
'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]',
|
|
@@ -1688,7 +1759,10 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => {
|
|
|
1688
1759
|
expect(engineer).not.toBeNull();
|
|
1689
1760
|
|
|
1690
1761
|
const signupUrl = new URL(signup!.href);
|
|
1762
|
+
expect(signupUrl.origin).toBe("https://dashboard.operations.copilotkit.ai");
|
|
1763
|
+
expect(signupUrl.pathname).toBe("/sign-in");
|
|
1691
1764
|
expect(signupUrl.searchParams.get("ref")).toBe("cpk-inspector");
|
|
1765
|
+
expectNoUtmParams(signupUrl);
|
|
1692
1766
|
const distinctId = signupUrl.searchParams.get("posthog_distinct_id");
|
|
1693
1767
|
expect(distinctId).toMatch(/^[0-9a-f-]{36}$/);
|
|
1694
1768
|
|
|
@@ -1696,6 +1770,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => {
|
|
|
1696
1770
|
expect(engineerUrl.origin).toBe("https://www.copilotkit.ai");
|
|
1697
1771
|
expect(engineerUrl.pathname).toBe("/talk-to-an-engineer");
|
|
1698
1772
|
expect(engineerUrl.searchParams.get("ref")).toBe("cpk-inspector-threads");
|
|
1773
|
+
expectNoUtmParams(engineerUrl);
|
|
1699
1774
|
expect(engineerUrl.searchParams.get("posthog_distinct_id")).toBe(
|
|
1700
1775
|
distinctId,
|
|
1701
1776
|
);
|
|
@@ -1746,7 +1821,7 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => {
|
|
|
1746
1821
|
}
|
|
1747
1822
|
});
|
|
1748
1823
|
|
|
1749
|
-
it("
|
|
1824
|
+
it("renders example threads and the deselected overview when enabled thread history is empty", async () => {
|
|
1750
1825
|
const { agent } = createMockAgent("alpha");
|
|
1751
1826
|
const harness = createHeaderMockCore({ alpha: agent }, {}, {}, true);
|
|
1752
1827
|
|
|
@@ -1763,12 +1838,1481 @@ describe("WebInspectorElement owned thread store headers (#5581)", () => {
|
|
|
1763
1838
|
internals.handleMenuSelect("threads");
|
|
1764
1839
|
await inspector.updateComplete;
|
|
1765
1840
|
|
|
1766
|
-
|
|
1841
|
+
await vi.waitFor(() => {
|
|
1842
|
+
const text = threadListText(inspector);
|
|
1843
|
+
expect(text).toContain("Realtime thread sync");
|
|
1844
|
+
expect(text).toContain("Manage saved conversations");
|
|
1845
|
+
expect(text).toContain("Inspect durable run history");
|
|
1846
|
+
});
|
|
1847
|
+
|
|
1848
|
+
const text = inspector.shadowRoot?.textContent ?? "";
|
|
1849
|
+
expect(text).toContain("Threads are persistent, inspectable conversations");
|
|
1850
|
+
expect(text).toContain(
|
|
1851
|
+
"Take a tour with the example threads in the sidebar.",
|
|
1852
|
+
);
|
|
1853
|
+
const threadsDocs = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
|
|
1854
|
+
'a[href^="https://docs.copilotkit.ai/threads"]',
|
|
1855
|
+
);
|
|
1856
|
+
expect(threadsDocs?.textContent?.trim()).toBe("Learn how Threads work");
|
|
1857
|
+
const threadsDocsUrl = new URL(threadsDocs!.href);
|
|
1858
|
+
expect(threadsDocsUrl.origin).toBe("https://docs.copilotkit.ai");
|
|
1859
|
+
expect(threadsDocsUrl.pathname).toBe("/threads");
|
|
1860
|
+
expect(threadsDocsUrl.searchParams.get("ref")).toBe(
|
|
1861
|
+
"cpk-inspector-threads",
|
|
1862
|
+
);
|
|
1863
|
+
expectNoUtmParams(threadsDocsUrl);
|
|
1864
|
+
const selfHosted = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
|
|
1865
|
+
'a[href^="https://docs.copilotkit.ai/premium/self-hosting"]',
|
|
1866
|
+
);
|
|
1867
|
+
expect(selfHosted?.textContent?.trim()).toBe(
|
|
1868
|
+
"Explore self-hosted Intelligence",
|
|
1869
|
+
);
|
|
1870
|
+
const selfHostedUrl = new URL(selfHosted!.href);
|
|
1871
|
+
expect(selfHostedUrl.origin).toBe("https://docs.copilotkit.ai");
|
|
1872
|
+
expect(selfHostedUrl.pathname).toBe("/premium/self-hosting");
|
|
1873
|
+
expect(selfHostedUrl.searchParams.get("ref")).toBe("cpk-inspector-threads");
|
|
1874
|
+
expectNoUtmParams(selfHostedUrl);
|
|
1875
|
+
expect(threadListText(inspector)).toContain("Example");
|
|
1876
|
+
expect(text).not.toContain("No threads yet");
|
|
1877
|
+
expect(
|
|
1878
|
+
inspector.shadowRoot?.querySelector("cpk-thread-details"),
|
|
1879
|
+
).toBeNull();
|
|
1880
|
+
expect(
|
|
1881
|
+
(inspector as unknown as InspectorThreadViewInternals).selectedThreadId,
|
|
1882
|
+
).toBeNull();
|
|
1883
|
+
|
|
1767
1884
|
const engineer = inspector.shadowRoot?.querySelector<HTMLAnchorElement>(
|
|
1768
1885
|
'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]',
|
|
1769
1886
|
);
|
|
1770
|
-
|
|
1771
|
-
|
|
1887
|
+
const engineerUrl = new URL(engineer!.href);
|
|
1888
|
+
expect(engineerUrl.origin).toBe("https://www.copilotkit.ai");
|
|
1889
|
+
expect(engineerUrl.pathname).toBe("/talk-to-an-engineer");
|
|
1890
|
+
expect(engineerUrl.searchParams.get("ref")).toBe("cpk-inspector-threads");
|
|
1891
|
+
expectNoUtmParams(engineerUrl);
|
|
1892
|
+
expect(engineer?.closest("#cpk-main-scroll")).toBeNull();
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
it("defers loading the empty overview video until after the overview paints", async () => {
|
|
1896
|
+
vi.useFakeTimers();
|
|
1897
|
+
vi.stubGlobal("matchMedia", () => ({ matches: false }));
|
|
1898
|
+
|
|
1899
|
+
const { agent } = createMockAgent("alpha");
|
|
1900
|
+
const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false);
|
|
1901
|
+
|
|
1902
|
+
const inspector = new WebInspectorElement();
|
|
1903
|
+
document.body.appendChild(inspector);
|
|
1904
|
+
inspector.core = harness.core as unknown as WebInspectorElement["core"];
|
|
1905
|
+
harness.emitAgentsChanged();
|
|
1906
|
+
|
|
1907
|
+
const internals = inspector as unknown as {
|
|
1908
|
+
isOpen: boolean;
|
|
1909
|
+
handleMenuSelect: (key: "threads") => void;
|
|
1910
|
+
};
|
|
1911
|
+
internals.isOpen = true;
|
|
1912
|
+
internals.handleMenuSelect("threads");
|
|
1913
|
+
await inspector.updateComplete;
|
|
1914
|
+
|
|
1915
|
+
expect(inspector.shadowRoot?.textContent ?? "").toContain(
|
|
1916
|
+
"Threads are persistent, inspectable conversations",
|
|
1917
|
+
);
|
|
1918
|
+
expect(
|
|
1919
|
+
inspector.shadowRoot?.querySelector(".cpk-threads-overview-video-frame"),
|
|
1920
|
+
).not.toBeNull();
|
|
1921
|
+
expect(
|
|
1922
|
+
inspector.shadowRoot?.querySelector(".cpk-threads-overview-video"),
|
|
1923
|
+
).toBeNull();
|
|
1924
|
+
|
|
1925
|
+
await vi.advanceTimersByTimeAsync(450);
|
|
1926
|
+
await inspector.updateComplete;
|
|
1927
|
+
|
|
1928
|
+
const video = inspector.shadowRoot?.querySelector<HTMLVideoElement>(
|
|
1929
|
+
".cpk-threads-overview-video",
|
|
1930
|
+
);
|
|
1931
|
+
expect(video?.src).toBe(
|
|
1932
|
+
"https://cdn.copilotkit.ai/corp-site/videos/copilotkit-generative-ui-agentic-frontend-demo.webm",
|
|
1933
|
+
);
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
it("does not load the empty overview video when reduced motion is preferred", async () => {
|
|
1937
|
+
vi.useFakeTimers();
|
|
1938
|
+
vi.stubGlobal("matchMedia", () => ({ matches: true }));
|
|
1939
|
+
|
|
1940
|
+
const { agent } = createMockAgent("alpha");
|
|
1941
|
+
const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false);
|
|
1942
|
+
|
|
1943
|
+
const inspector = new WebInspectorElement();
|
|
1944
|
+
document.body.appendChild(inspector);
|
|
1945
|
+
inspector.core = harness.core as unknown as WebInspectorElement["core"];
|
|
1946
|
+
harness.emitAgentsChanged();
|
|
1947
|
+
|
|
1948
|
+
const internals = inspector as unknown as {
|
|
1949
|
+
isOpen: boolean;
|
|
1950
|
+
handleMenuSelect: (key: "threads") => void;
|
|
1951
|
+
};
|
|
1952
|
+
internals.isOpen = true;
|
|
1953
|
+
internals.handleMenuSelect("threads");
|
|
1954
|
+
await inspector.updateComplete;
|
|
1955
|
+
|
|
1956
|
+
expect(inspector.shadowRoot?.textContent ?? "").toContain(
|
|
1957
|
+
"Threads are persistent, inspectable conversations",
|
|
1958
|
+
);
|
|
1959
|
+
expect(
|
|
1960
|
+
inspector.shadowRoot?.querySelector(".cpk-threads-overview-video-frame"),
|
|
1961
|
+
).not.toBeNull();
|
|
1962
|
+
|
|
1963
|
+
await vi.advanceTimersByTimeAsync(1200);
|
|
1964
|
+
await inspector.updateComplete;
|
|
1965
|
+
|
|
1966
|
+
expect(
|
|
1967
|
+
inspector.shadowRoot?.querySelector(".cpk-threads-overview-video"),
|
|
1968
|
+
).toBeNull();
|
|
1969
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1972
|
+
it("clears the deferred video timeout when disconnected before load", async () => {
|
|
1973
|
+
vi.useFakeTimers();
|
|
1974
|
+
vi.stubGlobal("matchMedia", () => ({ matches: false }));
|
|
1975
|
+
Object.defineProperty(window, "requestIdleCallback", {
|
|
1976
|
+
configurable: true,
|
|
1977
|
+
value: undefined,
|
|
1978
|
+
});
|
|
1979
|
+
const clearTimeoutSpy = vi.spyOn(window, "clearTimeout");
|
|
1980
|
+
|
|
1981
|
+
const { agent } = createMockAgent("alpha");
|
|
1982
|
+
const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false);
|
|
1983
|
+
|
|
1984
|
+
const inspector = new WebInspectorElement();
|
|
1985
|
+
document.body.appendChild(inspector);
|
|
1986
|
+
inspector.core = harness.core as unknown as WebInspectorElement["core"];
|
|
1987
|
+
harness.emitAgentsChanged();
|
|
1988
|
+
|
|
1989
|
+
const internals = inspector as unknown as {
|
|
1990
|
+
isOpen: boolean;
|
|
1991
|
+
handleMenuSelect: (key: "threads") => void;
|
|
1992
|
+
};
|
|
1993
|
+
internals.isOpen = true;
|
|
1994
|
+
internals.handleMenuSelect("threads");
|
|
1995
|
+
await inspector.updateComplete;
|
|
1996
|
+
|
|
1997
|
+
expect(vi.getTimerCount()).toBe(1);
|
|
1998
|
+
inspector.remove();
|
|
1999
|
+
expect(clearTimeoutSpy).toHaveBeenCalled();
|
|
2000
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
it("cancels the deferred video idle callback when disconnected before load", async () => {
|
|
2004
|
+
vi.useFakeTimers();
|
|
2005
|
+
vi.stubGlobal("matchMedia", () => ({ matches: false }));
|
|
2006
|
+
const requestIdleCallback = vi.fn(() => 123);
|
|
2007
|
+
const cancelIdleCallback = vi.fn();
|
|
2008
|
+
Object.defineProperty(window, "requestIdleCallback", {
|
|
2009
|
+
configurable: true,
|
|
2010
|
+
value: requestIdleCallback,
|
|
2011
|
+
});
|
|
2012
|
+
Object.defineProperty(window, "cancelIdleCallback", {
|
|
2013
|
+
configurable: true,
|
|
2014
|
+
value: cancelIdleCallback,
|
|
2015
|
+
});
|
|
2016
|
+
|
|
2017
|
+
const { agent } = createMockAgent("alpha");
|
|
2018
|
+
const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false);
|
|
2019
|
+
|
|
2020
|
+
const inspector = new WebInspectorElement();
|
|
2021
|
+
document.body.appendChild(inspector);
|
|
2022
|
+
inspector.core = harness.core as unknown as WebInspectorElement["core"];
|
|
2023
|
+
harness.emitAgentsChanged();
|
|
2024
|
+
|
|
2025
|
+
const internals = inspector as unknown as {
|
|
2026
|
+
isOpen: boolean;
|
|
2027
|
+
handleMenuSelect: (key: "threads") => void;
|
|
2028
|
+
};
|
|
2029
|
+
internals.isOpen = true;
|
|
2030
|
+
internals.handleMenuSelect("threads");
|
|
2031
|
+
await inspector.updateComplete;
|
|
2032
|
+
|
|
2033
|
+
expect(requestIdleCallback).toHaveBeenCalledTimes(1);
|
|
2034
|
+
inspector.remove();
|
|
2035
|
+
expect(cancelIdleCallback).toHaveBeenCalledWith(123);
|
|
2036
|
+
});
|
|
2037
|
+
|
|
2038
|
+
it("does not render example threads once real threads are present", async () => {
|
|
2039
|
+
const inspector = new WebInspectorElement();
|
|
2040
|
+
document.body.appendChild(inspector);
|
|
2041
|
+
inspector.core = createHeaderMockCore({}, {}, {}, true)
|
|
2042
|
+
.core as unknown as WebInspectorElement["core"];
|
|
2043
|
+
|
|
2044
|
+
const internals = inspector as unknown as InspectorThreadViewInternals;
|
|
2045
|
+
internals.isOpen = true;
|
|
2046
|
+
internals.selectedMenu = "threads";
|
|
2047
|
+
internals._threads = [
|
|
2048
|
+
{
|
|
2049
|
+
id: "real-thread",
|
|
2050
|
+
name: "Real customer thread",
|
|
2051
|
+
agentId: "alpha",
|
|
2052
|
+
updatedAt: "2026-06-25T10:00:00.000Z",
|
|
2053
|
+
},
|
|
2054
|
+
];
|
|
2055
|
+
internals._threadsByAgent = new Map([["alpha", internals._threads]]);
|
|
2056
|
+
inspector.requestUpdate();
|
|
2057
|
+
await inspector.updateComplete;
|
|
2058
|
+
|
|
2059
|
+
const text = threadListText(inspector);
|
|
2060
|
+
expect(text).toContain("Real customer thread");
|
|
2061
|
+
expect(text).not.toContain("Realtime thread sync");
|
|
2062
|
+
expect(text).not.toContain("Example");
|
|
2063
|
+
});
|
|
2064
|
+
|
|
2065
|
+
it("selects an example thread, shows the tour, and toggles back to the overview on second click", async () => {
|
|
2066
|
+
const { agent } = createMockAgent("alpha");
|
|
2067
|
+
const harness = createHeaderMockCore({ alpha: agent }, {}, {}, true);
|
|
2068
|
+
|
|
2069
|
+
const inspector = new WebInspectorElement();
|
|
2070
|
+
document.body.appendChild(inspector);
|
|
2071
|
+
inspector.core = harness.core as unknown as WebInspectorElement["core"];
|
|
2072
|
+
harness.emitAgentsChanged();
|
|
2073
|
+
|
|
2074
|
+
const internals = inspector as unknown as {
|
|
2075
|
+
isOpen: boolean;
|
|
2076
|
+
handleMenuSelect: (key: "threads") => void;
|
|
2077
|
+
selectedThreadId: string | null;
|
|
2078
|
+
};
|
|
2079
|
+
internals.isOpen = true;
|
|
2080
|
+
internals.handleMenuSelect("threads");
|
|
2081
|
+
await inspector.updateComplete;
|
|
2082
|
+
|
|
2083
|
+
await vi.waitFor(() => {
|
|
2084
|
+
expect(threadListText(inspector)).toContain("Realtime thread sync");
|
|
2085
|
+
});
|
|
2086
|
+
|
|
2087
|
+
const threadList = inspector.shadowRoot?.querySelector("cpk-thread-list");
|
|
2088
|
+
const firstRow =
|
|
2089
|
+
threadList?.shadowRoot?.querySelector<HTMLElement>(".cpk-tl__item");
|
|
2090
|
+
expect(firstRow).toBeDefined();
|
|
2091
|
+
|
|
2092
|
+
firstRow!.click();
|
|
2093
|
+
await inspector.updateComplete;
|
|
2094
|
+
await vi.waitFor(() => {
|
|
2095
|
+
expect(internals.selectedThreadId).toBe("example-realtime-sync");
|
|
2096
|
+
expect(
|
|
2097
|
+
inspector.shadowRoot?.querySelector("cpk-thread-details"),
|
|
2098
|
+
).not.toBe(null);
|
|
2099
|
+
expect(inspector.shadowRoot?.textContent ?? "").toContain(
|
|
2100
|
+
"Read the run as a story",
|
|
2101
|
+
);
|
|
2102
|
+
});
|
|
2103
|
+
|
|
2104
|
+
firstRow!.click();
|
|
2105
|
+
await inspector.updateComplete;
|
|
2106
|
+
|
|
2107
|
+
await vi.waitFor(() => {
|
|
2108
|
+
expect(internals.selectedThreadId).toBeNull();
|
|
2109
|
+
expect(
|
|
2110
|
+
inspector.shadowRoot?.querySelector("cpk-thread-details"),
|
|
2111
|
+
).toBeNull();
|
|
2112
|
+
expect(inspector.shadowRoot?.textContent ?? "").toContain(
|
|
2113
|
+
"Threads are persistent, inspectable conversations",
|
|
2114
|
+
);
|
|
2115
|
+
});
|
|
2116
|
+
});
|
|
2117
|
+
|
|
2118
|
+
it("persists example tour dismissal so it does not auto-open again", async () => {
|
|
2119
|
+
const stored = new Map<string, string>();
|
|
2120
|
+
vi.stubGlobal("localStorage", {
|
|
2121
|
+
getItem: (key: string) => stored.get(key) ?? null,
|
|
2122
|
+
setItem: (key: string, value: string) => stored.set(key, value),
|
|
2123
|
+
removeItem: (key: string) => stored.delete(key),
|
|
2124
|
+
clear: () => stored.clear(),
|
|
2125
|
+
get length() {
|
|
2126
|
+
return stored.size;
|
|
2127
|
+
},
|
|
2128
|
+
key: (index: number) => Array.from(stored.keys())[index] ?? null,
|
|
2129
|
+
});
|
|
2130
|
+
|
|
2131
|
+
const { agent } = createMockAgent("alpha");
|
|
2132
|
+
const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false);
|
|
2133
|
+
|
|
2134
|
+
const inspector = new WebInspectorElement();
|
|
2135
|
+
document.body.appendChild(inspector);
|
|
2136
|
+
inspector.core = harness.core as unknown as WebInspectorElement["core"];
|
|
2137
|
+
harness.emitAgentsChanged();
|
|
2138
|
+
|
|
2139
|
+
const internals = inspector as unknown as {
|
|
2140
|
+
isOpen: boolean;
|
|
2141
|
+
handleMenuSelect: (key: "threads") => void;
|
|
2142
|
+
};
|
|
2143
|
+
internals.isOpen = true;
|
|
2144
|
+
internals.handleMenuSelect("threads");
|
|
2145
|
+
await inspector.updateComplete;
|
|
2146
|
+
|
|
2147
|
+
await vi.waitFor(() => {
|
|
2148
|
+
expect(threadListText(inspector)).toContain("Realtime thread sync");
|
|
2149
|
+
});
|
|
2150
|
+
|
|
2151
|
+
const firstRow = inspector.shadowRoot
|
|
2152
|
+
?.querySelector("cpk-thread-list")
|
|
2153
|
+
?.shadowRoot?.querySelector<HTMLElement>(".cpk-tl__item");
|
|
2154
|
+
firstRow!.click();
|
|
2155
|
+
await inspector.updateComplete;
|
|
2156
|
+
|
|
2157
|
+
await vi.waitFor(() => {
|
|
2158
|
+
expect(inspector.shadowRoot?.textContent ?? "").toContain(
|
|
2159
|
+
"Read the run as a story",
|
|
2160
|
+
);
|
|
2161
|
+
});
|
|
2162
|
+
|
|
2163
|
+
const skip = Array.from(
|
|
2164
|
+
inspector.shadowRoot?.querySelectorAll<HTMLButtonElement>("button") ?? [],
|
|
2165
|
+
).find((button) => button.textContent?.trim() === "Skip");
|
|
2166
|
+
expect(skip).toBeDefined();
|
|
2167
|
+
skip!.click();
|
|
2168
|
+
await inspector.updateComplete;
|
|
2169
|
+
|
|
2170
|
+
expect(stored.get("cpk:inspector:threads-example-tour:v1")).toContain(
|
|
2171
|
+
'"dismissed":true',
|
|
2172
|
+
);
|
|
2173
|
+
|
|
2174
|
+
const secondInspector = new WebInspectorElement();
|
|
2175
|
+
document.body.appendChild(secondInspector);
|
|
2176
|
+
secondInspector.core =
|
|
2177
|
+
harness.core as unknown as WebInspectorElement["core"];
|
|
2178
|
+
harness.emitAgentsChanged();
|
|
2179
|
+
const secondInternals = secondInspector as unknown as {
|
|
2180
|
+
isOpen: boolean;
|
|
2181
|
+
handleMenuSelect: (key: "threads") => void;
|
|
2182
|
+
};
|
|
2183
|
+
secondInternals.isOpen = true;
|
|
2184
|
+
secondInternals.handleMenuSelect("threads");
|
|
2185
|
+
await secondInspector.updateComplete;
|
|
2186
|
+
|
|
2187
|
+
await vi.waitFor(() => {
|
|
2188
|
+
expect(threadListText(secondInspector)).toContain("Realtime thread sync");
|
|
2189
|
+
});
|
|
2190
|
+
|
|
2191
|
+
const secondRow = secondInspector.shadowRoot
|
|
2192
|
+
?.querySelector("cpk-thread-list")
|
|
2193
|
+
?.shadowRoot?.querySelector<HTMLElement>(".cpk-tl__item");
|
|
2194
|
+
secondRow!.click();
|
|
2195
|
+
await secondInspector.updateComplete;
|
|
2196
|
+
|
|
2197
|
+
await vi.waitFor(() => {
|
|
2198
|
+
expect(
|
|
2199
|
+
secondInspector.shadowRoot?.querySelector("cpk-thread-details"),
|
|
2200
|
+
).not.toBeNull();
|
|
2201
|
+
});
|
|
2202
|
+
expect(secondInspector.shadowRoot?.textContent ?? "").not.toContain(
|
|
2203
|
+
"Read the run as a story",
|
|
2204
|
+
);
|
|
2205
|
+
expect(secondInspector.shadowRoot?.textContent ?? "").toContain(
|
|
2206
|
+
"Show tour",
|
|
1772
2207
|
);
|
|
2208
|
+
|
|
2209
|
+
const showTour = Array.from(
|
|
2210
|
+
secondInspector.shadowRoot?.querySelectorAll<HTMLButtonElement>(
|
|
2211
|
+
"button",
|
|
2212
|
+
) ?? [],
|
|
2213
|
+
).find((button) => button.textContent?.trim() === "Show tour");
|
|
2214
|
+
expect(showTour).toBeDefined();
|
|
2215
|
+
showTour!.click();
|
|
2216
|
+
await secondInspector.updateComplete;
|
|
2217
|
+
|
|
2218
|
+
await vi.waitFor(() => {
|
|
2219
|
+
expect(secondInspector.shadowRoot?.textContent ?? "").toContain(
|
|
2220
|
+
"Read the run as a story",
|
|
2221
|
+
);
|
|
2222
|
+
});
|
|
2223
|
+
});
|
|
2224
|
+
|
|
2225
|
+
it("tracks example thread selection and tour dismissal telemetry", async () => {
|
|
2226
|
+
const { agent } = createMockAgent("alpha");
|
|
2227
|
+
const harness = createHeaderMockCore({ alpha: agent }, {}, {}, false);
|
|
2228
|
+
|
|
2229
|
+
const inspector = new WebInspectorElement();
|
|
2230
|
+
document.body.appendChild(inspector);
|
|
2231
|
+
inspector.core = harness.core as unknown as WebInspectorElement["core"];
|
|
2232
|
+
harness.emitAgentsChanged();
|
|
2233
|
+
|
|
2234
|
+
const internals = inspector as unknown as {
|
|
2235
|
+
isOpen: boolean;
|
|
2236
|
+
handleMenuSelect: (key: "threads") => void;
|
|
2237
|
+
};
|
|
2238
|
+
internals.isOpen = true;
|
|
2239
|
+
internals.handleMenuSelect("threads");
|
|
2240
|
+
await inspector.updateComplete;
|
|
2241
|
+
|
|
2242
|
+
await vi.waitFor(() => {
|
|
2243
|
+
expect(threadListText(inspector)).toContain("Realtime thread sync");
|
|
2244
|
+
});
|
|
2245
|
+
|
|
2246
|
+
const firstRow = inspector.shadowRoot
|
|
2247
|
+
?.querySelector("cpk-thread-list")
|
|
2248
|
+
?.shadowRoot?.querySelector<HTMLElement>(".cpk-tl__item");
|
|
2249
|
+
firstRow!.click();
|
|
2250
|
+
await inspector.updateComplete;
|
|
2251
|
+
|
|
2252
|
+
await vi.waitFor(() => {
|
|
2253
|
+
expect(inspector.shadowRoot?.textContent ?? "").toContain(
|
|
2254
|
+
"Read the run as a story",
|
|
2255
|
+
);
|
|
2256
|
+
});
|
|
2257
|
+
|
|
2258
|
+
const skip = Array.from(
|
|
2259
|
+
inspector.shadowRoot?.querySelectorAll<HTMLButtonElement>("button") ?? [],
|
|
2260
|
+
).find((button) => button.textContent?.trim() === "Skip");
|
|
2261
|
+
skip!.click();
|
|
2262
|
+
await inspector.updateComplete;
|
|
2263
|
+
|
|
2264
|
+
const posts = telemetryPosts();
|
|
2265
|
+
expect(
|
|
2266
|
+
posts.some(
|
|
2267
|
+
(post) => post.event === "oss.inspector.threads_example_selected",
|
|
2268
|
+
),
|
|
2269
|
+
).toBe(true);
|
|
2270
|
+
expect(
|
|
2271
|
+
posts.some(
|
|
2272
|
+
(post) => post.event === "oss.inspector.threads_example_tour_started",
|
|
2273
|
+
),
|
|
2274
|
+
).toBe(true);
|
|
2275
|
+
const stepViewed = posts.find(
|
|
2276
|
+
(post) => post.event === "oss.inspector.threads_example_tour_step_viewed",
|
|
2277
|
+
);
|
|
2278
|
+
expect(stepViewed?.properties).toMatchObject({
|
|
2279
|
+
example_thread_id: "example-realtime-sync",
|
|
2280
|
+
tour_step: 1,
|
|
2281
|
+
});
|
|
2282
|
+
const dismissed = posts.find(
|
|
2283
|
+
(post) => post.event === "oss.inspector.threads_example_tour_dismissed",
|
|
2284
|
+
);
|
|
2285
|
+
expect(dismissed?.properties).toMatchObject({
|
|
2286
|
+
example_thread_id: "example-realtime-sync",
|
|
2287
|
+
dismiss_method: "skip",
|
|
2288
|
+
});
|
|
2289
|
+
});
|
|
2290
|
+
});
|
|
2291
|
+
|
|
2292
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2293
|
+
// Wave 6 — Memories tab + cpk-memory-list coverage
|
|
2294
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
2295
|
+
//
|
|
2296
|
+
// 6.1 Helpers: makeCoreWithMemory / makeCoreNoIntelligence / mountMemories
|
|
2297
|
+
// 6.2 Subscription: inspector._memories is seeded from store
|
|
2298
|
+
// 6.3 Tab presence: "Learning" label appears in the rendered menu
|
|
2299
|
+
// 6.4 View states: locked teaser vs. enabled empty vs. enabled with cards
|
|
2300
|
+
// 6.5 cpk-memory-list: cards, kind filter, search filter, empty state
|
|
2301
|
+
// 6.6 Passive guard: inspector reads from core.getMemoryStore(), never creates its own
|
|
2302
|
+
|
|
2303
|
+
// ── 6.1 Helpers ──────────────────────────────────────────────────────────
|
|
2304
|
+
|
|
2305
|
+
type MemoryStoreState = {
|
|
2306
|
+
memories: Memory[];
|
|
2307
|
+
isLoading: boolean;
|
|
2308
|
+
isMutating: boolean;
|
|
2309
|
+
error: Error | null;
|
|
2310
|
+
context: null;
|
|
2311
|
+
sessionId: number;
|
|
2312
|
+
available: boolean;
|
|
2313
|
+
realtimeStatus: "connecting" | "connected" | "unavailable";
|
|
2314
|
+
};
|
|
2315
|
+
|
|
2316
|
+
/**
|
|
2317
|
+
* Returns a minimal mock memory store seeded with the given memories and
|
|
2318
|
+
* availability flag. The `select(selector)` method returns an Observable-like
|
|
2319
|
+
* that calls the subscriber once synchronously with the derived value, then
|
|
2320
|
+
* never again — sufficient for the inspector's subscription wiring.
|
|
2321
|
+
*/
|
|
2322
|
+
function makeMockMemoryStore(
|
|
2323
|
+
memories: Memory[],
|
|
2324
|
+
available: boolean,
|
|
2325
|
+
realtimeStatus: MemoryStoreState["realtimeStatus"] = "connected",
|
|
2326
|
+
): { store: ReturnType<typeof buildStore>; state: MemoryStoreState } {
|
|
2327
|
+
const state: MemoryStoreState = {
|
|
2328
|
+
memories,
|
|
2329
|
+
isLoading: false,
|
|
2330
|
+
isMutating: false,
|
|
2331
|
+
error: null,
|
|
2332
|
+
context: null,
|
|
2333
|
+
sessionId: 0,
|
|
2334
|
+
available,
|
|
2335
|
+
realtimeStatus,
|
|
2336
|
+
};
|
|
2337
|
+
|
|
2338
|
+
function buildStore() {
|
|
2339
|
+
return {
|
|
2340
|
+
getState: () => state,
|
|
2341
|
+
select: <T>(selector: (s: MemoryStoreState) => T) => ({
|
|
2342
|
+
subscribe: (cb: (v: T) => void) => {
|
|
2343
|
+
cb(selector(state));
|
|
2344
|
+
return { unsubscribe: () => undefined };
|
|
2345
|
+
},
|
|
2346
|
+
}),
|
|
2347
|
+
};
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
const store = buildStore();
|
|
2351
|
+
return { store, state };
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
type MemoryMockCore = {
|
|
2355
|
+
agents: Record<string, AbstractAgent>;
|
|
2356
|
+
context: Record<string, unknown>;
|
|
2357
|
+
properties: Record<string, unknown>;
|
|
2358
|
+
telemetryDisabled: boolean;
|
|
2359
|
+
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus;
|
|
2360
|
+
intelligence: { wsUrl: string } | undefined;
|
|
2361
|
+
subscribe: (subscriber: CopilotKitCoreSubscriber) => {
|
|
2362
|
+
unsubscribe: () => void;
|
|
2363
|
+
};
|
|
2364
|
+
getThreadStores: () => Record<string, never>;
|
|
2365
|
+
getThreadStore: (agentId: string) => undefined;
|
|
2366
|
+
getMemoryStore: () => ReturnType<typeof makeMockMemoryStore>["store"];
|
|
2367
|
+
};
|
|
2368
|
+
|
|
2369
|
+
/**
|
|
2370
|
+
* Returns a mock core with an intelligence property set (so the memories view
|
|
2371
|
+
* is not locked by the intelligence guard) and a memory store seeded with the
|
|
2372
|
+
* supplied memories. Pass `available: false` to simulate memories being
|
|
2373
|
+
* unavailable (which also locks the view).
|
|
2374
|
+
*/
|
|
2375
|
+
function makeCoreWithMemory(
|
|
2376
|
+
memories: Memory[],
|
|
2377
|
+
opts: {
|
|
2378
|
+
available?: boolean;
|
|
2379
|
+
telemetryDisabled?: boolean;
|
|
2380
|
+
realtimeStatus?: MemoryStoreState["realtimeStatus"];
|
|
2381
|
+
} = {},
|
|
2382
|
+
): MemoryMockCore {
|
|
2383
|
+
const available = opts.available ?? true;
|
|
2384
|
+
const { store } = makeMockMemoryStore(
|
|
2385
|
+
memories,
|
|
2386
|
+
available,
|
|
2387
|
+
opts.realtimeStatus ?? "connected",
|
|
2388
|
+
);
|
|
2389
|
+
|
|
2390
|
+
return {
|
|
2391
|
+
agents: {},
|
|
2392
|
+
context: {},
|
|
2393
|
+
properties: {},
|
|
2394
|
+
telemetryDisabled: opts.telemetryDisabled ?? false,
|
|
2395
|
+
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
|
|
2396
|
+
// Intelligence present → locked teaser is NOT shown (unless available=false).
|
|
2397
|
+
intelligence: { wsUrl: "wss://localhost" },
|
|
2398
|
+
subscribe: (_subscriber: CopilotKitCoreSubscriber) => ({
|
|
2399
|
+
unsubscribe: () => undefined,
|
|
2400
|
+
}),
|
|
2401
|
+
getThreadStores: () => ({}),
|
|
2402
|
+
getThreadStore: (_agentId: string) => undefined,
|
|
2403
|
+
getMemoryStore: () => store,
|
|
2404
|
+
};
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
/**
|
|
2408
|
+
* Returns a mock core that has NO intelligence property. Used to assert the
|
|
2409
|
+
* locked teaser regardless of memory availability.
|
|
2410
|
+
*/
|
|
2411
|
+
function makeCoreNoIntelligence(): MemoryMockCore {
|
|
2412
|
+
const { store } = makeMockMemoryStore([], true);
|
|
2413
|
+
|
|
2414
|
+
return {
|
|
2415
|
+
agents: {},
|
|
2416
|
+
context: {},
|
|
2417
|
+
properties: {},
|
|
2418
|
+
telemetryDisabled: false,
|
|
2419
|
+
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
|
|
2420
|
+
intelligence: undefined,
|
|
2421
|
+
subscribe: (_subscriber: CopilotKitCoreSubscriber) => ({
|
|
2422
|
+
unsubscribe: () => undefined,
|
|
2423
|
+
}),
|
|
2424
|
+
getThreadStores: () => ({}),
|
|
2425
|
+
getThreadStore: (_agentId: string) => undefined,
|
|
2426
|
+
getMemoryStore: () => store,
|
|
2427
|
+
};
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
/**
|
|
2431
|
+
* Mounts a `<cpk-web-inspector>` with the given core, opens it, and switches
|
|
2432
|
+
* to the memories tab. Returns the element ready for assertion.
|
|
2433
|
+
*/
|
|
2434
|
+
async function mountMemories(
|
|
2435
|
+
core: MemoryMockCore,
|
|
2436
|
+
): Promise<WebInspectorElement> {
|
|
2437
|
+
const el = new WebInspectorElement();
|
|
2438
|
+
document.body.appendChild(el);
|
|
2439
|
+
// CopilotKitCore is a full class — our mock covers what the inspector reads.
|
|
2440
|
+
el.core = core as unknown as WebInspectorElement["core"];
|
|
2441
|
+
|
|
2442
|
+
// Open the inspector window so the tab content is rendered.
|
|
2443
|
+
const internals = el as unknown as {
|
|
2444
|
+
isOpen: boolean;
|
|
2445
|
+
handleMenuSelect: (key: string) => void;
|
|
2446
|
+
};
|
|
2447
|
+
internals.isOpen = true;
|
|
2448
|
+
internals.handleMenuSelect("memories");
|
|
2449
|
+
|
|
2450
|
+
await el.updateComplete;
|
|
2451
|
+
return el;
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// ── 6.2 Subscription ─────────────────────────────────────────────────────
|
|
2455
|
+
|
|
2456
|
+
describe("WebInspectorElement memories — subscription", () => {
|
|
2457
|
+
beforeEach(() => {
|
|
2458
|
+
document.body.innerHTML = "";
|
|
2459
|
+
vi.stubGlobal("localStorage", {
|
|
2460
|
+
getItem: () => null,
|
|
2461
|
+
setItem: () => undefined,
|
|
2462
|
+
removeItem: () => undefined,
|
|
2463
|
+
clear: () => undefined,
|
|
2464
|
+
get length() {
|
|
2465
|
+
return 0;
|
|
2466
|
+
},
|
|
2467
|
+
key: () => null,
|
|
2468
|
+
});
|
|
2469
|
+
});
|
|
2470
|
+
|
|
2471
|
+
afterEach(() => {
|
|
2472
|
+
vi.unstubAllGlobals();
|
|
2473
|
+
});
|
|
2474
|
+
|
|
2475
|
+
it("seeds _memories from core.getMemoryStore() on Memories-tab activation", async () => {
|
|
2476
|
+
const oneMemory: Memory = {
|
|
2477
|
+
id: "m1",
|
|
2478
|
+
kind: "topical",
|
|
2479
|
+
scope: "user",
|
|
2480
|
+
content: "Likes dogs",
|
|
2481
|
+
sourceThreadIds: [],
|
|
2482
|
+
invalidatedAt: null,
|
|
2483
|
+
};
|
|
2484
|
+
|
|
2485
|
+
const core = makeCoreWithMemory([oneMemory]);
|
|
2486
|
+
const el = await mountMemories(core);
|
|
2487
|
+
|
|
2488
|
+
const ids = (el as unknown as { _memories: Memory[] })._memories.map(
|
|
2489
|
+
(m) => m.id,
|
|
2490
|
+
);
|
|
2491
|
+
|
|
2492
|
+
expect(ids).toEqual(["m1"]);
|
|
2493
|
+
});
|
|
2494
|
+
});
|
|
2495
|
+
|
|
2496
|
+
// ── 6.3 Tab presence ─────────────────────────────────────────────────────
|
|
2497
|
+
|
|
2498
|
+
describe("WebInspectorElement memories — tab presence", () => {
|
|
2499
|
+
beforeEach(() => {
|
|
2500
|
+
document.body.innerHTML = "";
|
|
2501
|
+
vi.stubGlobal("localStorage", {
|
|
2502
|
+
getItem: () => null,
|
|
2503
|
+
setItem: () => undefined,
|
|
2504
|
+
removeItem: () => undefined,
|
|
2505
|
+
clear: () => undefined,
|
|
2506
|
+
get length() {
|
|
2507
|
+
return 0;
|
|
2508
|
+
},
|
|
2509
|
+
key: () => null,
|
|
2510
|
+
});
|
|
2511
|
+
});
|
|
2512
|
+
|
|
2513
|
+
afterEach(() => {
|
|
2514
|
+
vi.unstubAllGlobals();
|
|
2515
|
+
});
|
|
2516
|
+
|
|
2517
|
+
it("renders a Learning tab button in the inspector menu", async () => {
|
|
2518
|
+
const core = makeCoreWithMemory([]);
|
|
2519
|
+
const el = await mountMemories(core);
|
|
2520
|
+
|
|
2521
|
+
const buttons = Array.from(
|
|
2522
|
+
el.shadowRoot?.querySelectorAll<HTMLButtonElement>("button") ?? [],
|
|
2523
|
+
);
|
|
2524
|
+
const memoriesButton = buttons.find((btn) =>
|
|
2525
|
+
btn.textContent?.trim().includes("Learning"),
|
|
2526
|
+
);
|
|
2527
|
+
|
|
2528
|
+
expect(memoriesButton, "Learning tab button should render").toBeDefined();
|
|
2529
|
+
});
|
|
2530
|
+
});
|
|
2531
|
+
|
|
2532
|
+
// ── 6.4 View states ──────────────────────────────────────────────────────
|
|
2533
|
+
|
|
2534
|
+
describe("WebInspectorElement memories — view states", () => {
|
|
2535
|
+
beforeEach(() => {
|
|
2536
|
+
document.body.innerHTML = "";
|
|
2537
|
+
vi.stubGlobal("localStorage", {
|
|
2538
|
+
getItem: () => null,
|
|
2539
|
+
setItem: () => undefined,
|
|
2540
|
+
removeItem: () => undefined,
|
|
2541
|
+
clear: () => undefined,
|
|
2542
|
+
get length() {
|
|
2543
|
+
return 0;
|
|
2544
|
+
},
|
|
2545
|
+
key: () => null,
|
|
2546
|
+
});
|
|
2547
|
+
});
|
|
2548
|
+
|
|
2549
|
+
afterEach(() => {
|
|
2550
|
+
vi.unstubAllGlobals();
|
|
2551
|
+
});
|
|
2552
|
+
|
|
2553
|
+
it("renders the locked teaser when intelligence is absent", async () => {
|
|
2554
|
+
const core = makeCoreNoIntelligence();
|
|
2555
|
+
const el = await mountMemories(core);
|
|
2556
|
+
|
|
2557
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2558
|
+
expect(text).toContain("Long-term memory");
|
|
2559
|
+
expect(text).toContain(
|
|
2560
|
+
"Long-term memory isn't enabled on this deployment.",
|
|
2561
|
+
);
|
|
2562
|
+
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
|
|
2563
|
+
expect(
|
|
2564
|
+
memoryList,
|
|
2565
|
+
"cpk-memory-list should NOT render when locked",
|
|
2566
|
+
).toBeNull();
|
|
2567
|
+
});
|
|
2568
|
+
|
|
2569
|
+
it("does not use Threads onboarding UTM attribution for locked memory CTAs", async () => {
|
|
2570
|
+
const core = makeCoreNoIntelligence();
|
|
2571
|
+
const el = await mountMemories(core);
|
|
2572
|
+
|
|
2573
|
+
const talkToEngineer = el.shadowRoot?.querySelector<HTMLAnchorElement>(
|
|
2574
|
+
'a[href^="https://www.copilotkit.ai/talk-to-an-engineer"]',
|
|
2575
|
+
);
|
|
2576
|
+
const signup = el.shadowRoot?.querySelector<HTMLAnchorElement>(
|
|
2577
|
+
'a[href^="https://go.copilotkit.ai/intelligence-signup"]',
|
|
2578
|
+
);
|
|
2579
|
+
|
|
2580
|
+
expect(talkToEngineer).not.toBeNull();
|
|
2581
|
+
expect(signup).not.toBeNull();
|
|
2582
|
+
|
|
2583
|
+
for (const href of [talkToEngineer!.href, signup!.href]) {
|
|
2584
|
+
const url = new URL(href);
|
|
2585
|
+
expect(url.searchParams.get("ref")).toBeTruthy();
|
|
2586
|
+
expect(url.searchParams.has("utm_source")).toBe(false);
|
|
2587
|
+
expect(url.searchParams.has("utm_medium")).toBe(false);
|
|
2588
|
+
expect(url.searchParams.has("utm_campaign")).toBe(false);
|
|
2589
|
+
}
|
|
2590
|
+
});
|
|
2591
|
+
|
|
2592
|
+
it("renders the locked teaser when memories are unavailable", async () => {
|
|
2593
|
+
const core = makeCoreWithMemory([], { available: false });
|
|
2594
|
+
const el = await mountMemories(core);
|
|
2595
|
+
|
|
2596
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2597
|
+
expect(text).toContain("Long-term memory");
|
|
2598
|
+
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
|
|
2599
|
+
expect(
|
|
2600
|
+
memoryList,
|
|
2601
|
+
"cpk-memory-list should NOT render when unavailable",
|
|
2602
|
+
).toBeNull();
|
|
2603
|
+
});
|
|
2604
|
+
|
|
2605
|
+
it("renders cpk-memory-list with empty state when available and no memories", async () => {
|
|
2606
|
+
const core = makeCoreWithMemory([], { available: true });
|
|
2607
|
+
const el = await mountMemories(core);
|
|
2608
|
+
|
|
2609
|
+
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
|
|
2610
|
+
expect(
|
|
2611
|
+
memoryList,
|
|
2612
|
+
"cpk-memory-list should render when enabled",
|
|
2613
|
+
).not.toBeNull();
|
|
2614
|
+
|
|
2615
|
+
await (memoryList as unknown as { updateComplete: Promise<void> })
|
|
2616
|
+
.updateComplete;
|
|
2617
|
+
const listText = memoryList?.shadowRoot?.textContent ?? "";
|
|
2618
|
+
expect(listText).toContain("No memories yet");
|
|
2619
|
+
});
|
|
2620
|
+
|
|
2621
|
+
it("keeps the list rendered (not the full-screen error) when a mutation error arrives with memories present", async () => {
|
|
2622
|
+
// INSP-2: a failed remove/update sets the store error while a valid list is
|
|
2623
|
+
// already on screen. That must NOT blank the list with the full-screen
|
|
2624
|
+
// "Failed to load memories" state — the error is surfaced inline instead.
|
|
2625
|
+
const oneMemory: Memory = {
|
|
2626
|
+
id: "m1",
|
|
2627
|
+
kind: "topical",
|
|
2628
|
+
scope: "user",
|
|
2629
|
+
content: "Likes dogs",
|
|
2630
|
+
sourceThreadIds: [],
|
|
2631
|
+
invalidatedAt: null,
|
|
2632
|
+
};
|
|
2633
|
+
|
|
2634
|
+
const core = makeCoreWithMemory([oneMemory]);
|
|
2635
|
+
const el = await mountMemories(core);
|
|
2636
|
+
|
|
2637
|
+
// Simulate a mutation failure landing after the list is rendered.
|
|
2638
|
+
(el as unknown as { _memoriesError: Error | null })._memoriesError =
|
|
2639
|
+
new Error("could not delete memory");
|
|
2640
|
+
el.requestUpdate();
|
|
2641
|
+
await el.updateComplete;
|
|
2642
|
+
|
|
2643
|
+
// The list survives.
|
|
2644
|
+
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
|
|
2645
|
+
expect(
|
|
2646
|
+
memoryList,
|
|
2647
|
+
"cpk-memory-list must remain rendered on a mutation error",
|
|
2648
|
+
).not.toBeNull();
|
|
2649
|
+
|
|
2650
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2651
|
+
// Inline, non-blocking error with distinct copy.
|
|
2652
|
+
expect(text).toContain("Action failed: could not delete memory");
|
|
2653
|
+
// The full-screen load-failure copy must NOT appear.
|
|
2654
|
+
expect(text).not.toContain("Failed to load memories");
|
|
2655
|
+
});
|
|
2656
|
+
|
|
2657
|
+
it("shows the full-screen load error only when no memories are loaded", async () => {
|
|
2658
|
+
// INSP-2 counterpart: a snapshot-load failure (empty list) still shows the
|
|
2659
|
+
// full-screen "Failed to load memories" state.
|
|
2660
|
+
const core = makeCoreWithMemory([]);
|
|
2661
|
+
const el = await mountMemories(core);
|
|
2662
|
+
|
|
2663
|
+
(el as unknown as { _memoriesError: Error | null })._memoriesError =
|
|
2664
|
+
new Error("network down");
|
|
2665
|
+
el.requestUpdate();
|
|
2666
|
+
await el.updateComplete;
|
|
2667
|
+
|
|
2668
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2669
|
+
expect(text).toContain("Failed to load memories");
|
|
2670
|
+
expect(text).toContain("network down");
|
|
2671
|
+
expect(text).not.toContain("Action failed:");
|
|
2672
|
+
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
|
|
2673
|
+
expect(memoryList).toBeNull();
|
|
2674
|
+
});
|
|
2675
|
+
|
|
2676
|
+
it("shows the 'live' indicator only when realtime is connected", async () => {
|
|
2677
|
+
const core = makeCoreWithMemory([], {
|
|
2678
|
+
available: true,
|
|
2679
|
+
realtimeStatus: "connected",
|
|
2680
|
+
});
|
|
2681
|
+
const el = await mountMemories(core);
|
|
2682
|
+
|
|
2683
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2684
|
+
expect(text).toContain("live");
|
|
2685
|
+
expect(text).not.toContain("offline");
|
|
2686
|
+
expect(text).not.toContain("reconnecting");
|
|
2687
|
+
});
|
|
2688
|
+
|
|
2689
|
+
it("shows a muted 'reconnecting' indicator while realtime is connecting", async () => {
|
|
2690
|
+
const core = makeCoreWithMemory([], {
|
|
2691
|
+
available: true,
|
|
2692
|
+
realtimeStatus: "connecting",
|
|
2693
|
+
});
|
|
2694
|
+
const el = await mountMemories(core);
|
|
2695
|
+
|
|
2696
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2697
|
+
expect(text).toContain("reconnecting");
|
|
2698
|
+
// It must NOT claim "live" while still connecting.
|
|
2699
|
+
expect(text).not.toMatch(/>\s*live\s*</);
|
|
2700
|
+
});
|
|
2701
|
+
|
|
2702
|
+
it("shows a muted 'offline' indicator when realtime has permanently given up", async () => {
|
|
2703
|
+
const core = makeCoreWithMemory([], {
|
|
2704
|
+
available: true,
|
|
2705
|
+
realtimeStatus: "unavailable",
|
|
2706
|
+
});
|
|
2707
|
+
const el = await mountMemories(core);
|
|
2708
|
+
|
|
2709
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
2710
|
+
expect(text).toContain("offline");
|
|
2711
|
+
// The frozen snapshot must NOT be labelled "live".
|
|
2712
|
+
expect(text).not.toMatch(/>\s*live\s*</);
|
|
2713
|
+
});
|
|
2714
|
+
|
|
2715
|
+
it("renders cpk-memory-list with a card when one memory is present", async () => {
|
|
2716
|
+
const oneMemory: Memory = {
|
|
2717
|
+
id: "m1",
|
|
2718
|
+
kind: "topical",
|
|
2719
|
+
scope: "user",
|
|
2720
|
+
content: "Prefers dark mode",
|
|
2721
|
+
sourceThreadIds: [],
|
|
2722
|
+
invalidatedAt: null,
|
|
2723
|
+
};
|
|
2724
|
+
|
|
2725
|
+
const core = makeCoreWithMemory([oneMemory]);
|
|
2726
|
+
const el = await mountMemories(core);
|
|
2727
|
+
|
|
2728
|
+
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
|
|
2729
|
+
expect(memoryList, "cpk-memory-list should render").not.toBeNull();
|
|
2730
|
+
|
|
2731
|
+
await (memoryList as unknown as { updateComplete: Promise<void> })
|
|
2732
|
+
.updateComplete;
|
|
2733
|
+
const cards = memoryList?.shadowRoot?.querySelectorAll(".cpk-ml__card");
|
|
2734
|
+
expect(cards?.length).toBe(1);
|
|
2735
|
+
});
|
|
2736
|
+
});
|
|
2737
|
+
|
|
2738
|
+
// ── 6.5 cpk-memory-list ──────────────────────────────────────────────────
|
|
2739
|
+
|
|
2740
|
+
describe("cpk-memory-list", () => {
|
|
2741
|
+
const threeMemories: Memory[] = [
|
|
2742
|
+
{
|
|
2743
|
+
id: "t1",
|
|
2744
|
+
kind: "topical",
|
|
2745
|
+
scope: "user",
|
|
2746
|
+
content: "Likes cats",
|
|
2747
|
+
sourceThreadIds: [],
|
|
2748
|
+
invalidatedAt: null,
|
|
2749
|
+
},
|
|
2750
|
+
{
|
|
2751
|
+
id: "e1",
|
|
2752
|
+
kind: "episodic",
|
|
2753
|
+
scope: "user",
|
|
2754
|
+
content: "First login was on a Monday",
|
|
2755
|
+
sourceThreadIds: [],
|
|
2756
|
+
invalidatedAt: null,
|
|
2757
|
+
},
|
|
2758
|
+
{
|
|
2759
|
+
id: "o1",
|
|
2760
|
+
kind: "operational",
|
|
2761
|
+
scope: "user",
|
|
2762
|
+
content: "Deploys on Thursdays",
|
|
2763
|
+
sourceThreadIds: [],
|
|
2764
|
+
invalidatedAt: null,
|
|
2765
|
+
},
|
|
2766
|
+
];
|
|
2767
|
+
|
|
2768
|
+
/** Create and mount a standalone cpk-memory-list element. */
|
|
2769
|
+
async function mountList(memories: Memory[]): Promise<Element> {
|
|
2770
|
+
const el = document.createElement("cpk-memory-list");
|
|
2771
|
+
document.body.appendChild(el);
|
|
2772
|
+
// Assign memories via property (same as Lit's .memories=${...} binding).
|
|
2773
|
+
(el as unknown as { memories: Memory[] }).memories = memories;
|
|
2774
|
+
// Trigger update if the element is a Lit element.
|
|
2775
|
+
if ("updateComplete" in el) {
|
|
2776
|
+
await (el as unknown as { updateComplete: Promise<void> }).updateComplete;
|
|
2777
|
+
}
|
|
2778
|
+
return el;
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
beforeEach(() => {
|
|
2782
|
+
document.body.innerHTML = "";
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
it("renders one card per memory in order", async () => {
|
|
2786
|
+
const el = await mountList(threeMemories);
|
|
2787
|
+
const cards = el.shadowRoot?.querySelectorAll(".cpk-ml__card");
|
|
2788
|
+
expect(cards?.length).toBe(3);
|
|
2789
|
+
const contents = Array.from(cards ?? []).map((card) =>
|
|
2790
|
+
card.querySelector(".cpk-ml__content")?.textContent?.trim(),
|
|
2791
|
+
);
|
|
2792
|
+
expect(contents).toEqual([
|
|
2793
|
+
"Likes cats",
|
|
2794
|
+
"First login was on a Monday",
|
|
2795
|
+
"Deploys on Thursdays",
|
|
2796
|
+
]);
|
|
2797
|
+
});
|
|
2798
|
+
|
|
2799
|
+
it("narrows cards when an operational kind filter is clicked", async () => {
|
|
2800
|
+
const el = await mountList(threeMemories);
|
|
2801
|
+
|
|
2802
|
+
const operationalSeg = el.shadowRoot?.querySelector<HTMLElement>(
|
|
2803
|
+
'[data-kind="operational"]',
|
|
2804
|
+
);
|
|
2805
|
+
expect(
|
|
2806
|
+
operationalSeg,
|
|
2807
|
+
"operational filter segment should exist",
|
|
2808
|
+
).not.toBeNull();
|
|
2809
|
+
|
|
2810
|
+
operationalSeg!.click();
|
|
2811
|
+
await (el as unknown as { updateComplete: Promise<void> }).updateComplete;
|
|
2812
|
+
|
|
2813
|
+
const cards = el.shadowRoot?.querySelectorAll(".cpk-ml__card");
|
|
2814
|
+
expect(cards?.length).toBe(1);
|
|
2815
|
+
expect(
|
|
2816
|
+
cards?.[0]?.querySelector(".cpk-ml__content")?.textContent?.trim(),
|
|
2817
|
+
).toBe("Deploys on Thursdays");
|
|
2818
|
+
});
|
|
2819
|
+
|
|
2820
|
+
it("filters cards by search text (case-insensitive)", async () => {
|
|
2821
|
+
const el = await mountList(threeMemories);
|
|
2822
|
+
|
|
2823
|
+
const searchInput = el.shadowRoot?.querySelector<HTMLInputElement>(
|
|
2824
|
+
".cpk-ml__search-input",
|
|
2825
|
+
);
|
|
2826
|
+
expect(searchInput, "search input should exist").not.toBeNull();
|
|
2827
|
+
|
|
2828
|
+
searchInput!.value = "deploy";
|
|
2829
|
+
searchInput!.dispatchEvent(new Event("input", { bubbles: true }));
|
|
2830
|
+
await (el as unknown as { updateComplete: Promise<void> }).updateComplete;
|
|
2831
|
+
|
|
2832
|
+
const cards = el.shadowRoot?.querySelectorAll(".cpk-ml__card");
|
|
2833
|
+
expect(cards?.length).toBe(1);
|
|
2834
|
+
expect(
|
|
2835
|
+
cards?.[0]?.querySelector(".cpk-ml__content")?.textContent?.trim(),
|
|
2836
|
+
).toBe("Deploys on Thursdays");
|
|
2837
|
+
});
|
|
2838
|
+
|
|
2839
|
+
it("shows the empty state when memories is empty", async () => {
|
|
2840
|
+
const el = await mountList([]);
|
|
2841
|
+
const empty = el.shadowRoot?.querySelector(".cpk-ml__empty");
|
|
2842
|
+
expect(empty, "empty state should render").not.toBeNull();
|
|
2843
|
+
expect(el.shadowRoot?.textContent ?? "").toContain("No memories yet");
|
|
2844
|
+
const cards = el.shadowRoot?.querySelectorAll(".cpk-ml__card");
|
|
2845
|
+
expect(cards?.length ?? 0).toBe(0);
|
|
2846
|
+
});
|
|
2847
|
+
});
|
|
2848
|
+
|
|
2849
|
+
// ── 6.6 Passive guard ────────────────────────────────────────────────────
|
|
2850
|
+
|
|
2851
|
+
describe("WebInspectorElement memories — passive store guard", () => {
|
|
2852
|
+
beforeEach(() => {
|
|
2853
|
+
document.body.innerHTML = "";
|
|
2854
|
+
vi.stubGlobal("localStorage", {
|
|
2855
|
+
getItem: () => null,
|
|
2856
|
+
setItem: () => undefined,
|
|
2857
|
+
removeItem: () => undefined,
|
|
2858
|
+
clear: () => undefined,
|
|
2859
|
+
get length() {
|
|
2860
|
+
return 0;
|
|
2861
|
+
},
|
|
2862
|
+
key: () => null,
|
|
2863
|
+
});
|
|
2864
|
+
});
|
|
2865
|
+
|
|
2866
|
+
afterEach(() => {
|
|
2867
|
+
vi.unstubAllGlobals();
|
|
2868
|
+
});
|
|
2869
|
+
|
|
2870
|
+
it("calls core.getMemoryStore() on tab activation and reads from the returned store", async () => {
|
|
2871
|
+
const core = makeCoreWithMemory([]);
|
|
2872
|
+
const spy = vi.spyOn(core, "getMemoryStore");
|
|
2873
|
+
|
|
2874
|
+
await mountMemories(core);
|
|
2875
|
+
|
|
2876
|
+
expect(spy).toHaveBeenCalled();
|
|
2877
|
+
|
|
2878
|
+
// The store instance that spy captured is the exact same object that
|
|
2879
|
+
// core.getMemoryStore() returns — inspector reads from it, never wraps it.
|
|
2880
|
+
const returnedStore = spy.mock.results[0]?.value;
|
|
2881
|
+
expect(returnedStore).toBeDefined();
|
|
2882
|
+
// Verify the inspector consumed the store by checking its getState was accessible
|
|
2883
|
+
// (if the inspector had created its own store instead, this reference would differ).
|
|
2884
|
+
expect(typeof returnedStore.getState).toBe("function");
|
|
2885
|
+
expect(typeof returnedStore.select).toBe("function");
|
|
2886
|
+
});
|
|
2887
|
+
|
|
2888
|
+
it("does NOT call core.getMemoryStore() merely by attaching the inspector", async () => {
|
|
2889
|
+
// INSP-1: getMemoryStore() lazily creates + starts the store and opens
|
|
2890
|
+
// realtime, so attaching the inspector must touch nothing. The store is
|
|
2891
|
+
// only created when the user activates the Memories tab.
|
|
2892
|
+
const core = makeCoreWithMemory([]);
|
|
2893
|
+
const spy = vi.spyOn(core, "getMemoryStore");
|
|
2894
|
+
|
|
2895
|
+
const el = new WebInspectorElement();
|
|
2896
|
+
document.body.appendChild(el);
|
|
2897
|
+
el.core = core as unknown as WebInspectorElement["core"];
|
|
2898
|
+
(el as unknown as { isOpen: boolean }).isOpen = true;
|
|
2899
|
+
await el.updateComplete;
|
|
2900
|
+
|
|
2901
|
+
expect(spy).not.toHaveBeenCalled();
|
|
2902
|
+
|
|
2903
|
+
// Activating the Memories tab is what creates + subscribes to the store.
|
|
2904
|
+
(
|
|
2905
|
+
el as unknown as { handleMenuSelect: (k: string) => void }
|
|
2906
|
+
).handleMenuSelect("memories");
|
|
2907
|
+
await el.updateComplete;
|
|
2908
|
+
|
|
2909
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
2910
|
+
});
|
|
2911
|
+
|
|
2912
|
+
it("does not double-subscribe when the Memories tab is re-activated", async () => {
|
|
2913
|
+
const core = makeCoreWithMemory([]);
|
|
2914
|
+
const spy = vi.spyOn(core, "getMemoryStore");
|
|
2915
|
+
|
|
2916
|
+
const el = await mountMemories(core);
|
|
2917
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
2918
|
+
|
|
2919
|
+
// Re-activate the Memories tab — the guard must prevent a second
|
|
2920
|
+
// getMemoryStore() call (which would create a second store/realtime).
|
|
2921
|
+
(
|
|
2922
|
+
el as unknown as { handleMenuSelect: (k: string) => void }
|
|
2923
|
+
).handleMenuSelect("memories");
|
|
2924
|
+
await el.updateComplete;
|
|
2925
|
+
|
|
2926
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
2927
|
+
});
|
|
2928
|
+
|
|
2929
|
+
it("re-subscribes after detach when the Memories tab is activated again", async () => {
|
|
2930
|
+
const core = makeCoreWithMemory([]);
|
|
2931
|
+
const spy = vi.spyOn(core, "getMemoryStore");
|
|
2932
|
+
|
|
2933
|
+
const el = await mountMemories(core);
|
|
2934
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
2935
|
+
|
|
2936
|
+
// Detach (core = null) must reset the lazy-subscription guard.
|
|
2937
|
+
el.core = null;
|
|
2938
|
+
await el.updateComplete;
|
|
2939
|
+
|
|
2940
|
+
// Re-attach + re-activate the tab → a fresh subscription is created.
|
|
2941
|
+
el.core = core as unknown as WebInspectorElement["core"];
|
|
2942
|
+
(el as unknown as { isOpen: boolean }).isOpen = true;
|
|
2943
|
+
(
|
|
2944
|
+
el as unknown as { handleMenuSelect: (k: string) => void }
|
|
2945
|
+
).handleMenuSelect("memories");
|
|
2946
|
+
await el.updateComplete;
|
|
2947
|
+
|
|
2948
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
2949
|
+
});
|
|
2950
|
+
});
|
|
2951
|
+
|
|
2952
|
+
// ── 6.6.1 Active-on-boot subscription ─────────────────────────────────────
|
|
2953
|
+
//
|
|
2954
|
+
// The memory subscription is normally created on a Memories-tab CLICK
|
|
2955
|
+
// (handleMenuSelect → ensureMemorySubscription). But when the inspector boots
|
|
2956
|
+
// with the Memories tab ALREADY active — e.g. a persisted
|
|
2957
|
+
// `selectedMenu: "memories"` restored by hydrateStateFromStorageEarly — no
|
|
2958
|
+
// click ever fires, so historically no subscription was created: the realtime
|
|
2959
|
+
// indicator stayed stuck on the default "connecting" (rendered "reconnecting")
|
|
2960
|
+
// and the list was empty until the user toggled tabs. The fix subscribes when
|
|
2961
|
+
// the Memories tab is the active tab on boot, gated on the active tab so it
|
|
2962
|
+
// still does not subscribe in apps not viewing memory (INSP-1).
|
|
2963
|
+
|
|
2964
|
+
describe("WebInspectorElement memories — active-on-boot subscription", () => {
|
|
2965
|
+
beforeEach(() => {
|
|
2966
|
+
document.body.innerHTML = "";
|
|
2967
|
+
// Persist `selectedMenu: "memories"` so hydrateStateFromStorageEarly (run in
|
|
2968
|
+
// connectedCallback, before any user interaction) restores the Memories tab
|
|
2969
|
+
// as the active tab — reproducing the stuck-indicator boot scenario.
|
|
2970
|
+
const store: Record<string, string> = {
|
|
2971
|
+
"cpk:inspector:state": JSON.stringify({ selectedMenu: "memories" }),
|
|
2972
|
+
};
|
|
2973
|
+
vi.stubGlobal("localStorage", {
|
|
2974
|
+
getItem: (key: string) => store[key] ?? null,
|
|
2975
|
+
setItem: (key: string, value: string) => {
|
|
2976
|
+
store[key] = value;
|
|
2977
|
+
},
|
|
2978
|
+
removeItem: (key: string) => {
|
|
2979
|
+
delete store[key];
|
|
2980
|
+
},
|
|
2981
|
+
clear: () => {
|
|
2982
|
+
for (const key of Object.keys(store)) delete store[key];
|
|
2983
|
+
},
|
|
2984
|
+
get length() {
|
|
2985
|
+
return Object.keys(store).length;
|
|
2986
|
+
},
|
|
2987
|
+
key: (index: number) => Object.keys(store)[index] ?? null,
|
|
2988
|
+
});
|
|
2989
|
+
});
|
|
2990
|
+
|
|
2991
|
+
afterEach(() => {
|
|
2992
|
+
vi.unstubAllGlobals();
|
|
2993
|
+
});
|
|
2994
|
+
|
|
2995
|
+
it("subscribes to the memory store on boot when the Memories tab is already active (no click)", async () => {
|
|
2996
|
+
// The store reports a live realtime status. If the inspector subscribes on
|
|
2997
|
+
// boot, _memoriesRealtimeStatus reflects "connected"; if it does NOT (the
|
|
2998
|
+
// bug), it stays on the default "connecting".
|
|
2999
|
+
const core = makeCoreWithMemory([], { realtimeStatus: "connected" });
|
|
3000
|
+
const spy = vi.spyOn(core, "getMemoryStore");
|
|
3001
|
+
|
|
3002
|
+
const el = new WebInspectorElement();
|
|
3003
|
+
document.body.appendChild(el);
|
|
3004
|
+
// connectedCallback has already restored selectedMenu = "memories".
|
|
3005
|
+
// Assigning core (the realistic boot path) must trigger the subscription
|
|
3006
|
+
// without any handleMenuSelect click.
|
|
3007
|
+
el.core = core as unknown as WebInspectorElement["core"];
|
|
3008
|
+
(el as unknown as { isOpen: boolean }).isOpen = true;
|
|
3009
|
+
await el.updateComplete;
|
|
3010
|
+
|
|
3011
|
+
expect(
|
|
3012
|
+
(el as unknown as { selectedMenu: string }).selectedMenu,
|
|
3013
|
+
"persisted Memories tab should be the active tab on boot",
|
|
3014
|
+
).toBe("memories");
|
|
3015
|
+
|
|
3016
|
+
// The store was created + subscribed WITHOUT a tab click.
|
|
3017
|
+
expect(
|
|
3018
|
+
spy,
|
|
3019
|
+
"core.getMemoryStore() must be called on boot when Memories is active",
|
|
3020
|
+
).toHaveBeenCalled();
|
|
3021
|
+
|
|
3022
|
+
// The live status from the store is reflected — not the stuck default.
|
|
3023
|
+
expect(
|
|
3024
|
+
(el as unknown as { _memoriesRealtimeStatus: string })
|
|
3025
|
+
._memoriesRealtimeStatus,
|
|
3026
|
+
"realtime status must reflect the store, not the default 'connecting'",
|
|
3027
|
+
).toBe("connected");
|
|
3028
|
+
});
|
|
3029
|
+
|
|
3030
|
+
it("does not double-subscribe when boot subscription is followed by a Memories-tab click", async () => {
|
|
3031
|
+
// The boot subscription must be idempotent: a later explicit click must not
|
|
3032
|
+
// create a second store/realtime connection.
|
|
3033
|
+
const core = makeCoreWithMemory([], { realtimeStatus: "connected" });
|
|
3034
|
+
const spy = vi.spyOn(core, "getMemoryStore");
|
|
3035
|
+
|
|
3036
|
+
const el = new WebInspectorElement();
|
|
3037
|
+
document.body.appendChild(el);
|
|
3038
|
+
el.core = core as unknown as WebInspectorElement["core"];
|
|
3039
|
+
(el as unknown as { isOpen: boolean }).isOpen = true;
|
|
3040
|
+
await el.updateComplete;
|
|
3041
|
+
|
|
3042
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
3043
|
+
|
|
3044
|
+
(
|
|
3045
|
+
el as unknown as { handleMenuSelect: (k: string) => void }
|
|
3046
|
+
).handleMenuSelect("memories");
|
|
3047
|
+
await el.updateComplete;
|
|
3048
|
+
|
|
3049
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
3050
|
+
});
|
|
3051
|
+
});
|
|
3052
|
+
|
|
3053
|
+
// ── 6.7 Older-core compat: missing getMemoryStore ────────────────────────
|
|
3054
|
+
//
|
|
3055
|
+
// An inspector attached to an older @copilotkit/core that predates
|
|
3056
|
+
// getMemoryStore must not throw. The guard added in attachToCore must fall
|
|
3057
|
+
// through to the else branch, set _memoriesAvailable = false, and leave the
|
|
3058
|
+
// memories tab in the locked-teaser state — exactly like a core that defines
|
|
3059
|
+
// the method but returns available=false.
|
|
3060
|
+
|
|
3061
|
+
describe("WebInspectorElement memories — older-core compat (no getMemoryStore)", () => {
|
|
3062
|
+
beforeEach(() => {
|
|
3063
|
+
document.body.innerHTML = "";
|
|
3064
|
+
vi.stubGlobal("localStorage", {
|
|
3065
|
+
getItem: () => null,
|
|
3066
|
+
setItem: () => undefined,
|
|
3067
|
+
removeItem: () => undefined,
|
|
3068
|
+
clear: () => undefined,
|
|
3069
|
+
get length() {
|
|
3070
|
+
return 0;
|
|
3071
|
+
},
|
|
3072
|
+
key: () => null,
|
|
3073
|
+
});
|
|
3074
|
+
});
|
|
3075
|
+
|
|
3076
|
+
afterEach(() => {
|
|
3077
|
+
vi.unstubAllGlobals();
|
|
3078
|
+
});
|
|
3079
|
+
|
|
3080
|
+
it("does not throw when core lacks getMemoryStore, and renders the locked teaser", async () => {
|
|
3081
|
+
// Build a minimal core that does NOT define getMemoryStore — simulating an
|
|
3082
|
+
// older @copilotkit/core package. We deliberately omit the method rather
|
|
3083
|
+
// than setting it to undefined so the typeof guard fires correctly.
|
|
3084
|
+
const olderCore = {
|
|
3085
|
+
agents: {},
|
|
3086
|
+
context: {},
|
|
3087
|
+
properties: {},
|
|
3088
|
+
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
|
|
3089
|
+
// intelligence present so the only lock-cause is the missing store method
|
|
3090
|
+
intelligence: { wsUrl: "wss://localhost" },
|
|
3091
|
+
subscribe: (_subscriber: CopilotKitCoreSubscriber) => ({
|
|
3092
|
+
unsubscribe: () => undefined,
|
|
3093
|
+
}),
|
|
3094
|
+
getThreadStores: () => ({}),
|
|
3095
|
+
getThreadStore: (_agentId: string) => undefined,
|
|
3096
|
+
// getMemoryStore intentionally absent
|
|
3097
|
+
};
|
|
3098
|
+
|
|
3099
|
+
const el = new WebInspectorElement();
|
|
3100
|
+
document.body.appendChild(el);
|
|
3101
|
+
|
|
3102
|
+
// Assigning core must not throw even though getMemoryStore is missing.
|
|
3103
|
+
expect(() => {
|
|
3104
|
+
el.core = olderCore as unknown as WebInspectorElement["core"];
|
|
3105
|
+
}).not.toThrow();
|
|
3106
|
+
|
|
3107
|
+
// Open and switch to the memories tab so the view state is rendered.
|
|
3108
|
+
const internals = el as unknown as {
|
|
3109
|
+
isOpen: boolean;
|
|
3110
|
+
handleMenuSelect: (key: string) => void;
|
|
3111
|
+
};
|
|
3112
|
+
internals.isOpen = true;
|
|
3113
|
+
internals.handleMenuSelect("memories");
|
|
3114
|
+
await el.updateComplete;
|
|
3115
|
+
|
|
3116
|
+
// The locked teaser must render — cpk-memory-list must NOT appear.
|
|
3117
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
3118
|
+
expect(text).toContain("Long-term memory");
|
|
3119
|
+
const memoryList = el.shadowRoot?.querySelector("cpk-memory-list");
|
|
3120
|
+
expect(
|
|
3121
|
+
memoryList,
|
|
3122
|
+
"cpk-memory-list must not render when getMemoryStore is absent",
|
|
3123
|
+
).toBeNull();
|
|
3124
|
+
});
|
|
3125
|
+
|
|
3126
|
+
it("shows the SDK-upgrade teaser (distinct from the not-enabled teaser) when getMemoryStore is absent", async () => {
|
|
3127
|
+
// INSP-3: an older @copilotkit/core (no getMemoryStore) must guide an SDK
|
|
3128
|
+
// upgrade, with copy distinct from the genuine "not enabled on this
|
|
3129
|
+
// deployment" teaser shown by a current SDK against a memory-less backend.
|
|
3130
|
+
const olderCore = {
|
|
3131
|
+
agents: {},
|
|
3132
|
+
context: {},
|
|
3133
|
+
properties: {},
|
|
3134
|
+
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
|
|
3135
|
+
intelligence: { wsUrl: "wss://localhost" },
|
|
3136
|
+
subscribe: (_subscriber: CopilotKitCoreSubscriber) => ({
|
|
3137
|
+
unsubscribe: () => undefined,
|
|
3138
|
+
}),
|
|
3139
|
+
getThreadStores: () => ({}),
|
|
3140
|
+
getThreadStore: (_agentId: string) => undefined,
|
|
3141
|
+
// getMemoryStore intentionally absent
|
|
3142
|
+
};
|
|
3143
|
+
|
|
3144
|
+
const el = new WebInspectorElement();
|
|
3145
|
+
document.body.appendChild(el);
|
|
3146
|
+
el.core = olderCore as unknown as WebInspectorElement["core"];
|
|
3147
|
+
const internals = el as unknown as {
|
|
3148
|
+
isOpen: boolean;
|
|
3149
|
+
handleMenuSelect: (key: string) => void;
|
|
3150
|
+
};
|
|
3151
|
+
internals.isOpen = true;
|
|
3152
|
+
internals.handleMenuSelect("memories");
|
|
3153
|
+
await el.updateComplete;
|
|
3154
|
+
|
|
3155
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
3156
|
+
expect(text).toContain("@copilotkit SDK");
|
|
3157
|
+
expect(text).toContain("Upgrade");
|
|
3158
|
+
// Must NOT show the deployment-not-enabled copy in this case.
|
|
3159
|
+
expect(text).not.toContain(
|
|
3160
|
+
"Long-term memory isn't enabled on this deployment.",
|
|
3161
|
+
);
|
|
3162
|
+
});
|
|
3163
|
+
|
|
3164
|
+
it("shows the not-enabled teaser (distinct from the upgrade teaser) when the current SDK reports memory unavailable", async () => {
|
|
3165
|
+
// INSP-3 counterpart: a current SDK (getMemoryStore present) whose store
|
|
3166
|
+
// reports available=false shows the deployment teaser, NOT upgrade copy.
|
|
3167
|
+
const core = makeCoreWithMemory([], { available: false });
|
|
3168
|
+
const el = await mountMemories(core);
|
|
3169
|
+
|
|
3170
|
+
const text = el.shadowRoot?.textContent ?? "";
|
|
3171
|
+
expect(text).toContain(
|
|
3172
|
+
"Long-term memory isn't enabled on this deployment.",
|
|
3173
|
+
);
|
|
3174
|
+
expect(text).not.toContain("@copilotkit SDK");
|
|
3175
|
+
});
|
|
3176
|
+
});
|
|
3177
|
+
|
|
3178
|
+
// ── 6.8 Memories tab telemetry gating (A7) + detach reset (A8) ────────────
|
|
3179
|
+
//
|
|
3180
|
+
// The memories tab is the only telemetry call site that must honor the host
|
|
3181
|
+
// `core.telemetryDisabled` opt-out and must not re-fire on every click. These
|
|
3182
|
+
// tests mirror the Threads tab-click telemetry test. They also cover that
|
|
3183
|
+
// detachFromCore resets the memory view state so a later attach to an older
|
|
3184
|
+
// core never leaks stale memory counts into telemetry.
|
|
3185
|
+
|
|
3186
|
+
describe("WebInspectorElement memories — tab telemetry + detach reset", () => {
|
|
3187
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
3188
|
+
|
|
3189
|
+
const telemetryPosts = () =>
|
|
3190
|
+
fetchMock.mock.calls
|
|
3191
|
+
.filter(
|
|
3192
|
+
(call) =>
|
|
3193
|
+
String(call[0]) === "https://telemetry.copilotkit.ai/ingest" &&
|
|
3194
|
+
(call[1] as RequestInit | undefined)?.method === "POST",
|
|
3195
|
+
)
|
|
3196
|
+
.map((call) => {
|
|
3197
|
+
const body =
|
|
3198
|
+
((call[1] as RequestInit | undefined)?.body as string) ?? "{}";
|
|
3199
|
+
return JSON.parse(body) as {
|
|
3200
|
+
event: string;
|
|
3201
|
+
properties: Record<string, unknown>;
|
|
3202
|
+
};
|
|
3203
|
+
});
|
|
3204
|
+
|
|
3205
|
+
const memoriesTabClicks = () =>
|
|
3206
|
+
telemetryPosts().filter(
|
|
3207
|
+
(post) => post.event === "oss.inspector.memories_tab_clicked",
|
|
3208
|
+
);
|
|
3209
|
+
|
|
3210
|
+
beforeEach(() => {
|
|
3211
|
+
document.body.innerHTML = "";
|
|
3212
|
+
// localStorage not opted out → track() proceeds to the fetch sink.
|
|
3213
|
+
vi.stubGlobal("localStorage", {
|
|
3214
|
+
getItem: () => null,
|
|
3215
|
+
setItem: () => undefined,
|
|
3216
|
+
removeItem: () => undefined,
|
|
3217
|
+
clear: () => undefined,
|
|
3218
|
+
get length() {
|
|
3219
|
+
return 0;
|
|
3220
|
+
},
|
|
3221
|
+
key: () => null,
|
|
3222
|
+
});
|
|
3223
|
+
fetchMock = vi.fn(() =>
|
|
3224
|
+
Promise.resolve({
|
|
3225
|
+
ok: true,
|
|
3226
|
+
status: 200,
|
|
3227
|
+
json: () => Promise.resolve({}),
|
|
3228
|
+
}),
|
|
3229
|
+
);
|
|
3230
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
3231
|
+
});
|
|
3232
|
+
|
|
3233
|
+
afterEach(() => {
|
|
3234
|
+
vi.unstubAllGlobals();
|
|
3235
|
+
});
|
|
3236
|
+
|
|
3237
|
+
it("posts memories_tab_clicked when the Memories tab is selected", async () => {
|
|
3238
|
+
const oneMemory: Memory = {
|
|
3239
|
+
id: "m1",
|
|
3240
|
+
kind: "topical",
|
|
3241
|
+
scope: "user",
|
|
3242
|
+
content: "Likes dogs",
|
|
3243
|
+
sourceThreadIds: [],
|
|
3244
|
+
invalidatedAt: null,
|
|
3245
|
+
};
|
|
3246
|
+
|
|
3247
|
+
const core = makeCoreWithMemory([oneMemory]);
|
|
3248
|
+
await mountMemories(core);
|
|
3249
|
+
await Promise.resolve();
|
|
3250
|
+
|
|
3251
|
+
const clicks = memoriesTabClicks();
|
|
3252
|
+
expect(clicks).toHaveLength(1);
|
|
3253
|
+
expect(clicks[0]!.properties).toMatchObject({
|
|
3254
|
+
memory_count: 1,
|
|
3255
|
+
available: true,
|
|
3256
|
+
});
|
|
3257
|
+
});
|
|
3258
|
+
|
|
3259
|
+
it("does NOT post memories_tab_clicked when core.telemetryDisabled is true", async () => {
|
|
3260
|
+
const core = makeCoreWithMemory([], { telemetryDisabled: true });
|
|
3261
|
+
await mountMemories(core);
|
|
3262
|
+
await Promise.resolve();
|
|
3263
|
+
|
|
3264
|
+
expect(memoriesTabClicks()).toHaveLength(0);
|
|
3265
|
+
});
|
|
3266
|
+
|
|
3267
|
+
it("does not double-fire when the already-active Memories tab is re-selected", async () => {
|
|
3268
|
+
const core = makeCoreWithMemory([]);
|
|
3269
|
+
const el = await mountMemories(core);
|
|
3270
|
+
await Promise.resolve();
|
|
3271
|
+
|
|
3272
|
+
// Re-select the already-active Memories tab.
|
|
3273
|
+
const internals = el as unknown as {
|
|
3274
|
+
handleMenuSelect: (key: string) => void;
|
|
3275
|
+
};
|
|
3276
|
+
internals.handleMenuSelect("memories");
|
|
3277
|
+
await el.updateComplete;
|
|
3278
|
+
await Promise.resolve();
|
|
3279
|
+
|
|
3280
|
+
expect(memoriesTabClicks()).toHaveLength(1);
|
|
3281
|
+
});
|
|
3282
|
+
|
|
3283
|
+
it("resets memory view state on detachFromCore (memories empty, count 0)", async () => {
|
|
3284
|
+
const oneMemory: Memory = {
|
|
3285
|
+
id: "m1",
|
|
3286
|
+
kind: "topical",
|
|
3287
|
+
scope: "user",
|
|
3288
|
+
content: "Likes dogs",
|
|
3289
|
+
sourceThreadIds: [],
|
|
3290
|
+
invalidatedAt: null,
|
|
3291
|
+
};
|
|
3292
|
+
|
|
3293
|
+
const core = makeCoreWithMemory([oneMemory]);
|
|
3294
|
+
const el = await mountMemories(core);
|
|
3295
|
+
await Promise.resolve();
|
|
3296
|
+
|
|
3297
|
+
// Sanity: the seeded memory is present before detach.
|
|
3298
|
+
expect(
|
|
3299
|
+
(el as unknown as { _memories: Memory[] })._memories.map((m) => m.id),
|
|
3300
|
+
).toEqual(["m1"]);
|
|
3301
|
+
|
|
3302
|
+
// Reassigning core triggers detachFromCore(); null means no re-attach.
|
|
3303
|
+
el.core = null;
|
|
3304
|
+
await el.updateComplete;
|
|
3305
|
+
|
|
3306
|
+
const state = el as unknown as {
|
|
3307
|
+
_memories: Memory[];
|
|
3308
|
+
_memoriesLoading: boolean;
|
|
3309
|
+
_memoriesError: Error | null;
|
|
3310
|
+
_memoriesAvailable: boolean;
|
|
3311
|
+
};
|
|
3312
|
+
expect(state._memories).toEqual([]);
|
|
3313
|
+
expect(state._memories).toHaveLength(0);
|
|
3314
|
+
expect(state._memoriesLoading).toBe(false);
|
|
3315
|
+
expect(state._memoriesError).toBeNull();
|
|
3316
|
+
expect(state._memoriesAvailable).toBe(true);
|
|
1773
3317
|
});
|
|
1774
3318
|
});
|