mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The task chat shows messages from agents and users. > - An agent message can contain an internal on-behalf-of user value. > - The new task view showed this value as a badge next to the agent name. > - This badge added unwanted identity text to the task chat. > - This pull request removes the badge from the new task view. > - The benefit is a simpler agent identity row in the task chat. ## Linked Issues or Issue Description **What happened?** The new task view shows a `for <user>` badge next to an agent name when a message has an on-behalf-of user value. **Expected behavior** The task chat shows the agent name without the on-behalf-of badge. **Steps to reproduce** 1. Open the new task view. 2. Show an agent message that has an on-behalf-of user value. 3. See the attribution badge next to the agent name. **Paperclip version or commit** The issue occurs on `master` at commit `d0b7ba419`. **Deployment mode** Local dev and all other modes that use the web UI. ## What Changed - Removed the attribution chip from the task chat agent identity. - Added a regression test that supplies an on-behalf-of value and confirms that the badge is absent. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/components/task-chat/TaskChatBubble.test.tsx` passed with 21 tests. - `pnpm check:token-gates` passed. - `pnpm --filter @paperclipai/ui typecheck` passed. ## Risks - Low risk. The change only removes one badge from the new task chat view. - The message data remains unchanged. > This is a small UI fix. It does not add roadmap scope. ## Model Used - OpenAI Codex, GPT-5.4, with reasoning and tool use. The provider did not expose the context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/no-internal-issue-references`, `fix/sandbox-secret-resolution`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
537 lines
19 KiB
TypeScript
537 lines
19 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import type { ReactNode } from "react";
|
|
import type { IssueAttachment } from "@paperclipai/shared";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ThemeProvider } from "@/context/ThemeContext";
|
|
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
|
|
import { TaskChatBubble } from "./TaskChatBubble";
|
|
import type { TaskChatMessageItem } from "./task-chat-model";
|
|
|
|
describe("TaskChatBubble attachment chips", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root?.unmount());
|
|
root = null;
|
|
container.remove();
|
|
});
|
|
|
|
function renderMessage(
|
|
text: string,
|
|
author: TaskChatMessageItem["author"] = "human",
|
|
attachments: IssueAttachment[] = [],
|
|
) {
|
|
const item: TaskChatMessageItem = { id: "m1", kind: "message", author, text };
|
|
flushSync(() =>
|
|
root!.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={item} attachments={attachments} />
|
|
</ThemeProvider>,
|
|
),
|
|
);
|
|
}
|
|
|
|
it("shows persistent iMessage attribution only on inbound human bubbles", () => {
|
|
for (const author of ["human", "agent"] as const) {
|
|
flushSync(() => root!.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={{ id: "photon", kind: "message", author, text: "A reply", timestamp: "1:56 PM", sourceChannel: "imessage-photon" }} />
|
|
</ThemeProvider>,
|
|
));
|
|
expect(container.textContent?.includes("Sent from iMessage")).toBe(author === "human");
|
|
}
|
|
renderMessage("Board reply");
|
|
expect(container.textContent).not.toContain("Sent from iMessage");
|
|
});
|
|
|
|
it("opens attachment images in the shared task gallery", () => {
|
|
const openGallery = vi.fn(() => true);
|
|
const contentPath = "/api/attachments/shared-image/content";
|
|
flushSync(() => root!.render(
|
|
<ThemeProvider>
|
|
<IssueGalleryContext.Provider value={openGallery}>
|
|
<TaskChatBubble item={{ id: "m1", kind: "message", author: "agent", text: `` }} />
|
|
</IssueGalleryContext.Provider>
|
|
</ThemeProvider>,
|
|
));
|
|
const image = container.querySelector<HTMLImageElement>(`img[src="${contentPath}"]`);
|
|
expect(image).not.toBeNull();
|
|
flushSync(() => image!.click());
|
|
expect(openGallery).toHaveBeenCalledWith(contentPath);
|
|
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
|
});
|
|
|
|
it("renders a file reference as an attachment chip linking to the file", () => {
|
|
renderMessage("Here you go.\n\n[notes.txt](/api/attachments/abc/content) ");
|
|
|
|
const group = container.querySelector('[data-testid="task-chat-bubble-attachments"]');
|
|
expect(group).not.toBeNull();
|
|
expect(group?.textContent).toContain("notes.txt");
|
|
expect(group?.textContent).toContain("Text");
|
|
const link = group?.querySelector<HTMLAnchorElement>("a[href='/api/attachments/abc/content']");
|
|
expect(link).not.toBeNull();
|
|
// The link-only line left the rendered body; the prose stayed.
|
|
expect(container.textContent).toContain("Here you go.");
|
|
expect(container.textContent).not.toContain("(/api/attachments/abc/content)");
|
|
});
|
|
|
|
it("renders a chip-only row when the message body is just the file link", () => {
|
|
renderMessage("[report.pdf](/api/attachments/r1/content)", "agent");
|
|
|
|
expect(container.querySelector('[data-testid="task-chat-bubble-attachments"]')).not.toBeNull();
|
|
// No empty bubble is left behind once the only line is chipped.
|
|
expect(container.textContent).toContain("report.pdf");
|
|
expect(container.textContent).toContain("PDF");
|
|
});
|
|
|
|
it("leaves messages without file references untouched", () => {
|
|
renderMessage("Just words and a [normal link](https://example.com).");
|
|
|
|
expect(container.querySelector('[data-testid="task-chat-bubble-attachments"]')).toBeNull();
|
|
expect(container.textContent).toContain("Just words");
|
|
});
|
|
|
|
it("renders an extensionless PNG attachment as a thumbnail", () => {
|
|
renderMessage(
|
|
"Done.\n\n[desktop Default](/api/attachments/img/content)",
|
|
"agent",
|
|
[
|
|
attachment({
|
|
id: "img",
|
|
originalFilename: "desktop Default",
|
|
contentType: "image/png",
|
|
byteSize: 4096,
|
|
}),
|
|
],
|
|
);
|
|
|
|
const media = container.querySelector('[data-testid="task-chat-bubble-media"]');
|
|
expect(media).not.toBeNull();
|
|
expect(media?.querySelector("img")?.getAttribute("alt")).toBe("desktop Default");
|
|
expect(container.querySelector('[data-testid="task-chat-bubble-attachments"]')).toBeNull();
|
|
});
|
|
|
|
it("shows three thumbnails and an overflow tile for five images", () => {
|
|
const links = Array.from(
|
|
{ length: 5 },
|
|
(_, index) => `[shot ${index + 1}](/api/attachments/img${index + 1}/content)`,
|
|
).join("\n");
|
|
const attachments = Array.from({ length: 5 }, (_, index) =>
|
|
attachment({
|
|
id: `img${index + 1}`,
|
|
originalFilename: `shot ${index + 1}`,
|
|
contentType: "image/png",
|
|
}),
|
|
);
|
|
renderMessage(links, "agent", attachments);
|
|
|
|
const media = container.querySelector('[data-testid="task-chat-bubble-media"]');
|
|
expect(media?.querySelectorAll("img")).toHaveLength(3);
|
|
expect(media?.textContent).toContain("+2");
|
|
|
|
const secondThumbnail = media?.querySelectorAll("button")[1];
|
|
flushSync(() => secondThumbnail?.click());
|
|
expect(document.body.textContent).toContain("2 / 5");
|
|
});
|
|
|
|
it("shows a typed file chip with its stored size", () => {
|
|
renderMessage(
|
|
"[verification.log](/api/attachments/log/content)",
|
|
"agent",
|
|
[
|
|
attachment({
|
|
id: "log",
|
|
originalFilename: "verification.log",
|
|
contentType: "text/plain",
|
|
byteSize: 14 * 1024,
|
|
}),
|
|
],
|
|
);
|
|
|
|
const group = container.querySelector('[data-testid="task-chat-bubble-attachments"]');
|
|
expect(group?.textContent).toContain("Log · 14.0 KB");
|
|
});
|
|
|
|
it("renders only the provider attachments bound to this comment without Markdown refs", () => {
|
|
renderMessage("Please inspect both files.", "human", [
|
|
attachment({
|
|
id: "photo",
|
|
originalFilename: "evidence.png",
|
|
contentType: "image/png",
|
|
byteSize: 4096,
|
|
}),
|
|
attachment({
|
|
id: "notes",
|
|
originalFilename: "notes.txt",
|
|
contentType: "text/plain",
|
|
byteSize: 128,
|
|
}),
|
|
attachment({
|
|
id: "other-comment",
|
|
issueCommentId: "m2",
|
|
originalFilename: "unrelated.txt",
|
|
contentType: "text/plain",
|
|
}),
|
|
]);
|
|
|
|
expect(
|
|
container.querySelector('[data-testid="task-chat-bubble-media"] img')
|
|
?.getAttribute("alt"),
|
|
).toBe("evidence.png");
|
|
expect(container.textContent).toContain("Images · 1");
|
|
const group = container.querySelector(
|
|
'[data-testid="task-chat-bubble-attachments"]',
|
|
);
|
|
expect(group?.textContent).toContain("notes.txt");
|
|
expect(group?.textContent).not.toContain("unrelated.txt");
|
|
});
|
|
|
|
it("does not duplicate a bound attachment already referenced in the comment", () => {
|
|
renderMessage(
|
|
"[notes.txt](/api/attachments/notes/content)",
|
|
"human",
|
|
[
|
|
attachment({
|
|
id: "notes",
|
|
originalFilename: "notes.txt",
|
|
contentType: "text/plain",
|
|
}),
|
|
],
|
|
);
|
|
|
|
const group = container.querySelector(
|
|
'[data-testid="task-chat-bubble-attachments"]',
|
|
);
|
|
expect(group?.querySelectorAll("a")).toHaveLength(1);
|
|
expect(container.textContent).toContain("Files · 1");
|
|
});
|
|
});
|
|
|
|
function attachment(overrides: Partial<IssueAttachment>): IssueAttachment {
|
|
const id = overrides.id ?? "attachment";
|
|
return {
|
|
id,
|
|
companyId: "company",
|
|
issueId: "issue",
|
|
issueCommentId: "m1",
|
|
assetId: `asset-${id}`,
|
|
provider: "paperclip",
|
|
objectKey: id,
|
|
contentType: "application/octet-stream",
|
|
byteSize: 1,
|
|
sha256: id,
|
|
originalFilename: id,
|
|
createdByAgentId: "agent",
|
|
createdByUserId: null,
|
|
createdAt: new Date("2026-09-02T00:00:00Z"),
|
|
updatedAt: new Date("2026-09-02T00:00:00Z"),
|
|
contentPath: `/api/attachments/${id}/content`,
|
|
openPath: `/api/attachments/${id}/content`,
|
|
downloadPath: `/api/attachments/${id}/content?download=1`,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("TaskChatBubble accent-bubble text color", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root?.unmount());
|
|
root = null;
|
|
container.remove();
|
|
});
|
|
|
|
function render(item: TaskChatMessageItem) {
|
|
flushSync(() =>
|
|
root!.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={item} />
|
|
</ThemeProvider>,
|
|
),
|
|
);
|
|
}
|
|
|
|
it("marks the human bubble's markdown as on-accent so prose text follows text-white", () => {
|
|
render({ id: "m1", kind: "message", author: "human", text: "when a new task is created…" });
|
|
const body = container.querySelector(".paperclip-markdown");
|
|
expect(body).not.toBeNull();
|
|
// Without this class the light-mode prose body color reads as black on blue.
|
|
expect(body?.className).toContain("paperclip-markdown-on-accent");
|
|
});
|
|
|
|
it("leaves the neutral agent bubble on default prose colors", () => {
|
|
render({ id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Final reply." });
|
|
const body = container.querySelector(".paperclip-markdown");
|
|
expect(body).not.toBeNull();
|
|
expect(body?.className).not.toContain("paperclip-markdown-on-accent");
|
|
});
|
|
});
|
|
|
|
describe("TaskChatBubble agent page-surface treatment", () => {
|
|
it("does not show an on-behalf-of badge in the new task view", () => {
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
flushSync(() =>
|
|
root.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble
|
|
item={{ id: "attributed", kind: "message", author: "agent", authorName: "Fable", onBehalfOfUserName: "Dotta", text: "Done." }}
|
|
/>
|
|
</ThemeProvider>,
|
|
),
|
|
);
|
|
|
|
expect(container.textContent).toContain("Fable");
|
|
expect(container.textContent).not.toContain("for Dotta");
|
|
expect(container.querySelector('[data-testid="comment-attribution-chip"]')).toBeNull();
|
|
|
|
flushSync(() => root.unmount());
|
|
container.remove();
|
|
});
|
|
|
|
it("renders agent prose without a card background or constrained width", () => {
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
flushSync(() =>
|
|
root.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble
|
|
item={{ id: "agent-message", kind: "message", author: "agent", authorName: "Codex", text: "A long-form agent response" }}
|
|
/>
|
|
</ThemeProvider>,
|
|
),
|
|
);
|
|
|
|
const bubble = container.querySelector('[data-testid="task-chat-agent-bubble"]');
|
|
expect(bubble).not.toBeNull();
|
|
expect(bubble?.className).toContain("w-full");
|
|
expect(bubble?.className).toContain("bg-transparent");
|
|
expect(bubble?.className).toContain("px-1");
|
|
expect(bubble?.className).not.toContain("rounded-2xl");
|
|
expect(bubble?.className).not.toContain("bg-(--bubble-agent)");
|
|
expect(bubble?.className).not.toContain("max-w-(--pct-85)");
|
|
|
|
flushSync(() => root.unmount());
|
|
container.remove();
|
|
});
|
|
});
|
|
|
|
describe("TaskChatBubble verification caveats", () => {
|
|
it("renders non-blocking not-run verification beneath the durable agent reply", () => {
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
|
|
flushSync(() => root.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={{
|
|
id: "agent-message",
|
|
kind: "message",
|
|
author: "agent",
|
|
authorName: "Runner",
|
|
text: "The implementation is complete.",
|
|
verificationCaveats: [{
|
|
commandOrCheck: "Run npm test",
|
|
reasonCode: "tool_unavailable",
|
|
detail: "Node and npm are unavailable in this environment.",
|
|
}],
|
|
}} />
|
|
</ThemeProvider>,
|
|
));
|
|
|
|
const caveat = container.querySelector('[data-testid="task-chat-verification-caveats"]');
|
|
expect(caveat?.textContent).toContain("Verification caveat");
|
|
expect(caveat?.textContent).toContain("Run npm test");
|
|
expect(caveat?.textContent).toContain("tool unavailable");
|
|
expect(caveat?.textContent).toContain("Node and npm are unavailable");
|
|
|
|
flushSync(() => root.unmount());
|
|
container.remove();
|
|
});
|
|
});
|
|
|
|
describe("TaskChatBubble interstitial self-talk (PAP-357)", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root?.unmount());
|
|
root = null;
|
|
container.remove();
|
|
});
|
|
|
|
function renderItem(item: TaskChatMessageItem) {
|
|
flushSync(() =>
|
|
root!.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={item} />
|
|
</ThemeProvider>,
|
|
),
|
|
);
|
|
}
|
|
|
|
it("renders nothing — self-talk is ambient signal, suppressed in this view", () => {
|
|
renderItem({
|
|
id: "m1",
|
|
kind: "message",
|
|
author: "agent",
|
|
authorName: "CEO",
|
|
text: "Let me check the **adapter** first.",
|
|
interstitial: true,
|
|
streaming: true,
|
|
});
|
|
expect(container.textContent).toBe("");
|
|
expect(container.querySelector('[data-testid="task-chat-agent-avatar"]')).toBeNull();
|
|
});
|
|
|
|
it("keeps the bubble and avatar header for non-interstitial agent replies", () => {
|
|
renderItem({
|
|
id: "m1",
|
|
kind: "message",
|
|
author: "agent",
|
|
authorName: "CEO",
|
|
text: "Final reply.",
|
|
});
|
|
expect(container.querySelector('[data-testid="task-chat-interstitial"]')).toBeNull();
|
|
expect(container.querySelector('[data-testid="task-chat-agent-avatar"]')).not.toBeNull();
|
|
expect(container.textContent).toContain("CEO");
|
|
});
|
|
});
|
|
|
|
describe("TaskChatBubble footer line (round 9)", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root?.unmount());
|
|
root = null;
|
|
container.remove();
|
|
});
|
|
|
|
function renderBubble(item: TaskChatMessageItem, attachedTurn?: ReactNode) {
|
|
flushSync(() =>
|
|
root!.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={item} attachedTurn={attachedTurn} />
|
|
</ThemeProvider>,
|
|
),
|
|
);
|
|
}
|
|
|
|
it("shows the timestamp always — not only on hover", () => {
|
|
renderBubble({ id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Done.", timestamp: "2:34 PM" });
|
|
const stamp = [...container.querySelectorAll("span")].find((el) => el.textContent === "2:34 PM");
|
|
expect(stamp).not.toBeNull();
|
|
expect(stamp?.className).not.toContain("opacity-0");
|
|
expect(stamp?.className).not.toContain("group-hover");
|
|
});
|
|
|
|
it("renders the attached turn in place of the plain timestamp footer", () => {
|
|
renderBubble(
|
|
{ id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Done.", timestamp: "2:34 PM" },
|
|
<div data-testid="fake-attached-turn">2:34 PM · Worked</div>,
|
|
);
|
|
const slot = container.querySelector('[data-testid="task-chat-bubble-attached-turn"]');
|
|
expect(slot).not.toBeNull();
|
|
expect(slot?.querySelector('[data-testid="fake-attached-turn"]')).not.toBeNull();
|
|
// The plain footer timestamp is replaced (the attached line carries it).
|
|
const plain = [...container.querySelectorAll("span")].filter((el) => el.textContent === "2:34 PM");
|
|
expect(plain).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("TaskChatBubble footer actions (PAP-413)", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = createRoot(container);
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root?.unmount());
|
|
root = null;
|
|
container.remove();
|
|
});
|
|
|
|
function render(item: TaskChatMessageItem, opts?: { attachedTurn?: ReactNode; actions?: ReactNode }) {
|
|
flushSync(() =>
|
|
root!.render(
|
|
<ThemeProvider>
|
|
<TaskChatBubble item={item} attachedTurn={opts?.attachedTurn} actions={opts?.actions} />
|
|
</ThemeProvider>,
|
|
),
|
|
);
|
|
}
|
|
|
|
const actions = <div data-testid="fake-actions">actions</div>;
|
|
|
|
it("hands actions to the attached turn (not this wrapper) so they ride the summary row", () => {
|
|
render(
|
|
{ id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Done.", timestamp: "2:34 PM" },
|
|
{ attachedTurn: <div data-testid="fake-attached-turn">2:34 PM · Worked</div>, actions },
|
|
);
|
|
const slot = container.querySelector('[data-testid="task-chat-bubble-attached-turn"]');
|
|
expect(slot).not.toBeNull();
|
|
// The turn owns the footer line; the bubble no longer places actions beside
|
|
// it (they ride the turn's `leading` slot, anchored to the summary line).
|
|
expect(slot?.querySelector('[data-testid="fake-attached-turn"]')).not.toBeNull();
|
|
expect(slot?.querySelector('[data-testid="fake-actions"]')).toBeNull();
|
|
});
|
|
|
|
it("keeps runless timestamp left and actions at the stable right edge", () => {
|
|
render(
|
|
{ id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Done.", timestamp: "2:34 PM" },
|
|
{ actions },
|
|
);
|
|
expect(container.querySelector('[data-testid="fake-actions"]')).not.toBeNull();
|
|
const stamp = [...container.querySelectorAll("span")].find((el) => el.textContent === "2:34 PM");
|
|
expect(stamp).not.toBeNull();
|
|
const actionNode = container.querySelector('[data-testid="fake-actions"]')!;
|
|
expect(stamp!.compareDocumentPosition(actionNode) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
|
|
expect(actionNode.parentElement?.className).toContain("justify-between");
|
|
});
|
|
|
|
it("omits the actions row entirely when none are supplied (human bubble)", () => {
|
|
render({ id: "m1", kind: "message", author: "human", text: "Hi", timestamp: "2:34 PM" });
|
|
expect(container.querySelector('[data-testid="fake-actions"]')).toBeNull();
|
|
const stamp = [...container.querySelectorAll("span")].find((el) => el.textContent === "2:34 PM");
|
|
expect(stamp).not.toBeNull();
|
|
});
|
|
});
|