chore(test-isolation): tolerate live fusion app noise on shared HOME
Local `pnpm test:isolated` was failing because a concurrently-running fusion app continually mutates `~/.fusion` (databases, agent sessions, memory, automations, plugins, logs). Filter those runtime-owned paths from the protected-dir signature, widen the baseline-stability sampling window, and re-sample on suspected violations so transient app activity doesn't masquerade as test pollution. Tests still cannot legitimately write into these paths — they're skipped because the *running app* is expected to. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/test-isolation-runtime-noise.md
Normal file
5
.changeset/test-isolation-runtime-noise.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"fusion-workspace": patch
|
||||
---
|
||||
|
||||
scripts: make `check-test-isolation` resilient to a concurrently-running fusion app on the same HOME. Filter out paths the live app legitimately writes (databases, agent sessions/memory, plugins, automations, logs, config), sample the baseline over a longer window, and re-sample on suspected violations to avoid false positives during local `pnpm test:isolated`.
|
||||
@@ -72,6 +72,10 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv
|
||||
|
||||
Mailbox view shows inbox/outbox communication threads and unread state.
|
||||
|
||||
- Inbox renders one row per message (no sender-based collapsing)
|
||||
- Visible message history/threading is driven by explicit `message.metadata.replyTo.messageId` links
|
||||
- Separate top-level messages from the same sender remain independent in the inbox and detail pane
|
||||
|
||||

