From 342c361d794e1a1b3bd4afa5628aea36d1e15723 Mon Sep 17 00:00:00 2001
From: Yen Truong <36055303+yen-tt@users.noreply.github.com>
Date: Wed, 24 May 2023 15:49:34 -0400
Subject: [PATCH] Support Chat Streaming behavior (#12)
update Chat Headless to expose a `streamNextMessage` function that internally will invoke Chat Core `streamNextMessage`.
`ConversationState.Messages` state will get updated by appending the token to `text` field from `streamToken` event every time there's a new `streamToken` event. Notes are updated at the start of the stream from `startTokenStream` event. And finally, conversationId and message (for the timestamp) is updated from `endStream` event.
Updated test app to use both non-stream and stream method. Added more jest tests.
J=CLIP-152
TEST=manual&auto
see that jest tests passed
see that test app stream response to page as expected
---
.github/CODEOWNERS | 1 +
apps/test-site/.sample.env | 1 +
apps/test-site/src/App.tsx | 14 +-
package-lock.json | 10 +-
packages/chat-headless/THIRD-PARTY-NOTICES | 2 +-
.../docs/chat-headless.chatheadless.md | 1 +
...headless.chatheadless.streamnextmessage.md | 31 +++
.../chat-headless/etc/chat-headless.api.md | 25 ++
packages/chat-headless/jest.config.json | 5 +-
packages/chat-headless/package.json | 3 +-
packages/chat-headless/src/ChatHeadless.ts | 114 +++++++-
packages/chat-headless/src/corereexports.ts | 8 +
.../tests/chatheadless.chatapi.test.ts | 243 ++++++++++++++++++
.../chat-headless/tests/chatheadless.test.ts | 115 +--------
packages/chat-headless/tests/jest-setup.js | 8 +
15 files changed, 451 insertions(+), 130 deletions(-)
create mode 100644 .github/CODEOWNERS
create mode 100644 apps/test-site/.sample.env
create mode 100644 packages/chat-headless/docs/chat-headless.chatheadless.streamnextmessage.md
create mode 100644 packages/chat-headless/tests/chatheadless.chatapi.test.ts
create mode 100644 packages/chat-headless/tests/jest-setup.js
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..4b20ed4
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1 @@
+* @yext/clippy
\ No newline at end of file
diff --git a/apps/test-site/.sample.env b/apps/test-site/.sample.env
new file mode 100644
index 0000000..9a07bd8
--- /dev/null
+++ b/apps/test-site/.sample.env
@@ -0,0 +1 @@
+REACT_APP_BOT_API_KEY=
\ No newline at end of file
diff --git a/apps/test-site/src/App.tsx b/apps/test-site/src/App.tsx
index 0c9a954..b9c4372 100644
--- a/apps/test-site/src/App.tsx
+++ b/apps/test-site/src/App.tsx
@@ -8,7 +8,7 @@ import { useCallback, useEffect, useState } from "react";
const config: ChatConfig = {
botId: "red-dog-bot",
- apiKey: "API_KEY_HERE",
+ apiKey: process.env.REACT_APP_BOT_API_KEY || "BOT_KEY_HERE",
apiDomain: "liveapi-dev.yext.com",
};
@@ -16,17 +16,17 @@ function App() {
return (
-
+
);
}
-function MyComponent(): JSX.Element {
+function ChatComponent() {
const isLoading = useChatState((s) => s.conversation.isLoading);
const messages = useChatState((s) => s.conversation.messages);
- const actions = useChatActions();
const [input, setInput] = useState("");
+ const actions = useChatActions();
useEffect(() => {
if (messages.length === 0) {
@@ -39,6 +39,11 @@ function MyComponent(): JSX.Element {
setInput("");
}, [actions, input]);
+ const onClickStream = useCallback(() => {
+ actions.streamNextMessage(input);
+ setInput("");
+ }, [actions, input]);
+
const onInputChange = useCallback(
(e: React.ChangeEvent) => {
setInput(e.target.value);
@@ -54,6 +59,7 @@ function MyComponent(): JSX.Element {
{isLoading && loading...
}
Send
+ Send (Stream)
);
}
diff --git a/package-lock.json b/package-lock.json
index 39483c3..fae44ab 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18459,7 +18459,7 @@
"license": "BSD-3-Clause",
"dependencies": {
"@reduxjs/toolkit": "^1.9.5",
- "@yext/chat-core": "^0.2.0"
+ "@yext/chat-core": "^0.3.0"
},
"devDependencies": {
"@babel/preset-env": "^7.21.5",
@@ -19724,6 +19724,14 @@
"@types/yargs-parser": "*"
}
},
+ "packages/chat-headless/node_modules/@yext/chat-core": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@yext/chat-core/-/chat-core-0.3.0.tgz",
+ "integrity": "sha512-I+oNdG7kp5DixLZKS4tKYhENPvzw0N2fiBCZ040aTohTtJ6oopO+0FKSNyRIOgqPZ1ODx3oL/teqSu1TaQ6cZg==",
+ "dependencies": {
+ "cross-fetch": "^3.1.5"
+ }
+ },
"packages/chat-headless/node_modules/ansi-styles": {
"version": "4.3.0",
"dev": true,
diff --git a/packages/chat-headless/THIRD-PARTY-NOTICES b/packages/chat-headless/THIRD-PARTY-NOTICES
index 59ed6bf..fff853b 100644
--- a/packages/chat-headless/THIRD-PARTY-NOTICES
+++ b/packages/chat-headless/THIRD-PARTY-NOTICES
@@ -64,7 +64,7 @@ SOFTWARE.
The following npm package may be included in this product:
- - @yext/chat-core@0.2.0
+ - @yext/chat-core@0.3.0
This package contains the following license and notice below:
diff --git a/packages/chat-headless/docs/chat-headless.chatheadless.md b/packages/chat-headless/docs/chat-headless.chatheadless.md
index 7bd1526..cb6e6bc 100644
--- a/packages/chat-headless/docs/chat-headless.chatheadless.md
+++ b/packages/chat-headless/docs/chat-headless.chatheadless.md
@@ -36,4 +36,5 @@ export declare class ChatHeadless
| [setMessageNotes(notes)](./chat-headless.chatheadless.setmessagenotes.md) | | Sets [ConversationState.notes](./chat-headless.conversationstate.notes.md) to the specified notes |
| [setMessages(messages)](./chat-headless.chatheadless.setmessages.md) | | Sets [ConversationState.messages](./chat-headless.conversationstate.messages.md) to the specified messages |
| [setState(state)](./chat-headless.chatheadless.setstate.md) | | Sets the [State](./chat-headless.state.md) to the specified state. |
+| [streamNextMessage(text, source)](./chat-headless.chatheadless.streamnextmessage.md) | | Performs a Chat Stream API request for the next message generated by chat bot using the conversation state (e.g. message history and notes). The new message's "text" field is continously updated as tokens from the stream are consumed. Remaining conversation state are updated once the final event from the stream is recieved. |
diff --git a/packages/chat-headless/docs/chat-headless.chatheadless.streamnextmessage.md b/packages/chat-headless/docs/chat-headless.chatheadless.streamnextmessage.md
new file mode 100644
index 0000000..6a9bdb4
--- /dev/null
+++ b/packages/chat-headless/docs/chat-headless.chatheadless.streamnextmessage.md
@@ -0,0 +1,31 @@
+
+
+[Home](./index.md) > [@yext/chat-headless](./chat-headless.md) > [ChatHeadless](./chat-headless.chatheadless.md) > [streamNextMessage](./chat-headless.chatheadless.streamnextmessage.md)
+
+## ChatHeadless.streamNextMessage() method
+
+Performs a Chat Stream API request for the next message generated by chat bot using the conversation state (e.g. message history and notes). The new message's "text" field is continously updated as tokens from the stream are consumed. Remaining conversation state are updated once the final event from the stream is recieved.
+
+**Signature:**
+
+```typescript
+streamNextMessage(text?: string, source?: MessageSource): Promise;
+```
+
+## Parameters
+
+| Parameter | Type | Description |
+| --- | --- | --- |
+| text | string | _(Optional)_ the text of the next message |
+| source | MessageSource | _(Optional)_ the source of the message |
+
+**Returns:**
+
+Promise<MessageResponse>
+
+a Promise of the full response from the Chat Stream API
+
+## Remarks
+
+If rejected, an Error is returned.
+
diff --git a/packages/chat-headless/etc/chat-headless.api.md b/packages/chat-headless/etc/chat-headless.api.md
index 4d2acb5..974dd76 100644
--- a/packages/chat-headless/etc/chat-headless.api.md
+++ b/packages/chat-headless/etc/chat-headless.api.md
@@ -5,12 +5,20 @@
```ts
import { ChatConfig } from '@yext/chat-core';
+import { EndEvent } from '@yext/chat-core';
import { Message } from '@yext/chat-core';
import { MessageNotes } from '@yext/chat-core';
import { MessageRequest } from '@yext/chat-core';
import { MessageResponse } from '@yext/chat-core';
import { MessageSource } from '@yext/chat-core';
+import { RawResponse } from '@yext/chat-core';
+import { StartEvent } from '@yext/chat-core';
import { Store } from '@reduxjs/toolkit';
+import { StreamEvent } from '@yext/chat-core';
+import { StreamEventCallback } from '@yext/chat-core';
+import { StreamEventName } from '@yext/chat-core';
+import { StreamResponse } from '@yext/chat-core';
+import { TokenStreamEvent } from '@yext/chat-core';
import { Unsubscribe } from '@reduxjs/toolkit';
export { ChatConfig }
@@ -29,6 +37,7 @@ export class ChatHeadless {
get state(): State;
// @internal
get store(): Store;
+ streamNextMessage(text?: string, source?: MessageSource): Promise;
}
// @public
@@ -39,6 +48,8 @@ export interface ConversationState {
notes?: MessageNotes;
}
+export { EndEvent }
+
export { Message }
export { MessageNotes }
@@ -54,6 +65,10 @@ export interface MetaState {
context?: any;
}
+export { RawResponse }
+
+export { StartEvent }
+
// @public
export interface State {
conversation: ConversationState;
@@ -66,6 +81,16 @@ export interface StateListener {
valueAccessor(state: State): T;
}
+export { StreamEvent }
+
+export { StreamEventCallback }
+
+export { StreamEventName }
+
+export { StreamResponse }
+
+export { TokenStreamEvent }
+
// (No @packageDocumentation comment for this package)
```
diff --git a/packages/chat-headless/jest.config.json b/packages/chat-headless/jest.config.json
index ff573c9..0b915ce 100644
--- a/packages/chat-headless/jest.config.json
+++ b/packages/chat-headless/jest.config.json
@@ -6,9 +6,10 @@
"moduleFileExtensions": ["js", "ts"],
"moduleDirectories": ["node_modules", ""],
"testEnvironment": "jsdom",
- "testPathIgnorePatterns": ["./tests/mocks/*"],
+ "testPathIgnorePatterns": ["./tests/mocks/*", "./tests/jest-setup.js"],
"resetMocks": true,
"restoreMocks": true,
"clearMocks": true,
- "testMatch": ["/tests/**/*.[jt]s"]
+ "testMatch": ["/tests/**/*.[jt]s"],
+ "setupFiles": ["/tests/jest-setup.js"]
}
diff --git a/packages/chat-headless/package.json b/packages/chat-headless/package.json
index 850af28..0be208d 100644
--- a/packages/chat-headless/package.json
+++ b/packages/chat-headless/package.json
@@ -5,6 +5,7 @@
"main": "./dist/commonjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/esm/index.d.ts",
+ "sideEffects": false,
"keywords": [
"chat",
"yext",
@@ -38,7 +39,7 @@
"homepage": "https://github.com/yext/chat-headless#readme",
"dependencies": {
"@reduxjs/toolkit": "^1.9.5",
- "@yext/chat-core": "^0.2.0"
+ "@yext/chat-core": "^0.3.0"
},
"devDependencies": {
"@babel/preset-env": "^7.21.5",
diff --git a/packages/chat-headless/src/ChatHeadless.ts b/packages/chat-headless/src/ChatHeadless.ts
index 2d43dae..4aa6dda 100644
--- a/packages/chat-headless/src/ChatHeadless.ts
+++ b/packages/chat-headless/src/ChatHeadless.ts
@@ -5,6 +5,7 @@ import {
MessageNotes,
MessageResponse,
MessageSource,
+ StreamEventName,
} from "@yext/chat-core";
import { State } from "./models/state";
import { ReduxStateManager } from "./ReduxStateManager";
@@ -189,6 +190,105 @@ export class ChatHeadless {
async getNextMessage(
text?: string,
source: MessageSource = MessageSource.USER
+ ): Promise {
+ return this.nextMessageHandler(
+ async () => {
+ const { messages, conversationId, notes } = this.state.conversation;
+ const nextMessage = await this.chatCore.getNextMessage({
+ conversationId,
+ messages,
+ notes,
+ context: this.state.meta.context,
+ });
+ this.setConversationId(nextMessage.conversationId);
+ this.setMessages([...messages, nextMessage.message]);
+ this.setMessageNotes(nextMessage.notes);
+ return nextMessage;
+ },
+ text,
+ source
+ );
+ }
+
+ /**
+ * Performs a Chat Stream API request for the next message generated
+ * by chat bot using the conversation state (e.g. message history and notes).
+ * The new message's "text" field is continously updated as tokens from the
+ * stream are consumed. Remaining conversation state are updated once the
+ * final event from the stream is recieved.
+ *
+ * @public
+ *
+ * @remarks
+ * If rejected, an Error is returned.
+ *
+ * @param text - the text of the next message
+ * @param source - the source of the message
+ * @returns a Promise of the full response from the Chat Stream API
+ */
+ async streamNextMessage(
+ text?: string,
+ source: MessageSource = MessageSource.USER
+ ): Promise {
+ return this.nextMessageHandler(
+ async () => {
+ let messageResponse: MessageResponse | undefined = undefined;
+ let nextMessage: Message = {
+ source: MessageSource.BOT,
+ text: "",
+ };
+ const { messages, conversationId, notes } = this.state.conversation;
+ const stream = await this.chatCore.streamNextMessage({
+ conversationId,
+ messages,
+ notes,
+ context: this.state.meta.context,
+ });
+ stream.addEventListener(StreamEventName.StartEvent, ({ data }) => {
+ this.setMessageNotes(data);
+ });
+ stream.addEventListener(
+ StreamEventName.TokenStreamEvent,
+ ({ data }) => {
+ nextMessage = {
+ ...nextMessage,
+ text: nextMessage.text + data.token,
+ };
+ this.setMessages([...messages, nextMessage]);
+ }
+ );
+ stream.addEventListener(StreamEventName.EndEvent, ({ data }) => {
+ this.setConversationId(data.conversationId);
+ this.setMessages([...messages, data.message]);
+ messageResponse = data;
+ });
+ await stream.consume();
+ if (!messageResponse) {
+ return Promise.reject(
+ "Stream Error: Missing full message response at the end of stream."
+ );
+ }
+ return messageResponse;
+ },
+ text,
+ source
+ );
+ }
+
+ /**
+ * Setup relevant state before hitting Chat API endpoint for next message, such as
+ * setting loading status and appending new user's message in conversation state.
+ * Also update loading state when the next message is received or an error occurred.
+ *
+ * @param nextMessageFn - function to invoke to get next message
+ * @param text - the text of the next message
+ * @param source - the source of the message
+ * @returns a Promise of a response from the Chat API
+ */
+ private async nextMessageHandler(
+ nextMessageFn: () => Promise,
+ text?: string,
+ source: MessageSource = MessageSource.USER
): Promise {
this.setChatLoadingStatus(true);
let messages: Message[] = this.state.conversation.messages;
@@ -203,22 +303,14 @@ export class ChatHeadless {
];
this.setMessages(messages);
}
- let nextMessage: MessageResponse;
+ let messageResponse;
try {
- nextMessage = await this.chatCore.getNextMessage({
- conversationId: this.state.conversation.conversationId,
- messages,
- notes: this.state.conversation.notes,
- context: this.state.meta.context,
- });
+ messageResponse = await nextMessageFn();
} catch (e) {
this.setChatLoadingStatus(false);
return Promise.reject(e as Error);
}
- this.setConversationId(nextMessage.conversationId);
this.setChatLoadingStatus(false);
- this.setMessages([...messages, nextMessage.message]);
- this.setMessageNotes(nextMessage.notes);
- return nextMessage;
+ return messageResponse;
}
}
diff --git a/packages/chat-headless/src/corereexports.ts b/packages/chat-headless/src/corereexports.ts
index ecc918b..ff63075 100644
--- a/packages/chat-headless/src/corereexports.ts
+++ b/packages/chat-headless/src/corereexports.ts
@@ -5,4 +5,12 @@ export {
MessageSource,
MessageRequest,
MessageResponse,
+ StreamEventName,
+ StreamEvent,
+ StartEvent,
+ TokenStreamEvent,
+ EndEvent,
+ StreamResponse,
+ RawResponse,
+ StreamEventCallback,
} from "@yext/chat-core";
diff --git a/packages/chat-headless/tests/chatheadless.chatapi.test.ts b/packages/chat-headless/tests/chatheadless.chatapi.test.ts
new file mode 100644
index 0000000..204e387
--- /dev/null
+++ b/packages/chat-headless/tests/chatheadless.chatapi.test.ts
@@ -0,0 +1,243 @@
+import {
+ ChatHeadless,
+ Message,
+ MessageSource,
+ State,
+ ChatConfig,
+ MetaState,
+} from "../src";
+import {
+ ChatCore,
+ MessageRequest,
+ MessageResponse,
+ RawResponse,
+ StreamResponse,
+} from "@yext/chat-core";
+import { initialState } from "../src/slices/conversation";
+import { Readable } from "stream";
+
+const config: ChatConfig = {
+ botId: "MY_BOT",
+ apiKey: "MY_API_KEY",
+};
+
+const mockedMetaState: MetaState = {
+ context: {
+ foo: "bar",
+ },
+};
+
+beforeEach(() => {
+ sessionStorage.clear();
+});
+
+describe("Chat API methods work as expected", () => {
+ const expectedUserMessage: Message = {
+ text: "This is a dummy text!",
+ source: MessageSource.USER,
+ timestamp: expect.any(String),
+ };
+ const expectedResponse: MessageResponse = {
+ conversationId: "convo-id",
+ message: {
+ text: "dummy response!",
+ source: MessageSource.BOT,
+ timestamp: "2023-05-15T17:39:58.019Z",
+ },
+ notes: {
+ currentGoal: "SOME_GOAL",
+ },
+ };
+
+ async function testAPI(
+ chatHeadless: ChatHeadless,
+ testFn: (text: string) => Promise,
+ coreTestFnSpy: unknown
+ ) {
+ chatHeadless.setState({
+ conversation: initialState,
+ meta: mockedMetaState,
+ });
+ const responsePromise = testFn.bind(chatHeadless)("This is a dummy text!");
+ //state update before response
+ const expectedStateBeforeRes: State = {
+ conversation: {
+ messages: [expectedUserMessage],
+ isLoading: true,
+ },
+ meta: mockedMetaState,
+ };
+ expect(chatHeadless.state).toEqual(expectedStateBeforeRes);
+
+ const response = await responsePromise;
+ //state update after response
+ const expectedStateAfterRes = {
+ conversation: {
+ conversationId: expectedResponse.conversationId,
+ messages: [expectedUserMessage, expectedResponse.message],
+ notes: expectedResponse.notes,
+ isLoading: false,
+ },
+ meta: mockedMetaState,
+ };
+ expect(chatHeadless.state).toEqual(expectedStateAfterRes);
+ expect(coreTestFnSpy).toBeCalledTimes(1);
+ const expectedRequest: MessageRequest = {
+ conversationId: expectedStateBeforeRes.conversation.conversationId,
+ notes: expectedStateBeforeRes.conversation.notes,
+ messages: expectedStateBeforeRes.conversation.messages,
+ context: expectedStateBeforeRes.meta.context,
+ };
+ expect(coreTestFnSpy).toBeCalledWith(expectedRequest);
+ expect(response).toEqual(expectedResponse);
+ }
+
+ it("getNextMessage works as expected", async () => {
+ const chatHeadless = new ChatHeadless(config);
+ const coreGetNextMessageSpy = jest
+ .spyOn(ChatCore.prototype, "getNextMessage")
+ .mockResolvedValueOnce(expectedResponse);
+ await testAPI(
+ chatHeadless,
+ chatHeadless.getNextMessage,
+ coreGetNextMessageSpy
+ );
+ });
+
+ it("streamNextMessage works as expected", async () => {
+ const chatHeadless = new ChatHeadless(config);
+ const coreStreamtNextMessageSpy = jest
+ .spyOn(ChatCore.prototype, "streamNextMessage")
+ .mockResolvedValueOnce(
+ new StreamResponse({
+ body: new Readable({
+ read() {
+ this.push(
+ 'event: startTokenStream\ndata: { "currentGoal": "SOME_GOAL" }\n\n'
+ );
+ this.push('event: streamToken\ndata: {"token": "dummy"}\n\n');
+ this.push('event: streamToken\ndata: {"token": " response"}\n\n');
+ this.push('event: streamToken\ndata: {"token": "!"}\n\n');
+ this.push(
+ 'event: endStream\ndata: {"conversationId": "convo-id",' +
+ '"message": { "timestamp": "2023-05-15T17:39:58.019Z", "source": "BOT", "text": "dummy response!"},' +
+ '"notes": { "currentGoal": "SOME_GOAL" }}\n\n'
+ );
+ this.push(null);
+ },
+ }),
+ } as unknown as RawResponse)
+ );
+
+ const setMessagesSpy = jest.spyOn(chatHeadless, "setMessages");
+ await testAPI(
+ chatHeadless,
+ chatHeadless.streamNextMessage,
+ coreStreamtNextMessageSpy
+ );
+ // 1 for user's message, 3 for each streamToken event, and 1 for endStream event
+ expect(setMessagesSpy).toBeCalledTimes(5);
+ expect(setMessagesSpy).nthCalledWith(1, [expectedUserMessage]);
+ expect(setMessagesSpy).nthCalledWith(2, [
+ expectedUserMessage,
+ {
+ source: "BOT",
+ text: "dummy",
+ },
+ ]);
+ expect(setMessagesSpy).nthCalledWith(3, [
+ expectedUserMessage,
+ {
+ source: "BOT",
+ text: "dummy response",
+ },
+ ]);
+ expect(setMessagesSpy).nthCalledWith(4, [
+ expectedUserMessage,
+ {
+ source: "BOT",
+ text: "dummy response!",
+ },
+ ]);
+ expect(setMessagesSpy).nthCalledWith(5, [
+ expectedUserMessage,
+ {
+ timestamp: "2023-05-15T17:39:58.019Z",
+ source: "BOT",
+ text: "dummy response!",
+ },
+ ]);
+ });
+
+ it("logs error when streamNextMessage failed to get full message response at end of stream", async () => {
+ const chatHeadless = new ChatHeadless(config);
+ const coreStreamNextMessageSpy = jest
+ .spyOn(ChatCore.prototype, "streamNextMessage")
+ .mockResolvedValueOnce(
+ new StreamResponse({
+ body: new Readable({
+ read() {
+ this.push("event: startTokenStream\ndata: {}\n\n");
+ this.push('event: streamToken\ndata: {"token": "dummy"}\n\n');
+ this.push(
+ 'event: streamToken\ndata: {"token": " response!"}\n\n'
+ );
+ //missing endStream event with full response
+ this.push(null);
+ },
+ }),
+ } as unknown as RawResponse)
+ );
+ expect.assertions(2);
+
+ try {
+ await chatHeadless.streamNextMessage("This is a dummy text!");
+ } catch (e) {
+ // eslint-disable-next-line jest/no-conditional-expect
+ expect(e).toEqual(
+ "Stream Error: Missing full message response at the end of stream."
+ );
+ }
+ expect(coreStreamNextMessageSpy).toBeCalledTimes(1);
+ });
+
+ it("updates loading status and throw error when an API request returns an error", async () => {
+ const errorMessage =
+ "Chat API error: FATAL_ERROR: Invalid API Key. (code: 1)";
+ const chatHeadless = new ChatHeadless(config);
+ const coreGetNextMessageSpy = jest
+ .spyOn(ChatCore.prototype, "getNextMessage")
+ .mockRejectedValue(errorMessage);
+ expect.assertions(3);
+
+ try {
+ await chatHeadless.getNextMessage("This is a dummy text!");
+ } catch (e) {
+ // eslint-disable-next-line jest/no-conditional-expect
+ expect(e).toEqual(errorMessage);
+ // eslint-disable-next-line jest/no-conditional-expect
+ expect(chatHeadless.state).toEqual({
+ conversation: {
+ messages: [expectedUserMessage],
+ isLoading: false,
+ },
+ meta: {},
+ });
+ }
+ expect(coreGetNextMessageSpy).toBeCalledTimes(1);
+ });
+
+ it("sends message array as is for initial message from bot", async () => {
+ const chatHeadless = new ChatHeadless(config);
+ expect(chatHeadless.state.conversation.messages).toEqual([]);
+
+ const coreGetNextMessageSpy = jest
+ .spyOn(ChatCore.prototype, "getNextMessage")
+ .mockResolvedValueOnce(expectedResponse);
+ await chatHeadless.getNextMessage();
+ expect(coreGetNextMessageSpy).toBeCalledTimes(1);
+ expect(coreGetNextMessageSpy).toBeCalledWith({
+ messages: [],
+ });
+ });
+});
diff --git a/packages/chat-headless/tests/chatheadless.test.ts b/packages/chat-headless/tests/chatheadless.test.ts
index 93c3d69..d020827 100644
--- a/packages/chat-headless/tests/chatheadless.test.ts
+++ b/packages/chat-headless/tests/chatheadless.test.ts
@@ -7,12 +7,7 @@ import {
MetaState,
State,
} from "../src";
-import {
- ChatConfig,
- ChatCore,
- MessageRequest,
- MessageResponse,
-} from "@yext/chat-core";
+import { ChatConfig } from "@yext/chat-core";
import { ReduxStateManager } from "../src/ReduxStateManager";
import {
initialState,
@@ -32,6 +27,10 @@ const mockedMetaState: MetaState = {
},
};
+beforeEach(() => {
+ sessionStorage.clear();
+});
+
describe("setters work as expected", () => {
it("setState works as expected", () => {
const chatHeadless = new ChatHeadless(config);
@@ -145,110 +144,6 @@ describe("setters work as expected", () => {
});
});
-describe("Chat API methods work as expected", () => {
- const expectedUserMessage: Message = {
- text: "This is a dummy text!",
- source: MessageSource.USER,
- timestamp: expect.any(String),
- };
- const expectedResponse: MessageResponse = {
- conversationId: "convo-id",
- message: {
- text: "dummy response!",
- source: MessageSource.BOT,
- timestamp: "2023-05-15T17:39:58.019Z",
- },
- notes: {
- currentGoal: "SOME_GOAL",
- },
- };
-
- it("getNextMessage works as expected", async () => {
- const chatHeadless = new ChatHeadless(config, false);
- chatHeadless.setState({
- conversation: initialState,
- meta: mockedMetaState,
- });
- const coreGetNextMessageSpy = jest
- .spyOn(ChatCore.prototype, "getNextMessage")
- .mockResolvedValueOnce(expectedResponse);
- const responsePromise = chatHeadless.getNextMessage(
- "This is a dummy text!"
- );
- //state update before response
- const expectedStateBeforeRes: State = {
- conversation: {
- messages: [expectedUserMessage],
- isLoading: true,
- },
- meta: mockedMetaState,
- };
- expect(chatHeadless.state).toEqual(expectedStateBeforeRes);
-
- const response = await responsePromise;
- //state update after response
- const expectedStateAfterRes = {
- conversation: {
- conversationId: expectedResponse.conversationId,
- messages: [expectedUserMessage, expectedResponse.message],
- notes: expectedResponse.notes,
- isLoading: false,
- },
- meta: mockedMetaState,
- };
- expect(chatHeadless.state).toEqual(expectedStateAfterRes);
- expect(coreGetNextMessageSpy).toBeCalledTimes(1);
- const expectedRequest: MessageRequest = {
- conversationId: expectedStateBeforeRes.conversation.conversationId,
- notes: expectedStateBeforeRes.conversation.notes,
- messages: expectedStateBeforeRes.conversation.messages,
- context: expectedStateBeforeRes.meta.context,
- };
- expect(coreGetNextMessageSpy).toBeCalledWith(expectedRequest);
- expect(response).toEqual(expectedResponse);
- });
-
- it("updates loading status and throw error when an API request returns an error", async () => {
- const errorMessage =
- "Chat API error: FATAL_ERROR: Invalid API Key. (code: 1)";
- const chatHeadless = new ChatHeadless(config, false);
- const coreGetNextMessageSpy = jest
- .spyOn(ChatCore.prototype, "getNextMessage")
- .mockRejectedValue(errorMessage);
- expect.assertions(3);
-
- try {
- await chatHeadless.getNextMessage("This is a dummy text!");
- } catch (e) {
- // eslint-disable-next-line jest/no-conditional-expect
- expect(e).toEqual(errorMessage);
- // eslint-disable-next-line jest/no-conditional-expect
- expect(chatHeadless.state).toEqual({
- conversation: {
- messages: [expectedUserMessage],
- isLoading: false,
- },
- meta: {},
- });
- }
- expect(coreGetNextMessageSpy).toBeCalledTimes(1);
- });
-
- it("sends message array as is for initial message from bot", async () => {
- const chatHeadless = new ChatHeadless(config, false);
- expect(chatHeadless.state.conversation.messages).toEqual([]);
-
- const coreGetNextMessageSpy = jest
- .spyOn(ChatCore.prototype, "getNextMessage")
- .mockResolvedValueOnce(expectedResponse);
- await chatHeadless.getNextMessage();
- expect(coreGetNextMessageSpy).toBeCalledTimes(1);
- expect(coreGetNextMessageSpy).toBeCalledWith({
- messages: [],
- });
- });
-});
-
describe("addListener works as expected", () => {
const messages: Message[] = [
{
diff --git a/packages/chat-headless/tests/jest-setup.js b/packages/chat-headless/tests/jest-setup.js
new file mode 100644
index 0000000..575b7a7
--- /dev/null
+++ b/packages/chat-headless/tests/jest-setup.js
@@ -0,0 +1,8 @@
+import { TextDecoder, TextEncoder } from "util";
+
+/**
+ * jest's jsdom doesn't have the following properties defined in global for the DOM.
+ * polyfill it with functions from NodeJS. This is to used in Chat Core.
+ */
+global.TextDecoder = TextDecoder;
+global.TextEncoder = TextEncoder;