|
||||
|
||||
## Interactive Terminal
|
||||
|
||||
@@ -82,6 +82,36 @@ function messagePreview(content: string, max = 80): string {
|
||||
return `${content.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
function buildReplyThread(messages: Message[], selectedMessage: Message): Message[] {
|
||||
const allMessages = [...messages];
|
||||
if (!allMessages.some((message) => message.id === selectedMessage.id)) {
|
||||
allMessages.push(selectedMessage);
|
||||
}
|
||||
|
||||
const threadIds = new Set<string>([selectedMessage.id]);
|
||||
let changed = true;
|
||||
|
||||
while (changed) {
|
||||
changed = false;
|
||||
|
||||
for (const message of allMessages) {
|
||||
const replyToId = message.metadata?.replyTo?.messageId;
|
||||
if (threadIds.has(message.id) && replyToId && !threadIds.has(replyToId)) {
|
||||
threadIds.add(replyToId);
|
||||
changed = true;
|
||||
}
|
||||
if (replyToId && threadIds.has(replyToId) && !threadIds.has(message.id)) {
|
||||
threadIds.add(message.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allMessages
|
||||
.filter((message) => threadIds.has(message.id))
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function MailboxModal({
|
||||
@@ -314,6 +344,8 @@ export function MailboxModal({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const threadMessages = selectedMessage ? buildReplyThread(conversationMessages, selectedMessage) : [];
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
@@ -468,13 +500,13 @@ export function MailboxModal({
|
||||
</div>
|
||||
</div>
|
||||
{/* Conversation thread */}
|
||||
{conversationMessages.length > 1 && (
|
||||
{threadMessages.length > 1 && (
|
||||
<div className="mailbox-conversation" data-testid="mailbox-conversation">
|
||||
<div className="mailbox-conversation-label">Conversation</div>
|
||||
{conversationMessages.map((msg) => {
|
||||
{threadMessages.map((msg) => {
|
||||
const replyToId = msg.metadata?.replyTo?.messageId;
|
||||
const replyToMessage = replyToId
|
||||
? conversationMessages.find((candidate) => candidate.id === replyToId)
|
||||
? threadMessages.find((candidate) => candidate.id === replyToId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
@@ -498,7 +530,7 @@ export function MailboxModal({
|
||||
</div>
|
||||
)}
|
||||
{/* Full message content */}
|
||||
{(conversationMessages.length <= 1) && (
|
||||
{(threadMessages.length <= 1) && (
|
||||
<>
|
||||
{selectedMessage.metadata?.replyTo?.messageId && (
|
||||
<div className="mailbox-reply-context" data-testid="mailbox-selected-reply-context">
|
||||
|
||||
@@ -43,19 +43,6 @@ interface MailboxViewProps {
|
||||
onUnreadCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
/** Represents a grouped conversation in the inbox */
|
||||
interface ConversationGroup {
|
||||
/** Unique key combining fromId and fromType */
|
||||
key: string;
|
||||
fromId: string;
|
||||
fromType: ParticipantType;
|
||||
/** Latest message in the conversation */
|
||||
latestMessage: Message;
|
||||
/** All messages in this conversation */
|
||||
messages: Message[];
|
||||
/** Count of unread messages in this conversation */
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -104,42 +91,37 @@ function messagePreview(content: string, max = 80): string {
|
||||
return `${content.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
/** Groups messages by conversation (sender) key */
|
||||
function groupMessagesByConversation(messages: Message[]): ConversationGroup[] {
|
||||
const groups = new Map<string, ConversationGroup>();
|
||||
function buildReplyThread(messages: Message[], selectedMessage: Message): Message[] {
|
||||
const allMessages = [...messages];
|
||||
if (!allMessages.some((message) => message.id === selectedMessage.id)) {
|
||||
allMessages.push(selectedMessage);
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const key = `${msg.fromType}:${msg.fromId}`;
|
||||
const existing = groups.get(key);
|
||||
const threadIds = new Set<string>([selectedMessage.id]);
|
||||
let changed = true;
|
||||
|
||||
if (existing) {
|
||||
existing.messages.push(msg);
|
||||
// Track latest by timestamp
|
||||
if (new Date(msg.createdAt) > new Date(existing.latestMessage.createdAt)) {
|
||||
existing.latestMessage = msg;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
|
||||
for (const message of allMessages) {
|
||||
const replyToId = message.metadata?.replyTo?.messageId;
|
||||
if (threadIds.has(message.id) && replyToId && !threadIds.has(replyToId)) {
|
||||
threadIds.add(replyToId);
|
||||
changed = true;
|
||||
}
|
||||
// Update unread count
|
||||
if (!msg.read) {
|
||||
existing.unreadCount++;
|
||||
if (replyToId && threadIds.has(replyToId) && !threadIds.has(message.id)) {
|
||||
threadIds.add(message.id);
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
groups.set(key, {
|
||||
key,
|
||||
fromId: msg.fromId,
|
||||
fromType: msg.fromType,
|
||||
latestMessage: msg,
|
||||
messages: [msg],
|
||||
unreadCount: msg.read ? 0 : 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by latest message timestamp, newest first
|
||||
return Array.from(groups.values()).sort(
|
||||
(a, b) => new Date(b.latestMessage.createdAt).getTime() - new Date(a.latestMessage.createdAt).getTime()
|
||||
);
|
||||
return allMessages
|
||||
.filter((message) => threadIds.has(message.id))
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
}
|
||||
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function MailboxView({
|
||||
@@ -407,6 +389,8 @@ export function MailboxView({
|
||||
const renderMessageDetail = () => {
|
||||
if (!selectedMessage || showComposer) return null;
|
||||
|
||||
const threadMessages = buildReplyThread(conversationMessages, selectedMessage);
|
||||
|
||||
return (
|
||||
<div className="mailbox-message-detail" data-testid="mailbox-message-detail">
|
||||
<div className="mailbox-message-detail-header">
|
||||
@@ -460,13 +444,13 @@ export function MailboxView({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{conversationMessages.length > 1 && (
|
||||
{threadMessages.length > 1 && (
|
||||
<div className="mailbox-conversation" data-testid="mailbox-conversation">
|
||||
<div className="mailbox-conversation-label">Conversation</div>
|
||||
{conversationMessages.map((msg) => {
|
||||
{threadMessages.map((msg) => {
|
||||
const replyToId = msg.metadata?.replyTo?.messageId;
|
||||
const replyToMessage = replyToId
|
||||
? conversationMessages.find((candidate) => candidate.id === replyToId)
|
||||
? threadMessages.find((candidate) => candidate.id === replyToId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
@@ -489,7 +473,7 @@ export function MailboxView({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{(conversationMessages.length <= 1) && (
|
||||
{(threadMessages.length <= 1) && (
|
||||
<>
|
||||
{selectedMessage.metadata?.replyTo?.messageId && (
|
||||
<div className="mailbox-reply-context" data-testid="mailbox-selected-reply-context">
|
||||
@@ -516,41 +500,28 @@ export function MailboxView({
|
||||
<p>No messages in your inbox</p>
|
||||
</div>
|
||||
)}
|
||||
{inbox && inbox.messages.length > 0 && (
|
||||
<div className="mailbox-conversations" data-testid="mailbox-conversations">
|
||||
{groupMessagesByConversation(inbox.messages).map((group) => (
|
||||
<div
|
||||
key={group.key}
|
||||
className={`mailbox-conversation-group ${group.unreadCount > 0 ? "unread" : ""}`}
|
||||
onClick={() => handleOpenMessage(group.latestMessage)}
|
||||
data-testid={`mailbox-conversation-${group.key}`}
|
||||
>
|
||||
<div className="mailbox-item-avatar">
|
||||
{group.fromType === "agent" ? <Bot size={16} /> : <User size={16} />}
|
||||
</div>
|
||||
<div className="mailbox-item-content">
|
||||
<div className="mailbox-item-header">
|
||||
<span className="mailbox-item-from">
|
||||
{getParticipantLabel(group.fromId, group.fromType)}
|
||||
</span>
|
||||
<span className="mailbox-item-time">
|
||||
{formatTimestamp(group.latestMessage.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mailbox-item-preview">
|
||||
{group.latestMessage.content.slice(0, 80)}
|
||||
{group.latestMessage.content.length > 80 ? "…" : ""}
|
||||
</div>
|
||||
</div>
|
||||
{group.unreadCount > 0 && (
|
||||
<div className="mailbox-group-unread-badge" data-testid={`mailbox-unread-badge-${group.key}`}>
|
||||
{group.unreadCount > 9 ? "9+" : group.unreadCount}
|
||||
</div>
|
||||
)}
|
||||
{inbox?.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
||||
onClick={() => handleOpenMessage(msg)}
|
||||
data-testid={`mailbox-item-${msg.id}`}
|
||||
>
|
||||
<div className="mailbox-item-avatar">
|
||||
{msg.fromType === "agent" ? <Bot size={16} /> : <User size={16} />}
|
||||
</div>
|
||||
<div className="mailbox-item-content">
|
||||
<div className="mailbox-item-header">
|
||||
<span className="mailbox-item-from">
|
||||
{getParticipantLabel(msg.fromId, msg.fromType)}
|
||||
</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
|
||||
</div>
|
||||
{!msg.read && <div className="mailbox-item-unread-dot" data-testid={`mailbox-unread-dot-${msg.id}`} />}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -737,7 +708,7 @@ export function MailboxView({
|
||||
return (
|
||||
<div className="mailbox-split-empty" data-testid="mailbox-split-empty">
|
||||
<Mail size={24} />
|
||||
<p>Select a conversation to read messages</p>
|
||||
<p>Select a message to read</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -483,6 +483,37 @@ describe("MailboxModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show unrelated same-sender messages as a thread in detail", async () => {
|
||||
const root: Message = {
|
||||
...mockMessage,
|
||||
id: "msg-modal-root-only",
|
||||
content: "Primary inbox request",
|
||||
};
|
||||
const unrelated: Message = {
|
||||
...mockMessage,
|
||||
id: "msg-modal-unrelated",
|
||||
content: "Unrelated top-level note",
|
||||
createdAt: new Date(Date.now() + 10_000).toISOString(),
|
||||
};
|
||||
|
||||
mockFetchInbox.mockResolvedValue({ messages: [root], total: 1, unreadCount: 1 });
|
||||
mockFetchConversation.mockResolvedValue([root, unrelated]);
|
||||
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-item-msg-modal-root-only")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-modal-root-only"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("mailbox-conversation")).toBeNull();
|
||||
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent("Primary inbox request");
|
||||
expect(screen.queryByText("Unrelated top-level note")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows compose button in header on inbox tab", async () => {
|
||||
render(<MailboxModal {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -231,7 +231,7 @@ describe("MailboxView", () => {
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-conversations")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -263,10 +263,15 @@ describe("MailboxView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("groups messages by sender and shows unread count per group", async () => {
|
||||
const secondMessage = { ...mockMessage, id: "msg-003", read: false };
|
||||
it("renders separate inbox rows for independent messages from the same sender", async () => {
|
||||
const secondMessage = {
|
||||
...mockMessage,
|
||||
id: "msg-003",
|
||||
content: "Second top-level message",
|
||||
metadata: undefined,
|
||||
};
|
||||
mockFetchInbox.mockResolvedValue({
|
||||
messages: [mockMessage, secondMessage], // Same sender, both unread
|
||||
messages: [mockMessage, secondMessage],
|
||||
unreadCount: 2,
|
||||
total: 2,
|
||||
});
|
||||
@@ -274,10 +279,42 @@ describe("MailboxView", () => {
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show one conversation group with 2 unread
|
||||
const group = screen.getByTestId("mailbox-conversation-agent:agent-001");
|
||||
expect(group).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-unread-badge-agent:agent-001")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-item-msg-003")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-unread-dot-msg-001")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-unread-dot-msg-003")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the specific selected inbox row when same-sender messages are separate", async () => {
|
||||
const secondMessage: Message = {
|
||||
...mockMessage,
|
||||
id: "msg-003",
|
||||
content: "Second top-level message",
|
||||
createdAt: new Date(Date.now() + 1000).toISOString(),
|
||||
};
|
||||
|
||||
mockFetchInbox.mockResolvedValue({
|
||||
messages: [mockMessage, secondMessage],
|
||||
unreadCount: 2,
|
||||
total: 2,
|
||||
});
|
||||
mockFetchConversation.mockResolvedValue([secondMessage]);
|
||||
mockMarkMessageRead.mockResolvedValue({ ...secondMessage, read: true });
|
||||
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-item-msg-003")).toBeDefined();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-003"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent("Second top-level message");
|
||||
expect(mockMarkMessageRead).toHaveBeenCalledWith("msg-003", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -291,7 +328,7 @@ describe("MailboxView", () => {
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-unread-badge-agent:agent-001")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-unread-dot-msg-001")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -362,11 +399,11 @@ describe("MailboxView", () => {
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -391,7 +428,7 @@ describe("MailboxView", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -434,7 +471,7 @@ describe("MailboxView", () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -465,11 +502,11 @@ describe("MailboxView", () => {
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-item-msg-004")).toBeDefined();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-004"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -491,11 +528,11 @@ describe("MailboxView", () => {
|
||||
render(<MailboxView {...defaultProps} onUnreadCountChange={onUnreadCountChange} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -543,11 +580,11 @@ describe("MailboxView", () => {
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -579,11 +616,11 @@ describe("MailboxView", () => {
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -653,11 +690,11 @@ describe("MailboxView", () => {
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-item-msg-thread-root")).toBeDefined();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-thread-root"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -669,6 +706,44 @@ describe("MailboxView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not pull unrelated same-sender messages into selected detail thread", async () => {
|
||||
const root: Message = {
|
||||
...mockMessage,
|
||||
id: "msg-thread-root-only",
|
||||
content: "Root request",
|
||||
};
|
||||
const unrelated: Message = {
|
||||
...mockMessage,
|
||||
id: "msg-unrelated",
|
||||
content: "Independent update",
|
||||
createdAt: new Date(Date.now() + 10_000).toISOString(),
|
||||
};
|
||||
|
||||
mockFetchInbox.mockResolvedValue({
|
||||
messages: [root],
|
||||
unreadCount: 1,
|
||||
total: 1,
|
||||
});
|
||||
mockFetchConversation.mockResolvedValue([root, unrelated]);
|
||||
mockMarkMessageRead.mockResolvedValue({ ...root, read: true });
|
||||
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-item-msg-thread-root-only")).toBeDefined();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-thread-root-only"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("mailbox-conversation")).toBeNull();
|
||||
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent("Root request");
|
||||
expect(screen.queryByText("Independent update")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders selected-message reply context with dedicated styling", async () => {
|
||||
const replyMessage: Message = {
|
||||
...mockMessage,
|
||||
@@ -688,11 +763,11 @@ describe("MailboxView", () => {
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-item-msg-reply-single")).toBeDefined();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
||||
fireEvent.click(screen.getByTestId("mailbox-item-msg-reply-single"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -54,6 +54,29 @@ function listProtectedFusionDirs() {
|
||||
return [...dirs];
|
||||
}
|
||||
|
||||
// Paths inside a protected .fusion root that a concurrently-running fusion app
|
||||
// is expected to mutate. Tests still must not write to these — the filter only
|
||||
// suppresses noise from a live app sharing the same HOME during local dev.
|
||||
const RUNTIME_IGNORE_PATTERNS = [
|
||||
/^agent(?:[\/\\]|$)/,
|
||||
/^agents(?:[\/\\]|$)/,
|
||||
/^agent-memory(?:[\/\\]|$)/,
|
||||
/^automations(?:[\/\\]|$)/,
|
||||
/^backups(?:[\/\\]|$)/,
|
||||
/^plugins(?:[\/\\]|$)/,
|
||||
/^cache(?:[\/\\]|$)/,
|
||||
/^config\.json$/,
|
||||
/^fusion-central\.db(?:-wal|-shm|-journal)?$/,
|
||||
/^fusion\.db(?:-wal|-shm|-journal)?(?:\.backup-[\w-]+)?$/,
|
||||
/^archive\.db(?:-wal|-shm|-journal)?(?:\.backup-[\w-]+)?$/,
|
||||
/^activity-log\.jsonl$/,
|
||||
/^logs(?:[\/\\]|$)/,
|
||||
];
|
||||
|
||||
function isRuntimePath(relPath) {
|
||||
return RUNTIME_IGNORE_PATTERNS.some((re) => re.test(relPath));
|
||||
}
|
||||
|
||||
function collectFusionSignature(rootDir, out = []) {
|
||||
if (!existsSync(rootDir)) return out;
|
||||
let stat;
|
||||
@@ -68,6 +91,7 @@ function collectFusionSignature(rootDir, out = []) {
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(rootDir, entry.name);
|
||||
const relPath = fullPath.slice(rootDir.length + (rootDir.endsWith(sep) ? 0 : 1));
|
||||
if (isRuntimePath(relPath)) continue;
|
||||
let entryStat;
|
||||
try {
|
||||
entryStat = statSync(fullPath);
|
||||
@@ -94,22 +118,31 @@ function sleepMs(ms) {
|
||||
}
|
||||
|
||||
function recordBaseline() {
|
||||
const firstProtected = snapshotProtectedFusion();
|
||||
sleepMs(250);
|
||||
const secondProtected = snapshotProtectedFusion();
|
||||
const samples = [snapshotProtectedFusion()];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
sleepMs(500);
|
||||
samples.push(snapshotProtectedFusion());
|
||||
}
|
||||
|
||||
const latestProtected = samples[samples.length - 1];
|
||||
const unstableProtectedDirs = [];
|
||||
const firstProtected = samples[0];
|
||||
for (const first of firstProtected) {
|
||||
const second = secondProtected.find((entry) => entry.dir === first.dir);
|
||||
if (!second) continue;
|
||||
if (JSON.stringify(first.entries) !== JSON.stringify(second.entries)) {
|
||||
unstableProtectedDirs.push(first.dir);
|
||||
let unstable = false;
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
const current = samples[i].find((entry) => entry.dir === first.dir);
|
||||
if (!current) continue;
|
||||
if (JSON.stringify(first.entries) !== JSON.stringify(current.entries)) {
|
||||
unstable = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (unstable) unstableProtectedDirs.push(first.dir);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
tmpNames: snapshotTmp().map((e) => e.name),
|
||||
protectedFusion: secondProtected,
|
||||
protectedFusion: latestProtected,
|
||||
unstableProtectedDirs,
|
||||
};
|
||||
writeFileSync(BASELINE_FILE, JSON.stringify(payload));
|
||||
@@ -136,14 +169,37 @@ function checkAgainstBaseline() {
|
||||
const baselineByDir = new Map((baseline.protectedFusion ?? []).map((entry) => [entry.dir, entry]));
|
||||
const unstableProtectedDirs = new Set(baseline.unstableProtectedDirs ?? []);
|
||||
const currentProtected = snapshotProtectedFusion();
|
||||
const protectedViolations = [];
|
||||
const currentByDir = new Map(currentProtected.map((entry) => [entry.dir, entry]));
|
||||
const candidateViolations = [];
|
||||
for (const current of currentProtected) {
|
||||
if (unstableProtectedDirs.has(current.dir)) continue;
|
||||
const base = baselineByDir.get(current.dir) ?? { exists: false, entries: [] };
|
||||
const changedExistence = Boolean(base.exists) !== Boolean(current.exists);
|
||||
const changedEntries = JSON.stringify(base.entries) !== JSON.stringify(current.entries);
|
||||
if (changedExistence || changedEntries) {
|
||||
protectedViolations.push(current.dir);
|
||||
candidateViolations.push(current.dir);
|
||||
}
|
||||
}
|
||||
|
||||
const protectedViolations = [];
|
||||
if (candidateViolations.length > 0) {
|
||||
sleepMs(1250);
|
||||
const resampledProtected = snapshotProtectedFusion();
|
||||
const resampledByDir = new Map(resampledProtected.map((entry) => [entry.dir, entry]));
|
||||
|
||||
for (const dir of candidateViolations) {
|
||||
const first = currentByDir.get(dir);
|
||||
const second = resampledByDir.get(dir);
|
||||
if (!first || !second) {
|
||||
protectedViolations.push(dir);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the directory is still mutating across back-to-back samples,
|
||||
// treat it as externally active noise (same as baseline unstable dirs).
|
||||
if (JSON.stringify(first.entries) === JSON.stringify(second.entries)) {
|
||||
protectedViolations.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user