@agent-native/toolkit 0.217.0 → 0.219.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/agent-native.eject.json +21 -0
- package/dist/app/agent-page/AgentContextTab.d.ts.map +1 -1
- package/dist/app/agent-page/AgentContextTab.js +1 -0
- package/dist/app/agent-page/AgentContextTab.js.map +1 -1
- package/dist/app/agentkit/react/components.d.ts.map +1 -1
- package/dist/app/agentkit/react/components.js +1 -1
- package/dist/app/agentkit/react/components.js.map +1 -1
- package/dist/app/agentkit/react/context.d.ts +4 -1
- package/dist/app/agentkit/react/context.d.ts.map +1 -1
- package/dist/app/agentkit/react/context.js +3 -1
- package/dist/app/agentkit/react/context.js.map +1 -1
- package/dist/app/agentkit/react/data-chart-layout.d.ts +30 -0
- package/dist/app/agentkit/react/data-chart-layout.d.ts.map +1 -0
- package/dist/app/agentkit/react/data-chart-layout.js +2 -0
- package/dist/app/agentkit/react/data-chart-layout.js.map +1 -0
- package/dist/app/agentkit/react/index.d.ts +1 -0
- package/dist/app/agentkit/react/index.d.ts.map +1 -1
- package/dist/app/agentkit/react/index.js +1 -0
- package/dist/app/agentkit/react/index.js.map +1 -1
- package/dist/app/agentkit/react/root.d.ts +3 -1
- package/dist/app/agentkit/react/root.d.ts.map +1 -1
- package/dist/app/agentkit/react/root.js +2 -2
- package/dist/app/agentkit/react/root.js.map +1 -1
- package/dist/app/agentkit/react/styles.css +31 -6
- package/dist/app/agentkit/react/transcript-layout.d.ts +2 -0
- package/dist/app/agentkit/react/transcript-layout.d.ts.map +1 -1
- package/dist/app/agentkit/react/transcript-layout.js.map +1 -1
- package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
- package/dist/app/chat/AgentKitAssistantChat.js +2 -2
- package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
- package/dist/app/chat/AgentPanel.js +6 -6
- package/dist/app/chat/AgentPanel.js.map +1 -1
- package/dist/app/chat/MultiTabAssistantChat.d.ts.map +1 -1
- package/dist/app/chat/MultiTabAssistantChat.js +53 -95
- package/dist/app/chat/MultiTabAssistantChat.js.map +1 -1
- package/dist/app/chat/agentkit-chat/action-widget.d.ts.map +1 -1
- package/dist/app/chat/agentkit-chat/action-widget.js +4 -1
- package/dist/app/chat/agentkit-chat/action-widget.js.map +1 -1
- package/dist/app/chat/agentkit-chat/rail.d.ts +1 -1
- package/dist/app/chat/agentkit-chat/rail.d.ts.map +1 -1
- package/dist/app/chat/agentkit-chat/rail.js +1 -1
- package/dist/app/chat/agentkit-chat/rail.js.map +1 -1
- package/dist/app/chat/chat/index.d.ts +2 -1
- package/dist/app/chat/chat/index.d.ts.map +1 -1
- package/dist/app/chat/chat/index.js +1 -1
- package/dist/app/chat/chat/index.js.map +1 -1
- package/dist/app/chat/chat/surface-types.d.ts +3 -0
- package/dist/app/chat/chat/surface-types.d.ts.map +1 -1
- package/dist/app/chat/chat/surface-types.js.map +1 -1
- package/dist/app/chat/chat/tool-call-display.d.ts +5 -1
- package/dist/app/chat/chat/tool-call-display.d.ts.map +1 -1
- package/dist/app/chat/chat/tool-call-display.js +6 -4
- package/dist/app/chat/chat/tool-call-display.js.map +1 -1
- package/dist/app/chat/chat/tool-render-registry.d.ts +4 -0
- package/dist/app/chat/chat/tool-render-registry.d.ts.map +1 -1
- package/dist/app/chat/chat/tool-render-registry.js.map +1 -1
- package/dist/app/chat/chat/widgets/DataChartRenderer.d.ts +3 -1
- package/dist/app/chat/chat/widgets/DataChartRenderer.d.ts.map +1 -1
- package/dist/app/chat/chat/widgets/DataChartRenderer.js +116 -26
- package/dist/app/chat/chat/widgets/DataChartRenderer.js.map +1 -1
- package/dist/app/chat/chat/widgets/DataChartWidget.d.ts +3 -1
- package/dist/app/chat/chat/widgets/DataChartWidget.d.ts.map +1 -1
- package/dist/app/chat/chat/widgets/DataChartWidget.js +21 -2
- package/dist/app/chat/chat/widgets/DataChartWidget.js.map +1 -1
- package/dist/app/chat/chat/widgets/DataInsightsWidget.d.ts +3 -1
- package/dist/app/chat/chat/widgets/DataInsightsWidget.d.ts.map +1 -1
- package/dist/app/chat/chat/widgets/DataInsightsWidget.js +2 -2
- package/dist/app/chat/chat/widgets/DataInsightsWidget.js.map +1 -1
- package/dist/app/chat/chat/widgets/DataWidgetDisplayControls.d.ts +1 -1
- package/dist/app/chat/chat/widgets/DataWidgetDisplayControls.d.ts.map +1 -1
- package/dist/app/chat/chat/widgets/DataWidgetDisplayControls.js +3 -1
- package/dist/app/chat/chat/widgets/DataWidgetDisplayControls.js.map +1 -1
- package/dist/app/chat/chat/widgets/builtin-tool-renderers.d.ts.map +1 -1
- package/dist/app/chat/chat/widgets/builtin-tool-renderers.js +2 -2
- package/dist/app/chat/chat/widgets/builtin-tool-renderers.js.map +1 -1
- package/dist/app/chat/chat-first/chat-history.d.ts +13 -2
- package/dist/app/chat/chat-first/chat-history.d.ts.map +1 -1
- package/dist/app/chat/chat-first/chat-history.js +24 -3
- package/dist/app/chat/chat-first/chat-history.js.map +1 -1
- package/dist/app/chat/conversation/AgentConversation.d.ts.map +1 -1
- package/dist/app/chat/conversation/AgentConversation.js +6 -1
- package/dist/app/chat/conversation/AgentConversation.js.map +1 -1
- package/dist/app/chat/ownership-history/ChatOwnerAvatar.d.ts +6 -0
- package/dist/app/chat/ownership-history/ChatOwnerAvatar.d.ts.map +1 -0
- package/dist/app/chat/ownership-history/ChatOwnerAvatar.js +9 -0
- package/dist/app/chat/ownership-history/ChatOwnerAvatar.js.map +1 -0
- package/dist/app/chat/ownership-history/index.d.ts +11 -0
- package/dist/app/chat/ownership-history/index.d.ts.map +1 -0
- package/dist/app/chat/ownership-history/index.js +22 -0
- package/dist/app/chat/ownership-history/index.js.map +1 -0
- package/dist/app/chat/ownership-history/rows.d.ts +27 -0
- package/dist/app/chat/ownership-history/rows.d.ts.map +1 -0
- package/dist/app/chat/ownership-history/rows.js +101 -0
- package/dist/app/chat/ownership-history/rows.js.map +1 -0
- package/dist/app/chat/ownership-history/useOwnershipChatHistory.d.ts +52 -0
- package/dist/app/chat/ownership-history/useOwnershipChatHistory.d.ts.map +1 -0
- package/dist/app/chat/ownership-history/useOwnershipChatHistory.js +497 -0
- package/dist/app/chat/ownership-history/useOwnershipChatHistory.js.map +1 -0
- package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ar-SA.js +3 -0
- package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
- package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/de-DE.js +3 -0
- package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
- package/dist/app/i18n/catalogs/en-US.d.ts +3 -0
- package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/en-US.js +3 -0
- package/dist/app/i18n/catalogs/en-US.js.map +1 -1
- package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/es-ES.js +3 -0
- package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
- package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/fr-FR.js +3 -0
- package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
- package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/hi-IN.js +3 -0
- package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
- package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ja-JP.js +3 -0
- package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
- package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ko-KR.js +3 -0
- package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
- package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/pt-BR.js +3 -0
- package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
- package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/zh-CN.js +3 -0
- package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
- package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/zh-TW.js +3 -0
- package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
- package/dist/app/shared/CommandMenu.d.ts +2 -0
- package/dist/app/shared/CommandMenu.d.ts.map +1 -1
- package/dist/app/shared/CommandMenu.js +11 -6
- package/dist/app/shared/CommandMenu.js.map +1 -1
- package/dist/app/sharing/ShareButton.d.ts.map +1 -1
- package/dist/app/sharing/ShareButton.js +9 -8
- package/dist/app/sharing/ShareButton.js.map +1 -1
- package/dist/chat-history/ChatHistoryList.d.ts +16 -3
- package/dist/chat-history/ChatHistoryList.d.ts.map +1 -1
- package/dist/chat-history/ChatHistoryList.js +25 -15
- package/dist/chat-history/ChatHistoryList.js.map +1 -1
- package/dist/chat-history.css +109 -9
- package/dist/composer/TiptapComposer.js +1 -1
- package/dist/composer/TiptapComposer.js.map +1 -1
- package/package.json +10 -3
- package/src/app/agent-page/AgentContextTab.spec.ts +50 -2
- package/src/app/agent-page/AgentContextTab.tsx +1 -0
- package/src/app/agentkit/react/components.interaction.spec.tsx +2 -0
- package/src/app/agentkit/react/components.spec.tsx +11 -6
- package/src/app/agentkit/react/components.tsx +3 -0
- package/src/app/agentkit/react/context.tsx +6 -0
- package/src/app/agentkit/react/data-chart-layout.ts +25 -0
- package/src/app/agentkit/react/index.ts +1 -0
- package/src/app/agentkit/react/root.tsx +4 -0
- package/src/app/agentkit/react/styles.css +31 -6
- package/src/app/agentkit/react/transcript-layout.ts +2 -0
- package/src/app/chat/AgentKitAssistantChat.spec.tsx +201 -27
- package/src/app/chat/AgentKitAssistantChat.tsx +3 -0
- package/src/app/chat/AgentPanel.tsx +8 -8
- package/src/app/chat/MultiTabAssistantChat.spec.tsx +270 -3
- package/src/app/chat/MultiTabAssistantChat.tsx +81 -158
- package/src/app/chat/agentkit-chat/action-widget.spec.tsx +62 -0
- package/src/app/chat/agentkit-chat/action-widget.tsx +4 -0
- package/src/app/chat/agentkit-chat/rail.ts +11 -0
- package/src/app/chat/chat/index.ts +10 -0
- package/src/app/chat/chat/surface-types.ts +3 -0
- package/src/app/chat/chat/tool-call-display.spec.tsx +29 -0
- package/src/app/chat/chat/tool-call-display.tsx +17 -1
- package/src/app/chat/chat/tool-render-registry.tsx +5 -0
- package/src/app/chat/chat/widgets/DataChartRenderer.spec.tsx +377 -17
- package/src/app/chat/chat/widgets/DataChartRenderer.tsx +232 -63
- package/src/app/chat/chat/widgets/DataChartWidget.spec.tsx +67 -0
- package/src/app/chat/chat/widgets/DataChartWidget.tsx +39 -5
- package/src/app/chat/chat/widgets/DataInsightsWidget.tsx +13 -2
- package/src/app/chat/chat/widgets/DataWidgetDisplayControls.tsx +6 -4
- package/src/app/chat/chat/widgets/builtin-tool-renderers.tsx +5 -1
- package/src/app/chat/chat-first/chat-history.spec.tsx +79 -0
- package/src/app/chat/chat-first/chat-history.tsx +75 -22
- package/src/app/chat/conversation/AgentConversation.spec.tsx +51 -1
- package/src/app/chat/conversation/AgentConversation.tsx +11 -2
- package/src/app/chat/ownership-history/ChatOwnerAvatar.tsx +27 -0
- package/src/app/chat/ownership-history/index.tsx +67 -0
- package/src/app/chat/ownership-history/ownership-history.spec.tsx +3146 -0
- package/src/app/chat/ownership-history/rows.ts +156 -0
- package/src/app/chat/ownership-history/useOwnershipChatHistory.ts +709 -0
- package/src/app/i18n/catalogs/ar-SA.ts +3 -0
- package/src/app/i18n/catalogs/de-DE.ts +3 -0
- package/src/app/i18n/catalogs/en-US.ts +3 -0
- package/src/app/i18n/catalogs/es-ES.ts +3 -0
- package/src/app/i18n/catalogs/fr-FR.ts +3 -0
- package/src/app/i18n/catalogs/hi-IN.ts +3 -0
- package/src/app/i18n/catalogs/ja-JP.ts +3 -0
- package/src/app/i18n/catalogs/ko-KR.ts +3 -0
- package/src/app/i18n/catalogs/pt-BR.ts +3 -0
- package/src/app/i18n/catalogs/zh-CN.ts +3 -0
- package/src/app/i18n/catalogs/zh-TW.ts +3 -0
- package/src/app/shared/CommandMenu.spec.tsx +175 -6
- package/src/app/shared/CommandMenu.tsx +24 -7
- package/src/app/sharing/ShareButton.tsx +12 -10
- package/src/chat-history/ChatHistoryList.spec.tsx +51 -0
- package/src/chat-history/ChatHistoryList.tsx +87 -26
- package/src/chat-history/ChatHistoryRailSections.spec.tsx +12 -4
- package/src/chat-history.css +109 -9
- package/src/composer/TiptapComposer.tsx +1 -1
|
@@ -0,0 +1,3146 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ChatThreadListRow,
|
|
5
|
+
ChatThreadSummary,
|
|
6
|
+
} from "@agent-native/core/client/agent-chat";
|
|
7
|
+
import { agentNativePath } from "@agent-native/core/client/api-path";
|
|
8
|
+
import { Activity, act } from "react";
|
|
9
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
vi.mock("@agent-native/core/client/use-avatar", () => ({
|
|
13
|
+
useAvatarUrl: () => null,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const clipboard = vi.hoisted(() => ({ write: vi.fn(async () => true) }));
|
|
17
|
+
vi.mock("@agent-native/toolkit/clipboard", () => ({
|
|
18
|
+
writeClipboardText: clipboard.write,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
OwnershipChatHistoryRail,
|
|
23
|
+
chatThreadToHistoryItem,
|
|
24
|
+
ownDraftRows,
|
|
25
|
+
useOwnershipChatHistory,
|
|
26
|
+
type UseOwnershipChatHistoryOptions,
|
|
27
|
+
type UseOwnershipChatHistoryResult,
|
|
28
|
+
} from "./index.js";
|
|
29
|
+
|
|
30
|
+
function row(
|
|
31
|
+
id: string,
|
|
32
|
+
overrides: Partial<ChatThreadListRow> = {},
|
|
33
|
+
): ChatThreadListRow {
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
title: id,
|
|
37
|
+
preview: "",
|
|
38
|
+
messageCount: 1,
|
|
39
|
+
createdAt: 1,
|
|
40
|
+
updatedAt: 1,
|
|
41
|
+
scope: null,
|
|
42
|
+
ownerEmail: "owner@example.com",
|
|
43
|
+
accessBasis: "owner",
|
|
44
|
+
visibility: "private",
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function deferred<T>() {
|
|
50
|
+
let resolve!: (value: T) => void;
|
|
51
|
+
let reject!: (cause: unknown) => void;
|
|
52
|
+
const promise = new Promise<T>((done, fail) => {
|
|
53
|
+
resolve = done;
|
|
54
|
+
reject = fail;
|
|
55
|
+
});
|
|
56
|
+
return { promise, resolve, reject };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const t = (key: string, options?: Record<string, unknown>) =>
|
|
60
|
+
`${key}${options?.name ? `:${options.name}` : ""}`;
|
|
61
|
+
|
|
62
|
+
describe("ownership history row boundary", () => {
|
|
63
|
+
it("draft persistence: only explicit server ownership supplies persistence proof", () => {
|
|
64
|
+
const local = (id: string, messageCount = 0): ChatThreadSummary => ({
|
|
65
|
+
id,
|
|
66
|
+
title: "",
|
|
67
|
+
preview: "",
|
|
68
|
+
messageCount,
|
|
69
|
+
createdAt: 1,
|
|
70
|
+
updatedAt: 1,
|
|
71
|
+
scope: null,
|
|
72
|
+
});
|
|
73
|
+
const rows = ownDraftRows({
|
|
74
|
+
threads: [
|
|
75
|
+
row("saved-empty", { messageCount: 0 }),
|
|
76
|
+
row("saved-started"),
|
|
77
|
+
local("active-new"),
|
|
78
|
+
local("started-new", 3),
|
|
79
|
+
local("observed-new"),
|
|
80
|
+
row("empty-owner", { ownerEmail: "" }),
|
|
81
|
+
row("shared", { accessBasis: "direct" }),
|
|
82
|
+
row("organization", { accessBasis: "organization" }),
|
|
83
|
+
],
|
|
84
|
+
isNewThread: () => true,
|
|
85
|
+
activeId: "saved-empty",
|
|
86
|
+
observedThreadStarts: new Map([
|
|
87
|
+
["active-new", 2],
|
|
88
|
+
["observed-new", 2],
|
|
89
|
+
["synthesized-new", 2],
|
|
90
|
+
["shared", 2],
|
|
91
|
+
["organization", 2],
|
|
92
|
+
]),
|
|
93
|
+
});
|
|
94
|
+
expect(rows.map(({ id, persisted }) => [id, persisted])).toEqual([
|
|
95
|
+
["saved-empty", true],
|
|
96
|
+
["saved-started", true],
|
|
97
|
+
["active-new", false],
|
|
98
|
+
["started-new", false],
|
|
99
|
+
["observed-new", false],
|
|
100
|
+
["synthesized-new", false],
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("opts proven active blank owners and new local rows in without relaxing ownership", () => {
|
|
105
|
+
const local = (id: string): ChatThreadSummary => ({
|
|
106
|
+
id,
|
|
107
|
+
title: "",
|
|
108
|
+
preview: "",
|
|
109
|
+
messageCount: 0,
|
|
110
|
+
createdAt: 1,
|
|
111
|
+
updatedAt: 1,
|
|
112
|
+
scope: null,
|
|
113
|
+
});
|
|
114
|
+
const threads = [
|
|
115
|
+
row("owner", { messageCount: 0 }),
|
|
116
|
+
row("owner-org", { messageCount: 0, visibility: "org" }),
|
|
117
|
+
local("new-local"),
|
|
118
|
+
local("unproven"),
|
|
119
|
+
{ ...local("partial"), ownerEmail: "owner@example.com" },
|
|
120
|
+
row("archived", { messageCount: 0, archivedAt: 4 }),
|
|
121
|
+
row("direct", { messageCount: 0, accessBasis: "direct" }),
|
|
122
|
+
row("organization", { messageCount: 0, accessBasis: "organization" }),
|
|
123
|
+
];
|
|
124
|
+
const options = {
|
|
125
|
+
threads,
|
|
126
|
+
isNewThread: (id: string) => id === "new-local" || id === "partial",
|
|
127
|
+
};
|
|
128
|
+
expect(ownDraftRows(options)).toEqual([]);
|
|
129
|
+
for (const activeId of ["owner", "owner-org", "new-local"]) {
|
|
130
|
+
expect(
|
|
131
|
+
ownDraftRows({ ...options, activeId }).map((row) => row.id),
|
|
132
|
+
).toEqual([activeId]);
|
|
133
|
+
}
|
|
134
|
+
for (const activeId of [
|
|
135
|
+
"unproven",
|
|
136
|
+
"partial",
|
|
137
|
+
"archived",
|
|
138
|
+
"direct",
|
|
139
|
+
"organization",
|
|
140
|
+
"absent",
|
|
141
|
+
]) {
|
|
142
|
+
expect(ownDraftRows({ ...options, activeId })).toEqual([]);
|
|
143
|
+
}
|
|
144
|
+
expect(
|
|
145
|
+
ownDraftRows({
|
|
146
|
+
threads: [],
|
|
147
|
+
isNewThread: () => true,
|
|
148
|
+
activeId: "absent-new",
|
|
149
|
+
}),
|
|
150
|
+
).toEqual([]);
|
|
151
|
+
});
|
|
152
|
+
it("maps owner organization visibility as Mine without attribution", () => {
|
|
153
|
+
const item = chatThreadToHistoryItem(
|
|
154
|
+
row("mine", { visibility: "org", pinnedAt: 2 }),
|
|
155
|
+
{ t, activeId: "mine" },
|
|
156
|
+
);
|
|
157
|
+
expect(item).toMatchObject({
|
|
158
|
+
id: "mine",
|
|
159
|
+
accessBasis: "owner",
|
|
160
|
+
pinned: true,
|
|
161
|
+
});
|
|
162
|
+
expect(item.ownerLabel).toBeUndefined();
|
|
163
|
+
expect(item.accessLabel).toBeUndefined();
|
|
164
|
+
expect(item.timestamp).toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it.each(["direct", "organization"] as const)(
|
|
168
|
+
"attributes %s rows, dropping inherited pins and actions",
|
|
169
|
+
(accessBasis) => {
|
|
170
|
+
const item = chatThreadToHistoryItem(
|
|
171
|
+
row("shared", { accessBasis, pinnedAt: 20 }),
|
|
172
|
+
{ t, now: 60_001 },
|
|
173
|
+
);
|
|
174
|
+
expect(item).toMatchObject({
|
|
175
|
+
title: "shared",
|
|
176
|
+
pinned: false,
|
|
177
|
+
readOnly: true,
|
|
178
|
+
accessBasis,
|
|
179
|
+
ownerLabel: "owner@example.com",
|
|
180
|
+
accessLabel:
|
|
181
|
+
accessBasis === "direct"
|
|
182
|
+
? "agentChat.history.accessDirect"
|
|
183
|
+
: "agentChat.history.accessOrganization",
|
|
184
|
+
});
|
|
185
|
+
expect(item.timestamp).toBe(
|
|
186
|
+
new Intl.RelativeTimeFormat("en-US", {
|
|
187
|
+
style: "narrow",
|
|
188
|
+
numeric: "auto",
|
|
189
|
+
}).format(-1, "minute"),
|
|
190
|
+
);
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
it("falls back from title to preview to localized untitled copy", () => {
|
|
195
|
+
expect(
|
|
196
|
+
chatThreadToHistoryItem(row("a", { title: "", preview: "Preview" }), {
|
|
197
|
+
t,
|
|
198
|
+
}).titleText,
|
|
199
|
+
).toBe("Preview");
|
|
200
|
+
expect(
|
|
201
|
+
chatThreadToHistoryItem(row("a", { title: "" }), { t }).titleText,
|
|
202
|
+
).toBe("agentChat.history.untitledChat");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("admits only started owned drafts, including observed local starts", () => {
|
|
206
|
+
const local = (
|
|
207
|
+
id: string,
|
|
208
|
+
overrides: Partial<ChatThreadSummary> = {},
|
|
209
|
+
): ChatThreadSummary => ({
|
|
210
|
+
id,
|
|
211
|
+
title: "",
|
|
212
|
+
preview: "",
|
|
213
|
+
messageCount: 0,
|
|
214
|
+
createdAt: 1,
|
|
215
|
+
updatedAt: 1,
|
|
216
|
+
scope: null,
|
|
217
|
+
...overrides,
|
|
218
|
+
});
|
|
219
|
+
const threads = [
|
|
220
|
+
local("untouched"),
|
|
221
|
+
local("started", { messageCount: 1 }),
|
|
222
|
+
local("observed"),
|
|
223
|
+
local("unproven", { messageCount: 2 }),
|
|
224
|
+
local("partial", { messageCount: 1, ownerEmail: "owner@example.com" }),
|
|
225
|
+
row("confirmed"),
|
|
226
|
+
row("archived", { archivedAt: 4 }),
|
|
227
|
+
row("direct", { accessBasis: "direct" }),
|
|
228
|
+
row("organization", { accessBasis: "organization" }),
|
|
229
|
+
];
|
|
230
|
+
const result = ownDraftRows({
|
|
231
|
+
threads,
|
|
232
|
+
isNewThread: (id) =>
|
|
233
|
+
["untouched", "started", "observed", "partial", "synthesized"].includes(
|
|
234
|
+
id,
|
|
235
|
+
),
|
|
236
|
+
observedThreadStarts: new Map([
|
|
237
|
+
["observed", 3],
|
|
238
|
+
["synthesized", 5],
|
|
239
|
+
["stranger", 6],
|
|
240
|
+
["direct", 7],
|
|
241
|
+
["archived", 8],
|
|
242
|
+
]),
|
|
243
|
+
});
|
|
244
|
+
expect(result.map((item) => item.id)).toEqual([
|
|
245
|
+
"started",
|
|
246
|
+
"observed",
|
|
247
|
+
"confirmed",
|
|
248
|
+
"synthesized",
|
|
249
|
+
]);
|
|
250
|
+
expect(result.at(-1)).toEqual({
|
|
251
|
+
id: "synthesized",
|
|
252
|
+
title: "",
|
|
253
|
+
messageCount: 0,
|
|
254
|
+
createdAt: 5,
|
|
255
|
+
updatedAt: 5,
|
|
256
|
+
persisted: false,
|
|
257
|
+
});
|
|
258
|
+
expect(result.every((item) => !("accessBasis" in item))).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("ownership history adapter", () => {
|
|
263
|
+
let container: HTMLDivElement;
|
|
264
|
+
let root: Root;
|
|
265
|
+
let latest: UseOwnershipChatHistoryResult;
|
|
266
|
+
let mine: ChatThreadListRow[];
|
|
267
|
+
let shared: ChatThreadListRow[];
|
|
268
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
269
|
+
let failMine: boolean;
|
|
270
|
+
let failShared: boolean;
|
|
271
|
+
let renderRail: boolean;
|
|
272
|
+
const selected = vi.fn();
|
|
273
|
+
const newChat = vi.fn();
|
|
274
|
+
|
|
275
|
+
function pageResponse(urlValue: unknown): Response {
|
|
276
|
+
const url = new URL(String(urlValue), "http://test.local");
|
|
277
|
+
const ownership = url.searchParams.get("ownership");
|
|
278
|
+
if (
|
|
279
|
+
(ownership === "mine" && failMine) ||
|
|
280
|
+
(ownership === "shared" && failShared)
|
|
281
|
+
)
|
|
282
|
+
return new Response("unavailable", { status: 503 });
|
|
283
|
+
const source = ownership === "mine" ? mine : shared;
|
|
284
|
+
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
285
|
+
const limit = Number(url.searchParams.get("limit"));
|
|
286
|
+
return Response.json({
|
|
287
|
+
threads: source.slice(offset, offset + limit),
|
|
288
|
+
hasMore: source.length > offset + limit,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function Probe(props: UseOwnershipChatHistoryOptions) {
|
|
293
|
+
latest = useOwnershipChatHistory(props);
|
|
294
|
+
return renderRail ? (
|
|
295
|
+
<OwnershipChatHistoryRail
|
|
296
|
+
{...latest}
|
|
297
|
+
activeId={props.activeId}
|
|
298
|
+
onSelect={selected}
|
|
299
|
+
onNewChat={newChat}
|
|
300
|
+
showNewChat={false}
|
|
301
|
+
/>
|
|
302
|
+
) : null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function render(options: UseOwnershipChatHistoryOptions = {}) {
|
|
306
|
+
await act(async () => {
|
|
307
|
+
root.render(<Probe {...options} />);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function renderActivity(
|
|
312
|
+
options: UseOwnershipChatHistoryOptions,
|
|
313
|
+
mode: "visible" | "hidden",
|
|
314
|
+
) {
|
|
315
|
+
await act(async () => {
|
|
316
|
+
root.render(
|
|
317
|
+
<Activity mode={mode}>
|
|
318
|
+
<Probe {...options} />
|
|
319
|
+
</Activity>,
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function section(id: string) {
|
|
325
|
+
return latest.sections.find((section) => section.id === id)!;
|
|
326
|
+
}
|
|
327
|
+
function item(id: string) {
|
|
328
|
+
return latest.sections
|
|
329
|
+
.flatMap((section) => section.items)
|
|
330
|
+
.find((item) => item.id === id);
|
|
331
|
+
}
|
|
332
|
+
function sectionElement(id: string) {
|
|
333
|
+
return container.querySelector(
|
|
334
|
+
`[data-section-id="${id}"].an-chat-history__section`,
|
|
335
|
+
)!;
|
|
336
|
+
}
|
|
337
|
+
function clickText(element: Element, text: string) {
|
|
338
|
+
const button = [...element.querySelectorAll("button")].find(
|
|
339
|
+
(button) => button.textContent?.trim() === text,
|
|
340
|
+
);
|
|
341
|
+
expect(button).toBeDefined();
|
|
342
|
+
return act(async () => {
|
|
343
|
+
button!.click();
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
beforeEach(() => {
|
|
348
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
349
|
+
mine = [];
|
|
350
|
+
shared = [];
|
|
351
|
+
failMine = false;
|
|
352
|
+
failShared = false;
|
|
353
|
+
renderRail = false;
|
|
354
|
+
fetchMock = vi.fn(async (url) => pageResponse(url));
|
|
355
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
356
|
+
container = document.createElement("div");
|
|
357
|
+
document.body.appendChild(container);
|
|
358
|
+
root = createRoot(container);
|
|
359
|
+
selected.mockClear();
|
|
360
|
+
newChat.mockClear();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
afterEach(() => {
|
|
364
|
+
act(() => root.unmount());
|
|
365
|
+
container.remove();
|
|
366
|
+
vi.useRealTimers();
|
|
367
|
+
vi.restoreAllMocks();
|
|
368
|
+
vi.unstubAllGlobals();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("forwards two ownership queries before independent pagination and preserves attribution", async () => {
|
|
372
|
+
mine = [row("mine-org", { visibility: "org" }), row("mine-second")];
|
|
373
|
+
shared = [
|
|
374
|
+
row("direct", { accessBasis: "direct", pinnedAt: 12 }),
|
|
375
|
+
row("shared-second", { accessBasis: "organization" }),
|
|
376
|
+
];
|
|
377
|
+
await render({
|
|
378
|
+
apiUrl: "/test-chat",
|
|
379
|
+
pageSize: 1,
|
|
380
|
+
query: "term",
|
|
381
|
+
scope: { type: "project", id: "p" },
|
|
382
|
+
includeExternal: true,
|
|
383
|
+
});
|
|
384
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
385
|
+
for (const [urlValue] of fetchMock.mock.calls) {
|
|
386
|
+
const url = new URL(String(urlValue), "http://test.local");
|
|
387
|
+
expect(url.pathname).toBe("/test-chat/threads");
|
|
388
|
+
expect(url.searchParams.get("q")).toBe("term");
|
|
389
|
+
expect(url.searchParams.get("includeExternal")).toBe("1");
|
|
390
|
+
expect(url.searchParams.get("scopeId")).toBe("p");
|
|
391
|
+
}
|
|
392
|
+
expect(item("mine-org")?.ownerLabel).toBeUndefined();
|
|
393
|
+
expect(item("direct")).toMatchObject({
|
|
394
|
+
pinned: false,
|
|
395
|
+
readOnly: true,
|
|
396
|
+
ownerLabel: "owner@example.com",
|
|
397
|
+
accessLabel: "Shared with you",
|
|
398
|
+
});
|
|
399
|
+
await act(async () => {
|
|
400
|
+
await section("shared").onLoadMore!();
|
|
401
|
+
});
|
|
402
|
+
expect(latest.sharedRows.map((row) => row.id)).toEqual([
|
|
403
|
+
"direct",
|
|
404
|
+
"shared-second",
|
|
405
|
+
]);
|
|
406
|
+
expect(latest.mineRows.map((row) => row.id)).toEqual(["mine-org"]);
|
|
407
|
+
await act(async () => {
|
|
408
|
+
await section("mine").onLoadMore!();
|
|
409
|
+
});
|
|
410
|
+
expect(latest.mineRows.map((row) => row.id)).toEqual([
|
|
411
|
+
"mine-org",
|
|
412
|
+
"mine-second",
|
|
413
|
+
]);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("previews five Shared rows, discloses all loaded pages, and collapses back to five", async () => {
|
|
417
|
+
renderRail = true;
|
|
418
|
+
shared = Array.from({ length: 14 }, (_, i) =>
|
|
419
|
+
row(`shared-${i}`, { accessBasis: "direct" }),
|
|
420
|
+
);
|
|
421
|
+
await render({ pageSize: 7, activeId: "shared-12" });
|
|
422
|
+
expect(
|
|
423
|
+
sectionElement("shared").querySelectorAll(".an-chat-history-row"),
|
|
424
|
+
).toHaveLength(5);
|
|
425
|
+
expect(
|
|
426
|
+
container.querySelectorAll(".an-chat-history-row--active"),
|
|
427
|
+
).toHaveLength(0);
|
|
428
|
+
await clickText(sectionElement("shared"), "Show all");
|
|
429
|
+
expect(
|
|
430
|
+
sectionElement("shared").querySelectorAll(".an-chat-history-row"),
|
|
431
|
+
).toHaveLength(7);
|
|
432
|
+
await clickText(sectionElement("shared"), "Load more");
|
|
433
|
+
expect(
|
|
434
|
+
sectionElement("shared").querySelectorAll(".an-chat-history-row"),
|
|
435
|
+
).toHaveLength(14);
|
|
436
|
+
expect(
|
|
437
|
+
container.querySelectorAll(".an-chat-history-row--active"),
|
|
438
|
+
).toHaveLength(1);
|
|
439
|
+
await act(async () => {
|
|
440
|
+
sectionElement("shared")
|
|
441
|
+
.querySelectorAll<HTMLButtonElement>(".an-chat-history-row__button")[12]
|
|
442
|
+
.click();
|
|
443
|
+
});
|
|
444
|
+
expect(selected).toHaveBeenCalledWith("shared-12");
|
|
445
|
+
await act(async () => {
|
|
446
|
+
await latest.refresh();
|
|
447
|
+
});
|
|
448
|
+
expect(
|
|
449
|
+
sectionElement("shared").querySelectorAll(".an-chat-history-row"),
|
|
450
|
+
).toHaveLength(14);
|
|
451
|
+
await clickText(sectionElement("shared"), "Show less");
|
|
452
|
+
expect(
|
|
453
|
+
sectionElement("shared").querySelectorAll(".an-chat-history-row"),
|
|
454
|
+
).toHaveLength(5);
|
|
455
|
+
expect(newChat).not.toHaveBeenCalled();
|
|
456
|
+
expect(
|
|
457
|
+
fetchMock.mock.calls.every(([url]) => String(url).includes("/threads?")),
|
|
458
|
+
).toBe(true);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("keeps paging reachable when every loaded Mine row is pinned", async () => {
|
|
462
|
+
renderRail = true;
|
|
463
|
+
mine = Array.from({ length: 8 }, (_, i) =>
|
|
464
|
+
row(`pin-${i}`, { pinnedAt: i + 1 }),
|
|
465
|
+
);
|
|
466
|
+
await render({ pageSize: 3, pinned: true });
|
|
467
|
+
expect(section("mine").items).toEqual([]);
|
|
468
|
+
expect(section("mine").emptyLabel).toBeUndefined();
|
|
469
|
+
expect(
|
|
470
|
+
sectionElement("pinned").querySelectorAll(".an-chat-history-row"),
|
|
471
|
+
).toHaveLength(3);
|
|
472
|
+
await clickText(sectionElement("mine"), "Show all");
|
|
473
|
+
await clickText(sectionElement("mine"), "Load more");
|
|
474
|
+
expect(
|
|
475
|
+
sectionElement("pinned").querySelectorAll(".an-chat-history-row"),
|
|
476
|
+
).toHaveLength(6);
|
|
477
|
+
await clickText(sectionElement("mine"), "Load more");
|
|
478
|
+
expect(
|
|
479
|
+
sectionElement("pinned").querySelectorAll(".an-chat-history-row"),
|
|
480
|
+
).toHaveLength(8);
|
|
481
|
+
expect(sectionElement("pinned").textContent).not.toContain("Show all");
|
|
482
|
+
expect(section("pinned").onLoadMore).toBeUndefined();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("lets server rows win draft duplicates and never manufactures active shared rows", async () => {
|
|
486
|
+
mine = [row("owned", { title: "Server title", pinnedAt: 1 })];
|
|
487
|
+
shared = [row("shared", { accessBasis: "direct" })];
|
|
488
|
+
const drafts = ownDraftRows({
|
|
489
|
+
threads: [row("owned"), row("shared"), row("local")],
|
|
490
|
+
isNewThread: () => true,
|
|
491
|
+
});
|
|
492
|
+
await render({
|
|
493
|
+
localMineRows: drafts,
|
|
494
|
+
pinned: true,
|
|
495
|
+
activeId: "unloaded-shared",
|
|
496
|
+
});
|
|
497
|
+
expect(section("mine").items.map((item) => item.id)).toEqual(["local"]);
|
|
498
|
+
expect(item("owned")?.title).toBe("Server title");
|
|
499
|
+
expect(item("shared")?.readOnly).toBe(true);
|
|
500
|
+
expect(item("unloaded-shared")).toBeUndefined();
|
|
501
|
+
expect(latest.findMine("shared")).toBeUndefined();
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it.each(["server", "draft"] as const)(
|
|
505
|
+
"decorates the effective %s rename title while pending and restores it on rollback",
|
|
506
|
+
async (source) => {
|
|
507
|
+
renderRail = true;
|
|
508
|
+
const original = row("owned", { title: "Original", pinnedAt: 2 });
|
|
509
|
+
mine = source === "server" ? [original] : [];
|
|
510
|
+
const localMineRows =
|
|
511
|
+
source === "draft"
|
|
512
|
+
? ownDraftRows({ threads: [original], isNewThread: () => false })
|
|
513
|
+
: [];
|
|
514
|
+
const pending = deferred<boolean>();
|
|
515
|
+
const decorateItem = vi.fn((item, effectiveRow) => ({
|
|
516
|
+
...item,
|
|
517
|
+
title: (
|
|
518
|
+
<span data-testid="decorated-title">
|
|
519
|
+
{effectiveRow.title || effectiveRow.preview}
|
|
520
|
+
</span>
|
|
521
|
+
),
|
|
522
|
+
titleText: effectiveRow.title || effectiveRow.preview,
|
|
523
|
+
}));
|
|
524
|
+
await render({
|
|
525
|
+
pinned: true,
|
|
526
|
+
localMineRows,
|
|
527
|
+
decorateItem,
|
|
528
|
+
mutations: { rename: () => pending.promise },
|
|
529
|
+
});
|
|
530
|
+
let result!: Promise<boolean>;
|
|
531
|
+
act(() => {
|
|
532
|
+
result = latest.listProps.onRename!("owned", "Optimistic");
|
|
533
|
+
});
|
|
534
|
+
expect(
|
|
535
|
+
container.querySelector('[data-testid="decorated-title"]')?.textContent,
|
|
536
|
+
).toBe("Optimistic");
|
|
537
|
+
expect(item("owned")?.titleText).toBe("Optimistic");
|
|
538
|
+
expect(decorateItem.mock.lastCall?.[1].title).toBe("Optimistic");
|
|
539
|
+
expect(original.title).toBe("Original");
|
|
540
|
+
await act(async () => {
|
|
541
|
+
pending.resolve(false);
|
|
542
|
+
expect(await result).toBe(false);
|
|
543
|
+
});
|
|
544
|
+
expect(
|
|
545
|
+
container.querySelector('[data-testid="decorated-title"]')?.textContent,
|
|
546
|
+
).toBe("Original");
|
|
547
|
+
expect(item("owned")?.titleText).toBe("Original");
|
|
548
|
+
},
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
it.each([
|
|
552
|
+
{
|
|
553
|
+
input: " \t Quarterly\n Roadmap \r\n ",
|
|
554
|
+
expected: "Quarterly Roadmap",
|
|
555
|
+
label: "whitespace",
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
input: ` ${"A".repeat(158)}\t \n B overflow `,
|
|
559
|
+
expected: `${"A".repeat(158)} B`,
|
|
560
|
+
label: "160 characters",
|
|
561
|
+
},
|
|
562
|
+
])(
|
|
563
|
+
"normalized rename: $label agrees across pending decoration, callback, datastore and fresh read",
|
|
564
|
+
async ({ input, expected }) => {
|
|
565
|
+
renderRail = true;
|
|
566
|
+
mine = [row("owned", { title: "Original" })];
|
|
567
|
+
const transport = deferred<void>();
|
|
568
|
+
const rename = vi.fn(async (_id: string, title: string) => {
|
|
569
|
+
await transport.promise;
|
|
570
|
+
mine = [row("owned", { title })];
|
|
571
|
+
return true;
|
|
572
|
+
});
|
|
573
|
+
const onMutationFailed = vi.fn();
|
|
574
|
+
const options: UseOwnershipChatHistoryOptions = {
|
|
575
|
+
mutations: { rename },
|
|
576
|
+
onMutationFailed,
|
|
577
|
+
decorateItem: (item, effectiveRow) => ({
|
|
578
|
+
...item,
|
|
579
|
+
title: (
|
|
580
|
+
<span data-testid="normalized-title">{effectiveRow.title}</span>
|
|
581
|
+
),
|
|
582
|
+
titleText: effectiveRow.title,
|
|
583
|
+
}),
|
|
584
|
+
};
|
|
585
|
+
await render(options);
|
|
586
|
+
let result!: Promise<boolean>;
|
|
587
|
+
act(() => {
|
|
588
|
+
result = latest.listProps.onRename!("owned", input);
|
|
589
|
+
});
|
|
590
|
+
try {
|
|
591
|
+
expect(item("owned")?.titleText).toBe(expected);
|
|
592
|
+
expect(
|
|
593
|
+
container.querySelector('[data-testid="normalized-title"]')
|
|
594
|
+
?.textContent,
|
|
595
|
+
).toBe(expected);
|
|
596
|
+
expect(rename).toHaveBeenCalledExactlyOnceWith("owned", expected);
|
|
597
|
+
expect(mine[0].title).toBe("Original");
|
|
598
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
599
|
+
} finally {
|
|
600
|
+
await act(async () => {
|
|
601
|
+
transport.resolve();
|
|
602
|
+
expect(await result).toBe(true);
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
expect(mine[0].title).toBe(expected);
|
|
606
|
+
expect(latest.mineRows).toEqual(mine);
|
|
607
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
608
|
+
await act(async () => {
|
|
609
|
+
root.render(null);
|
|
610
|
+
});
|
|
611
|
+
await render(options);
|
|
612
|
+
expect(item("owned")?.titleText).toBe(expected);
|
|
613
|
+
expect(latest.mineRows[0].title).toBe(expected);
|
|
614
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
615
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
616
|
+
},
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
it.each(["false", "throw"] as const)(
|
|
620
|
+
"normalized rename: %s rolls back the displayed value without changing persisted data",
|
|
621
|
+
async (failure) => {
|
|
622
|
+
mine = [row("owned", { title: "Original" })];
|
|
623
|
+
const pending = deferred<boolean>();
|
|
624
|
+
const rename = vi.fn(() => pending.promise);
|
|
625
|
+
const onMutationFailed = vi.fn();
|
|
626
|
+
await render({ mutations: { rename }, onMutationFailed });
|
|
627
|
+
let result!: Promise<boolean>;
|
|
628
|
+
act(() => {
|
|
629
|
+
result = latest.listProps.onRename!("owned", " \t Unsaved\n title ");
|
|
630
|
+
});
|
|
631
|
+
const cause = new Error("Rename failed");
|
|
632
|
+
try {
|
|
633
|
+
expect(item("owned")?.titleText).toBe("Unsaved title");
|
|
634
|
+
expect(rename).toHaveBeenCalledExactlyOnceWith(
|
|
635
|
+
"owned",
|
|
636
|
+
"Unsaved title",
|
|
637
|
+
);
|
|
638
|
+
} finally {
|
|
639
|
+
await act(async () => {
|
|
640
|
+
if (failure === "throw") pending.reject(cause);
|
|
641
|
+
else pending.resolve(false);
|
|
642
|
+
expect(await result).toBe(false);
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
expect(item("owned")?.titleText).toBe("Original");
|
|
646
|
+
expect(mine[0].title).toBe("Original");
|
|
647
|
+
expect(latest.mineRows).toEqual(mine);
|
|
648
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith(
|
|
649
|
+
"rename",
|
|
650
|
+
"owned",
|
|
651
|
+
...(failure === "throw" ? [cause] : []),
|
|
652
|
+
);
|
|
653
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
654
|
+
},
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
it("normalized rename: empty input retains the persistence callback's failure disposition", async () => {
|
|
658
|
+
mine = [row("owned", { title: "Original" })];
|
|
659
|
+
const rename = vi.fn(async () => false);
|
|
660
|
+
const onMutationFailed = vi.fn();
|
|
661
|
+
await render({ mutations: { rename }, onMutationFailed });
|
|
662
|
+
await act(async () => {
|
|
663
|
+
expect(await latest.listProps.onRename!("owned", " \t\n ")).toBe(false);
|
|
664
|
+
});
|
|
665
|
+
expect(rename).toHaveBeenCalledExactlyOnceWith("owned", "");
|
|
666
|
+
expect(item("owned")?.titleText).toBe("Original");
|
|
667
|
+
expect(mine[0].title).toBe("Original");
|
|
668
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith("rename", "owned");
|
|
669
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it.each([
|
|
673
|
+
"active-empty",
|
|
674
|
+
"started",
|
|
675
|
+
"observed",
|
|
676
|
+
"synthesized",
|
|
677
|
+
"manual-unknown",
|
|
678
|
+
] as const)(
|
|
679
|
+
"draft persistence: %s stays selectable with its running title and no actions after decoration",
|
|
680
|
+
async (source) => {
|
|
681
|
+
renderRail = true;
|
|
682
|
+
const local = {
|
|
683
|
+
id: "draft",
|
|
684
|
+
title: "",
|
|
685
|
+
preview: "",
|
|
686
|
+
messageCount: source === "started" ? 3 : 0,
|
|
687
|
+
createdAt: 1,
|
|
688
|
+
updatedAt: 1,
|
|
689
|
+
};
|
|
690
|
+
const localMineRows =
|
|
691
|
+
source === "manual-unknown"
|
|
692
|
+
? [local]
|
|
693
|
+
: ownDraftRows({
|
|
694
|
+
threads:
|
|
695
|
+
source === "synthesized" ? [] : [{ ...local, scope: null }],
|
|
696
|
+
isNewThread: () => true,
|
|
697
|
+
activeId: source === "active-empty" ? "draft" : null,
|
|
698
|
+
observedThreadStarts:
|
|
699
|
+
source === "observed" || source === "synthesized"
|
|
700
|
+
? new Map([["draft", 1]])
|
|
701
|
+
: undefined,
|
|
702
|
+
});
|
|
703
|
+
const mutation = vi.fn(async () => false);
|
|
704
|
+
await render({
|
|
705
|
+
activeId: "draft",
|
|
706
|
+
localMineRows,
|
|
707
|
+
mutations: { pin: mutation, rename: mutation, archive: mutation },
|
|
708
|
+
decorateItem: (item) => ({
|
|
709
|
+
...item,
|
|
710
|
+
hideActions: false,
|
|
711
|
+
title: <span data-testid="running-draft">Working draft</span>,
|
|
712
|
+
}),
|
|
713
|
+
});
|
|
714
|
+
expect(latest.findMine("draft")).toBe(localMineRows[0]);
|
|
715
|
+
expect(
|
|
716
|
+
container.querySelector('[data-testid="running-draft"]')?.textContent,
|
|
717
|
+
).toBe("Working draft");
|
|
718
|
+
await act(async () => {
|
|
719
|
+
container
|
|
720
|
+
.querySelector<HTMLButtonElement>(".an-chat-history-row__button")!
|
|
721
|
+
.click();
|
|
722
|
+
});
|
|
723
|
+
expect(selected).toHaveBeenCalledExactlyOnceWith("draft");
|
|
724
|
+
expect(item("draft")?.hideActions).toBe(true);
|
|
725
|
+
expect(
|
|
726
|
+
container.querySelector('button[aria-haspopup="menu"]'),
|
|
727
|
+
).toBeNull();
|
|
728
|
+
expect(mutation).not.toHaveBeenCalled();
|
|
729
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
730
|
+
},
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
it("draft persistence: unconfirmed direct pin rename and archive calls do not persist or report failures", async () => {
|
|
734
|
+
const localMineRows = ownDraftRows({
|
|
735
|
+
threads: [
|
|
736
|
+
{
|
|
737
|
+
id: "started",
|
|
738
|
+
title: "Started",
|
|
739
|
+
preview: "",
|
|
740
|
+
messageCount: 4,
|
|
741
|
+
createdAt: 1,
|
|
742
|
+
updatedAt: 1,
|
|
743
|
+
scope: null,
|
|
744
|
+
},
|
|
745
|
+
],
|
|
746
|
+
isNewThread: () => true,
|
|
747
|
+
observedThreadStarts: new Map([["observed", 2]]),
|
|
748
|
+
});
|
|
749
|
+
localMineRows.push({
|
|
750
|
+
id: "manual",
|
|
751
|
+
title: "Manual",
|
|
752
|
+
messageCount: 9,
|
|
753
|
+
createdAt: 1,
|
|
754
|
+
updatedAt: 1,
|
|
755
|
+
});
|
|
756
|
+
const pin = vi.fn(async () => false);
|
|
757
|
+
const rename = vi.fn(async () => false);
|
|
758
|
+
const archive = vi.fn(async () => false);
|
|
759
|
+
const onMutationFailed = vi.fn();
|
|
760
|
+
await render({
|
|
761
|
+
localMineRows,
|
|
762
|
+
mutations: { pin, rename, archive },
|
|
763
|
+
onMutationFailed,
|
|
764
|
+
});
|
|
765
|
+
const originalTitles = new Map(
|
|
766
|
+
localMineRows.map(({ id }) => [id, item(id)?.titleText]),
|
|
767
|
+
);
|
|
768
|
+
for (const { id } of localMineRows) {
|
|
769
|
+
await act(async () => {
|
|
770
|
+
expect(await latest.listProps.onTogglePin!(id)).toBe(false);
|
|
771
|
+
expect(await latest.listProps.onRename!(id, "Changed")).toBe(false);
|
|
772
|
+
expect(await latest.listProps.onDelete!(id)).toBe(false);
|
|
773
|
+
});
|
|
774
|
+
expect(latest.findMine(id)).toBeDefined();
|
|
775
|
+
expect(item(id)).toMatchObject({
|
|
776
|
+
titleText: originalTitles.get(id),
|
|
777
|
+
pinned: false,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
expect(pin).not.toHaveBeenCalled();
|
|
781
|
+
expect(rename).not.toHaveBeenCalled();
|
|
782
|
+
expect(archive).not.toHaveBeenCalled();
|
|
783
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
784
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it.each(["server-list", "owned-fallback"] as const)(
|
|
788
|
+
"draft persistence: a saved empty %s keeps working owner actions",
|
|
789
|
+
async (source) => {
|
|
790
|
+
renderRail = true;
|
|
791
|
+
let stored = row("saved", { title: "", messageCount: 0, pinnedAt: null });
|
|
792
|
+
mine = source === "server-list" ? [stored] : [];
|
|
793
|
+
const localMineRows =
|
|
794
|
+
source === "owned-fallback"
|
|
795
|
+
? ownDraftRows({
|
|
796
|
+
threads: [stored],
|
|
797
|
+
isNewThread: () => false,
|
|
798
|
+
activeId: "saved",
|
|
799
|
+
})
|
|
800
|
+
: [];
|
|
801
|
+
const pin = vi.fn(async (_id: string, pinned: boolean) => {
|
|
802
|
+
stored = { ...stored, pinnedAt: pinned ? 2 : null };
|
|
803
|
+
mine = [stored];
|
|
804
|
+
return true;
|
|
805
|
+
});
|
|
806
|
+
const rename = vi.fn(async (_id: string, title: string) => {
|
|
807
|
+
stored = { ...stored, title };
|
|
808
|
+
mine = [stored];
|
|
809
|
+
return true;
|
|
810
|
+
});
|
|
811
|
+
const archive = vi.fn(async () => {
|
|
812
|
+
mine = [];
|
|
813
|
+
return true;
|
|
814
|
+
});
|
|
815
|
+
const options = {
|
|
816
|
+
activeId: "saved",
|
|
817
|
+
pinned: true,
|
|
818
|
+
localMineRows,
|
|
819
|
+
mutations: { pin, rename, archive },
|
|
820
|
+
};
|
|
821
|
+
await render(options);
|
|
822
|
+
expect(item("saved")?.hideActions).not.toBe(true);
|
|
823
|
+
expect(
|
|
824
|
+
container.querySelector('button[aria-haspopup="menu"]'),
|
|
825
|
+
).not.toBeNull();
|
|
826
|
+
await act(async () => {
|
|
827
|
+
expect(await latest.listProps.onTogglePin!("saved")).toBe(true);
|
|
828
|
+
});
|
|
829
|
+
expect(stored.pinnedAt).toBe(2);
|
|
830
|
+
expect(latest.mineRows).toEqual([stored]);
|
|
831
|
+
await render({ ...options, localMineRows: [] });
|
|
832
|
+
await act(async () => {
|
|
833
|
+
expect(await latest.listProps.onRename!("saved", "Saved title")).toBe(
|
|
834
|
+
true,
|
|
835
|
+
);
|
|
836
|
+
});
|
|
837
|
+
expect(stored.title).toBe("Saved title");
|
|
838
|
+
expect(stored.messageCount).toBe(0);
|
|
839
|
+
await act(async () => {
|
|
840
|
+
expect(await latest.listProps.onDelete!("saved")).toBe(true);
|
|
841
|
+
});
|
|
842
|
+
expect(item("saved")).toBeUndefined();
|
|
843
|
+
expect(pin).toHaveBeenCalledExactlyOnceWith("saved", true);
|
|
844
|
+
expect(rename).toHaveBeenCalledExactlyOnceWith("saved", "Saved title");
|
|
845
|
+
expect(archive).toHaveBeenCalledExactlyOnceWith("saved");
|
|
846
|
+
expect(fetchMock).toHaveBeenCalledTimes(8);
|
|
847
|
+
},
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
it("draft persistence: the first authoritative owner row enables actions for the same selected draft id", async () => {
|
|
851
|
+
renderRail = true;
|
|
852
|
+
shared = [row("shared", { accessBasis: "direct" })];
|
|
853
|
+
const localMineRows = ownDraftRows({
|
|
854
|
+
threads: [
|
|
855
|
+
{
|
|
856
|
+
id: "draft",
|
|
857
|
+
title: "",
|
|
858
|
+
preview: "",
|
|
859
|
+
messageCount: 0,
|
|
860
|
+
createdAt: 1,
|
|
861
|
+
updatedAt: 1,
|
|
862
|
+
scope: null,
|
|
863
|
+
},
|
|
864
|
+
],
|
|
865
|
+
isNewThread: () => true,
|
|
866
|
+
activeId: "draft",
|
|
867
|
+
});
|
|
868
|
+
const pin = vi.fn(async (_id: string, pinned: boolean) => {
|
|
869
|
+
mine = [
|
|
870
|
+
row("draft", {
|
|
871
|
+
title: "Saved server title",
|
|
872
|
+
messageCount: 0,
|
|
873
|
+
pinnedAt: pinned ? 2 : null,
|
|
874
|
+
}),
|
|
875
|
+
];
|
|
876
|
+
return true;
|
|
877
|
+
});
|
|
878
|
+
const onMutationFailed = vi.fn();
|
|
879
|
+
await render({
|
|
880
|
+
activeId: "draft",
|
|
881
|
+
pinned: true,
|
|
882
|
+
localMineRows,
|
|
883
|
+
mutations: { pin },
|
|
884
|
+
onMutationFailed,
|
|
885
|
+
decorateItem: (item) => ({ ...item, hideActions: false }),
|
|
886
|
+
});
|
|
887
|
+
expect(item("draft")?.hideActions).toBe(true);
|
|
888
|
+
expect(item("shared")?.readOnly).toBe(true);
|
|
889
|
+
mine = [row("draft", { title: "Saved server title", messageCount: 0 })];
|
|
890
|
+
await act(async () => {
|
|
891
|
+
await latest.refresh();
|
|
892
|
+
});
|
|
893
|
+
expect(latest.findMine("draft")).toEqual(mine[0]);
|
|
894
|
+
expect(section("mine").items.map((item) => item.id)).toEqual(["draft"]);
|
|
895
|
+
expect(item("draft")?.titleText).toBe("Saved server title");
|
|
896
|
+
expect(item("draft")?.hideActions).not.toBe(true);
|
|
897
|
+
expect(
|
|
898
|
+
container.querySelectorAll('button[aria-haspopup="menu"]'),
|
|
899
|
+
).toHaveLength(1);
|
|
900
|
+
expect(section("shared").items.map((item) => item.id)).toEqual(["shared"]);
|
|
901
|
+
expect(item("shared")?.readOnly).toBe(true);
|
|
902
|
+
expect(selected).not.toHaveBeenCalled();
|
|
903
|
+
await act(async () => {
|
|
904
|
+
expect(await latest.listProps.onTogglePin!("draft")).toBe(true);
|
|
905
|
+
});
|
|
906
|
+
expect(pin).toHaveBeenCalledExactlyOnceWith("draft", true);
|
|
907
|
+
expect(mine[0].pinnedAt).toBe(2);
|
|
908
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
909
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
it("retains the decorated rename after persistence when authoritative refresh fails", async () => {
|
|
913
|
+
renderRail = true;
|
|
914
|
+
mine = [row("owned", { title: "Original" })];
|
|
915
|
+
const pending = deferred<boolean>();
|
|
916
|
+
await render({
|
|
917
|
+
mutations: { rename: () => pending.promise },
|
|
918
|
+
decorateItem: (item, effectiveRow) => ({
|
|
919
|
+
...item,
|
|
920
|
+
title: <span data-testid="decorated-title">{effectiveRow.title}</span>,
|
|
921
|
+
titleText: effectiveRow.title,
|
|
922
|
+
}),
|
|
923
|
+
});
|
|
924
|
+
let result!: Promise<boolean>;
|
|
925
|
+
act(() => {
|
|
926
|
+
result = latest.listProps.onRename!("owned", "Persisted title");
|
|
927
|
+
});
|
|
928
|
+
expect(
|
|
929
|
+
container.querySelector('[data-testid="decorated-title"]')?.textContent,
|
|
930
|
+
).toBe("Persisted title");
|
|
931
|
+
failMine = true;
|
|
932
|
+
await act(async () => {
|
|
933
|
+
pending.resolve(true);
|
|
934
|
+
expect(await result).toBe(true);
|
|
935
|
+
});
|
|
936
|
+
expect(latest.error?.message).toContain("503");
|
|
937
|
+
expect(
|
|
938
|
+
container.querySelector('[data-testid="decorated-title"]')?.textContent,
|
|
939
|
+
).toBe("Persisted title");
|
|
940
|
+
expect(latest.mineRows[0].title).toBe("Original");
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it("filters local title/preview search case-sensitively without filtering server transcript matches", async () => {
|
|
944
|
+
mine = [row("server-body-match", { title: "Summary without the term" })];
|
|
945
|
+
shared = [row("shared", { title: "Plan shared", accessBasis: "direct" })];
|
|
946
|
+
const localMineRows = ownDraftRows({
|
|
947
|
+
threads: [
|
|
948
|
+
row("title", { title: "Quarterly Plan" }),
|
|
949
|
+
row("preview", { title: "Notes", preview: "The Plan begins" }),
|
|
950
|
+
row("case-mismatch", { title: "Quarterly plan", preview: "planning" }),
|
|
951
|
+
row("unrelated", { title: "Unrelated" }),
|
|
952
|
+
row("server-body-match", { title: "Plan stale local title" }),
|
|
953
|
+
row("shared", { title: "Plan shared", accessBasis: "direct" }),
|
|
954
|
+
],
|
|
955
|
+
isNewThread: () => false,
|
|
956
|
+
});
|
|
957
|
+
await render({ query: " Plan ", localMineRows });
|
|
958
|
+
expect(section("mine").items.map((item) => item.id)).toEqual([
|
|
959
|
+
"title",
|
|
960
|
+
"preview",
|
|
961
|
+
"server-body-match",
|
|
962
|
+
]);
|
|
963
|
+
expect(item("server-body-match")?.titleText).toBe(
|
|
964
|
+
"Summary without the term",
|
|
965
|
+
);
|
|
966
|
+
expect(latest.findMine("unrelated")?.id).toBe("unrelated");
|
|
967
|
+
expect(latest.findMine("shared")).toBeUndefined();
|
|
968
|
+
expect(section("shared").items.map((item) => item.id)).toEqual(["shared"]);
|
|
969
|
+
expect(
|
|
970
|
+
fetchMock.mock.calls.every(
|
|
971
|
+
([url]) =>
|
|
972
|
+
new URL(String(url), "http://test.local").searchParams.get("q") ===
|
|
973
|
+
"Plan",
|
|
974
|
+
),
|
|
975
|
+
).toBe(true);
|
|
976
|
+
|
|
977
|
+
mine = [];
|
|
978
|
+
shared = [];
|
|
979
|
+
await render({ query: "plan", localMineRows });
|
|
980
|
+
expect(section("mine").items.map((item) => item.id)).toEqual([
|
|
981
|
+
"case-mismatch",
|
|
982
|
+
]);
|
|
983
|
+
await render({ query: " ", localMineRows });
|
|
984
|
+
expect(section("mine").items.map((item) => item.id)).toEqual([
|
|
985
|
+
"title",
|
|
986
|
+
"preview",
|
|
987
|
+
"case-mismatch",
|
|
988
|
+
"unrelated",
|
|
989
|
+
"server-body-match",
|
|
990
|
+
]);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
it("treats percent, underscore, and escape-looking search text literally in each local field", async () => {
|
|
994
|
+
const localMineRows = ownDraftRows({
|
|
995
|
+
threads: [
|
|
996
|
+
row("literal-title", { title: "Status 100%_! complete" }),
|
|
997
|
+
row("literal-preview", { title: "Status", preview: "100%_! complete" }),
|
|
998
|
+
row("wildcard-lookalike", { title: "Status 100abcZ! complete" }),
|
|
999
|
+
row("split-fields", { title: "100%", preview: "_!" }),
|
|
1000
|
+
],
|
|
1001
|
+
isNewThread: () => false,
|
|
1002
|
+
});
|
|
1003
|
+
await render({ query: " 100%_! ", localMineRows });
|
|
1004
|
+
expect(section("mine").items.map((item) => item.id)).toEqual([
|
|
1005
|
+
"literal-title",
|
|
1006
|
+
"literal-preview",
|
|
1007
|
+
]);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it("keeps unfiltered owned draft lookup for another intent after a pending rename stops matching search", async () => {
|
|
1011
|
+
const localMineRows = ownDraftRows({
|
|
1012
|
+
threads: [row("local", { title: "Plan" })],
|
|
1013
|
+
isNewThread: () => false,
|
|
1014
|
+
});
|
|
1015
|
+
const pendingRename = deferred<boolean>();
|
|
1016
|
+
const pendingArchive = deferred<boolean>();
|
|
1017
|
+
const archive = vi.fn(() => pendingArchive.promise);
|
|
1018
|
+
await render({
|
|
1019
|
+
query: "Plan",
|
|
1020
|
+
localMineRows,
|
|
1021
|
+
mutations: { rename: () => pendingRename.promise, archive },
|
|
1022
|
+
});
|
|
1023
|
+
let renameResult!: Promise<boolean>;
|
|
1024
|
+
let archiveResult!: Promise<boolean>;
|
|
1025
|
+
act(() => {
|
|
1026
|
+
renameResult = latest.listProps.onRename!("local", "Other");
|
|
1027
|
+
});
|
|
1028
|
+
expect(item("local")).toBeUndefined();
|
|
1029
|
+
expect(latest.findMine("local")?.id).toBe("local");
|
|
1030
|
+
act(() => {
|
|
1031
|
+
archiveResult = latest.listProps.onDelete!("local");
|
|
1032
|
+
});
|
|
1033
|
+
expect(archive).toHaveBeenCalledWith("local");
|
|
1034
|
+
await act(async () => {
|
|
1035
|
+
pendingRename.resolve(false);
|
|
1036
|
+
expect(await renameResult).toBe(false);
|
|
1037
|
+
});
|
|
1038
|
+
expect(item("local")).toBeUndefined();
|
|
1039
|
+
await act(async () => {
|
|
1040
|
+
pendingArchive.resolve(false);
|
|
1041
|
+
expect(await archiveResult).toBe(false);
|
|
1042
|
+
});
|
|
1043
|
+
expect(item("local")?.titleText).toBe("Plan");
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it("moves optimistic pins, retitles, and archives, then reconciles successful persistence", async () => {
|
|
1047
|
+
mine = [row("owned")];
|
|
1048
|
+
const pendingPin = deferred<boolean>();
|
|
1049
|
+
const pin = vi.fn(() => pendingPin.promise);
|
|
1050
|
+
const rename = vi.fn(async (_id, title) => {
|
|
1051
|
+
mine = [row("owned", { title, pinnedAt: 2 })];
|
|
1052
|
+
return true;
|
|
1053
|
+
});
|
|
1054
|
+
const archive = vi.fn(async () => {
|
|
1055
|
+
mine = [];
|
|
1056
|
+
return true;
|
|
1057
|
+
});
|
|
1058
|
+
await render({ pinned: true, mutations: { pin, rename, archive } });
|
|
1059
|
+
let pinResult!: Promise<boolean>;
|
|
1060
|
+
act(() => {
|
|
1061
|
+
pinResult = latest.listProps.onTogglePin!("owned");
|
|
1062
|
+
});
|
|
1063
|
+
expect(section("pinned").items[0].id).toBe("owned");
|
|
1064
|
+
expect(pin).toHaveBeenCalledWith("owned", true);
|
|
1065
|
+
mine = [row("owned", { pinnedAt: 2 })];
|
|
1066
|
+
await act(async () => {
|
|
1067
|
+
pendingPin.resolve(true);
|
|
1068
|
+
expect(await pinResult).toBe(true);
|
|
1069
|
+
});
|
|
1070
|
+
await act(async () => {
|
|
1071
|
+
expect(await latest.listProps.onRename!("owned", "New title")).toBe(true);
|
|
1072
|
+
});
|
|
1073
|
+
expect(item("owned")?.title).toBe("New title");
|
|
1074
|
+
await act(async () => {
|
|
1075
|
+
expect(await latest.listProps.onDelete!("owned")).toBe(true);
|
|
1076
|
+
});
|
|
1077
|
+
expect(item("owned")).toBeUndefined();
|
|
1078
|
+
expect(latest.mineRows).toEqual([]);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
it.each(["false", "throw"])(
|
|
1082
|
+
"rolls back %s mutations and reports failure",
|
|
1083
|
+
async (failure) => {
|
|
1084
|
+
mine = [row("owned")];
|
|
1085
|
+
const pending = deferred<boolean>();
|
|
1086
|
+
const onMutationFailed = vi.fn();
|
|
1087
|
+
const rename = vi.fn(() => pending.promise);
|
|
1088
|
+
await render({ mutations: { rename }, onMutationFailed });
|
|
1089
|
+
let result!: Promise<boolean>;
|
|
1090
|
+
act(() => {
|
|
1091
|
+
result = latest.listProps.onRename!("owned", "Optimistic");
|
|
1092
|
+
});
|
|
1093
|
+
expect(item("owned")?.title).toBe("Optimistic");
|
|
1094
|
+
const cause = new Error("Could not persist");
|
|
1095
|
+
await act(async () => {
|
|
1096
|
+
if (failure === "throw") pending.reject(cause);
|
|
1097
|
+
else pending.resolve(false);
|
|
1098
|
+
expect(await result).toBe(false);
|
|
1099
|
+
});
|
|
1100
|
+
expect(item("owned")?.title).toBe("owned");
|
|
1101
|
+
expect(onMutationFailed).toHaveBeenCalledWith(
|
|
1102
|
+
"rename",
|
|
1103
|
+
"owned",
|
|
1104
|
+
...(failure === "throw" ? [cause] : []),
|
|
1105
|
+
);
|
|
1106
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
1107
|
+
},
|
|
1108
|
+
);
|
|
1109
|
+
|
|
1110
|
+
it.each(
|
|
1111
|
+
(["pin", "rename"] as const).flatMap((olderKind) =>
|
|
1112
|
+
(["false", "throw"] as const).flatMap((failure) =>
|
|
1113
|
+
(["pending", "persisted-refresh-error"] as const).map((laterState) => ({
|
|
1114
|
+
olderKind,
|
|
1115
|
+
failure,
|
|
1116
|
+
laterState,
|
|
1117
|
+
})),
|
|
1118
|
+
),
|
|
1119
|
+
),
|
|
1120
|
+
)(
|
|
1121
|
+
"independent fields: older $olderKind $failure with later $laterState rolls back only the failed field",
|
|
1122
|
+
async ({ olderKind, failure, laterState }) => {
|
|
1123
|
+
mine = [row("owned", { title: "Original" })];
|
|
1124
|
+
const pin = deferred<boolean>();
|
|
1125
|
+
const rename = deferred<boolean>();
|
|
1126
|
+
const older = olderKind === "pin" ? pin : rename;
|
|
1127
|
+
const later = olderKind === "pin" ? rename : pin;
|
|
1128
|
+
const onMutationFailed = vi.fn();
|
|
1129
|
+
await render({
|
|
1130
|
+
pinned: true,
|
|
1131
|
+
mutations: { pin: () => pin.promise, rename: () => rename.promise },
|
|
1132
|
+
onMutationFailed,
|
|
1133
|
+
});
|
|
1134
|
+
const start = (kind: "pin" | "rename") =>
|
|
1135
|
+
kind === "pin"
|
|
1136
|
+
? latest.listProps.onTogglePin!("owned")
|
|
1137
|
+
: latest.listProps.onRename!("owned", "Renamed");
|
|
1138
|
+
let olderResult!: Promise<boolean>;
|
|
1139
|
+
let laterResult!: Promise<boolean>;
|
|
1140
|
+
act(() => {
|
|
1141
|
+
olderResult = start(olderKind);
|
|
1142
|
+
});
|
|
1143
|
+
act(() => {
|
|
1144
|
+
laterResult = start(olderKind === "pin" ? "rename" : "pin");
|
|
1145
|
+
});
|
|
1146
|
+
expect(item("owned")).toMatchObject({
|
|
1147
|
+
titleText: "Renamed",
|
|
1148
|
+
pinned: true,
|
|
1149
|
+
});
|
|
1150
|
+
const finishLater = async () => {
|
|
1151
|
+
mine = [
|
|
1152
|
+
row("owned", {
|
|
1153
|
+
title: olderKind === "pin" ? "Renamed" : "Original",
|
|
1154
|
+
pinnedAt: olderKind === "rename" ? 2 : null,
|
|
1155
|
+
}),
|
|
1156
|
+
];
|
|
1157
|
+
failMine = true;
|
|
1158
|
+
await act(async () => {
|
|
1159
|
+
later.resolve(true);
|
|
1160
|
+
expect(await laterResult).toBe(true);
|
|
1161
|
+
});
|
|
1162
|
+
expect(latest.error?.message).toContain("503");
|
|
1163
|
+
};
|
|
1164
|
+
if (laterState === "persisted-refresh-error") await finishLater();
|
|
1165
|
+
const requestsBeforeFailure = fetchMock.mock.calls.length;
|
|
1166
|
+
const cause = new Error("Older field failed");
|
|
1167
|
+
await act(async () => {
|
|
1168
|
+
if (failure === "throw") older.reject(cause);
|
|
1169
|
+
else older.resolve(false);
|
|
1170
|
+
expect(await olderResult).toBe(false);
|
|
1171
|
+
});
|
|
1172
|
+
expect(item("owned")).toMatchObject({
|
|
1173
|
+
titleText: olderKind === "pin" ? "Renamed" : "Original",
|
|
1174
|
+
pinned: olderKind === "rename",
|
|
1175
|
+
});
|
|
1176
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith(
|
|
1177
|
+
olderKind,
|
|
1178
|
+
"owned",
|
|
1179
|
+
...(failure === "throw" ? [cause] : []),
|
|
1180
|
+
);
|
|
1181
|
+
expect(fetchMock).toHaveBeenCalledTimes(requestsBeforeFailure);
|
|
1182
|
+
if (laterState === "pending") await finishLater();
|
|
1183
|
+
expect(item("owned")).toMatchObject({
|
|
1184
|
+
titleText: olderKind === "pin" ? "Renamed" : "Original",
|
|
1185
|
+
pinned: olderKind === "rename",
|
|
1186
|
+
});
|
|
1187
|
+
expect(onMutationFailed).toHaveBeenCalledTimes(1);
|
|
1188
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
1189
|
+
},
|
|
1190
|
+
);
|
|
1191
|
+
|
|
1192
|
+
it.each(["pin", "rename"] as const)(
|
|
1193
|
+
"independent fields: a later failure preserves the earlier pending %s intent",
|
|
1194
|
+
async (olderKind) => {
|
|
1195
|
+
mine = [row("owned", { title: "Original" })];
|
|
1196
|
+
const pin = deferred<boolean>();
|
|
1197
|
+
const rename = deferred<boolean>();
|
|
1198
|
+
const older = olderKind === "pin" ? pin : rename;
|
|
1199
|
+
const later = olderKind === "pin" ? rename : pin;
|
|
1200
|
+
const onMutationFailed = vi.fn();
|
|
1201
|
+
await render({
|
|
1202
|
+
pinned: true,
|
|
1203
|
+
mutations: { pin: () => pin.promise, rename: () => rename.promise },
|
|
1204
|
+
onMutationFailed,
|
|
1205
|
+
});
|
|
1206
|
+
const start = (kind: "pin" | "rename") =>
|
|
1207
|
+
kind === "pin"
|
|
1208
|
+
? latest.listProps.onTogglePin!("owned")
|
|
1209
|
+
: latest.listProps.onRename!("owned", "Renamed");
|
|
1210
|
+
let olderResult!: Promise<boolean>;
|
|
1211
|
+
let laterResult!: Promise<boolean>;
|
|
1212
|
+
act(() => {
|
|
1213
|
+
olderResult = start(olderKind);
|
|
1214
|
+
});
|
|
1215
|
+
act(() => {
|
|
1216
|
+
laterResult = start(olderKind === "pin" ? "rename" : "pin");
|
|
1217
|
+
});
|
|
1218
|
+
await act(async () => {
|
|
1219
|
+
later.resolve(false);
|
|
1220
|
+
expect(await laterResult).toBe(false);
|
|
1221
|
+
});
|
|
1222
|
+
expect(item("owned")).toMatchObject({
|
|
1223
|
+
titleText: olderKind === "rename" ? "Renamed" : "Original",
|
|
1224
|
+
pinned: olderKind === "pin",
|
|
1225
|
+
});
|
|
1226
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith(
|
|
1227
|
+
olderKind === "pin" ? "rename" : "pin",
|
|
1228
|
+
"owned",
|
|
1229
|
+
);
|
|
1230
|
+
mine = [
|
|
1231
|
+
row("owned", {
|
|
1232
|
+
title: olderKind === "rename" ? "Renamed" : "Original",
|
|
1233
|
+
pinnedAt: olderKind === "pin" ? 2 : null,
|
|
1234
|
+
}),
|
|
1235
|
+
];
|
|
1236
|
+
await act(async () => {
|
|
1237
|
+
older.resolve(true);
|
|
1238
|
+
expect(await olderResult).toBe(true);
|
|
1239
|
+
});
|
|
1240
|
+
expect(item("owned")).toMatchObject({
|
|
1241
|
+
titleText: olderKind === "rename" ? "Renamed" : "Original",
|
|
1242
|
+
pinned: olderKind === "pin",
|
|
1243
|
+
});
|
|
1244
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
1245
|
+
},
|
|
1246
|
+
);
|
|
1247
|
+
|
|
1248
|
+
it.each(["pin", "rename"] as const)(
|
|
1249
|
+
"independent fields: an authoritative %s refresh keeps the other field pending",
|
|
1250
|
+
async (olderKind) => {
|
|
1251
|
+
mine = [row("owned", { title: "Original" })];
|
|
1252
|
+
const pin = deferred<boolean>();
|
|
1253
|
+
const rename = deferred<boolean>();
|
|
1254
|
+
const older = olderKind === "pin" ? pin : rename;
|
|
1255
|
+
const later = olderKind === "pin" ? rename : pin;
|
|
1256
|
+
const onMutationFailed = vi.fn();
|
|
1257
|
+
await render({
|
|
1258
|
+
pinned: true,
|
|
1259
|
+
mutations: { pin: () => pin.promise, rename: () => rename.promise },
|
|
1260
|
+
onMutationFailed,
|
|
1261
|
+
});
|
|
1262
|
+
const start = (kind: "pin" | "rename") =>
|
|
1263
|
+
kind === "pin"
|
|
1264
|
+
? latest.listProps.onTogglePin!("owned")
|
|
1265
|
+
: latest.listProps.onRename!("owned", "Renamed");
|
|
1266
|
+
let olderResult!: Promise<boolean>;
|
|
1267
|
+
let laterResult!: Promise<boolean>;
|
|
1268
|
+
act(() => {
|
|
1269
|
+
olderResult = start(olderKind);
|
|
1270
|
+
});
|
|
1271
|
+
act(() => {
|
|
1272
|
+
laterResult = start(olderKind === "pin" ? "rename" : "pin");
|
|
1273
|
+
});
|
|
1274
|
+
mine = [
|
|
1275
|
+
row("owned", {
|
|
1276
|
+
title: olderKind === "rename" ? "Renamed" : "Original",
|
|
1277
|
+
pinnedAt: olderKind === "pin" ? 2 : null,
|
|
1278
|
+
}),
|
|
1279
|
+
];
|
|
1280
|
+
await act(async () => {
|
|
1281
|
+
older.resolve(true);
|
|
1282
|
+
expect(await olderResult).toBe(true);
|
|
1283
|
+
});
|
|
1284
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
1285
|
+
expect(item("owned")).toMatchObject({
|
|
1286
|
+
titleText: "Renamed",
|
|
1287
|
+
pinned: true,
|
|
1288
|
+
});
|
|
1289
|
+
expect(latest.mineRows[0]).toMatchObject({
|
|
1290
|
+
title: olderKind === "rename" ? "Renamed" : "Original",
|
|
1291
|
+
pinnedAt: olderKind === "pin" ? 2 : null,
|
|
1292
|
+
});
|
|
1293
|
+
await act(async () => {
|
|
1294
|
+
later.resolve(false);
|
|
1295
|
+
expect(await laterResult).toBe(false);
|
|
1296
|
+
});
|
|
1297
|
+
expect(item("owned")).toMatchObject({
|
|
1298
|
+
titleText: olderKind === "rename" ? "Renamed" : "Original",
|
|
1299
|
+
pinned: olderKind === "pin",
|
|
1300
|
+
});
|
|
1301
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith(
|
|
1302
|
+
olderKind === "pin" ? "rename" : "pin",
|
|
1303
|
+
"owned",
|
|
1304
|
+
);
|
|
1305
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
1306
|
+
},
|
|
1307
|
+
);
|
|
1308
|
+
|
|
1309
|
+
it("independent fields: a failed archive restores the other pending rename", async () => {
|
|
1310
|
+
mine = [row("owned", { title: "Original" })];
|
|
1311
|
+
const archive = deferred<boolean>();
|
|
1312
|
+
const rename = deferred<boolean>();
|
|
1313
|
+
const onMutationFailed = vi.fn();
|
|
1314
|
+
await render({
|
|
1315
|
+
mutations: {
|
|
1316
|
+
archive: () => archive.promise,
|
|
1317
|
+
rename: () => rename.promise,
|
|
1318
|
+
},
|
|
1319
|
+
onMutationFailed,
|
|
1320
|
+
});
|
|
1321
|
+
let archiveResult!: Promise<boolean>;
|
|
1322
|
+
let renameResult!: Promise<boolean>;
|
|
1323
|
+
act(() => {
|
|
1324
|
+
archiveResult = latest.listProps.onDelete!("owned");
|
|
1325
|
+
});
|
|
1326
|
+
act(() => {
|
|
1327
|
+
renameResult = latest.listProps.onRename!("owned", "Renamed");
|
|
1328
|
+
});
|
|
1329
|
+
await act(async () => {
|
|
1330
|
+
archive.resolve(false);
|
|
1331
|
+
expect(await archiveResult).toBe(false);
|
|
1332
|
+
});
|
|
1333
|
+
expect(item("owned")?.titleText).toBe("Renamed");
|
|
1334
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith(
|
|
1335
|
+
"archive",
|
|
1336
|
+
"owned",
|
|
1337
|
+
);
|
|
1338
|
+
await act(async () => {
|
|
1339
|
+
rename.resolve(false);
|
|
1340
|
+
expect(await renameResult).toBe(false);
|
|
1341
|
+
});
|
|
1342
|
+
expect(item("owned")?.titleText).toBe("Original");
|
|
1343
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
it("independent fields: a successful rename refresh does not clear a pending archive", async () => {
|
|
1347
|
+
mine = [row("owned", { title: "Original" })];
|
|
1348
|
+
const archive = deferred<boolean>();
|
|
1349
|
+
const rename = deferred<boolean>();
|
|
1350
|
+
const onMutationFailed = vi.fn();
|
|
1351
|
+
await render({
|
|
1352
|
+
mutations: {
|
|
1353
|
+
archive: () => archive.promise,
|
|
1354
|
+
rename: () => rename.promise,
|
|
1355
|
+
},
|
|
1356
|
+
onMutationFailed,
|
|
1357
|
+
});
|
|
1358
|
+
let archiveResult!: Promise<boolean>;
|
|
1359
|
+
let renameResult!: Promise<boolean>;
|
|
1360
|
+
act(() => {
|
|
1361
|
+
archiveResult = latest.listProps.onDelete!("owned");
|
|
1362
|
+
});
|
|
1363
|
+
act(() => {
|
|
1364
|
+
renameResult = latest.listProps.onRename!("owned", "Renamed");
|
|
1365
|
+
});
|
|
1366
|
+
mine = [row("owned", { title: "Renamed" })];
|
|
1367
|
+
await act(async () => {
|
|
1368
|
+
rename.resolve(true);
|
|
1369
|
+
expect(await renameResult).toBe(true);
|
|
1370
|
+
});
|
|
1371
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
1372
|
+
expect(item("owned")).toBeUndefined();
|
|
1373
|
+
await act(async () => {
|
|
1374
|
+
archive.resolve(false);
|
|
1375
|
+
expect(await archiveResult).toBe(false);
|
|
1376
|
+
});
|
|
1377
|
+
expect(item("owned")?.titleText).toBe("Renamed");
|
|
1378
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith(
|
|
1379
|
+
"archive",
|
|
1380
|
+
"owned",
|
|
1381
|
+
);
|
|
1382
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
it.each(
|
|
1386
|
+
(["rename", "pin", "archive"] as const).flatMap((kind) =>
|
|
1387
|
+
(["false", "throw"] as const).map((failure) => ({ kind, failure })),
|
|
1388
|
+
),
|
|
1389
|
+
)(
|
|
1390
|
+
"persisted baseline: failed $kind retry ($failure) restores the prior successful value after refresh error",
|
|
1391
|
+
async ({ kind, failure }) => {
|
|
1392
|
+
mine = [row("owned", { title: "Original", pinnedAt: null })];
|
|
1393
|
+
const retry = deferred<boolean>();
|
|
1394
|
+
const mutation = vi
|
|
1395
|
+
.fn()
|
|
1396
|
+
.mockImplementationOnce(async () => {
|
|
1397
|
+
mine =
|
|
1398
|
+
kind === "archive"
|
|
1399
|
+
? []
|
|
1400
|
+
: [
|
|
1401
|
+
row("owned", {
|
|
1402
|
+
title: kind === "rename" ? "Saved" : "Original",
|
|
1403
|
+
pinnedAt: kind === "pin" ? 2 : null,
|
|
1404
|
+
}),
|
|
1405
|
+
];
|
|
1406
|
+
return true;
|
|
1407
|
+
})
|
|
1408
|
+
.mockReturnValueOnce(retry.promise);
|
|
1409
|
+
const onMutationFailed = vi.fn();
|
|
1410
|
+
await render({
|
|
1411
|
+
pinned: true,
|
|
1412
|
+
mutations: { rename: mutation, pin: mutation, archive: mutation },
|
|
1413
|
+
onMutationFailed,
|
|
1414
|
+
});
|
|
1415
|
+
const start = (title: string) =>
|
|
1416
|
+
kind === "rename"
|
|
1417
|
+
? latest.listProps.onRename!("owned", title)
|
|
1418
|
+
: kind === "pin"
|
|
1419
|
+
? latest.listProps.onTogglePin!("owned")
|
|
1420
|
+
: latest.listProps.onDelete!("owned");
|
|
1421
|
+
failMine = true;
|
|
1422
|
+
await act(async () => {
|
|
1423
|
+
expect(await start("Saved")).toBe(true);
|
|
1424
|
+
});
|
|
1425
|
+
expect(latest.error?.message).toContain("503");
|
|
1426
|
+
expect(latest.mineRows[0]).toMatchObject({
|
|
1427
|
+
title: "Original",
|
|
1428
|
+
pinnedAt: null,
|
|
1429
|
+
});
|
|
1430
|
+
let result!: Promise<boolean>;
|
|
1431
|
+
act(() => {
|
|
1432
|
+
result = start("Retry");
|
|
1433
|
+
});
|
|
1434
|
+
const cause = new Error("Retry failed");
|
|
1435
|
+
await act(async () => {
|
|
1436
|
+
if (failure === "throw") retry.reject(cause);
|
|
1437
|
+
else retry.resolve(false);
|
|
1438
|
+
expect(await result).toBe(false);
|
|
1439
|
+
});
|
|
1440
|
+
if (kind === "archive") {
|
|
1441
|
+
expect(item("owned")).toBeUndefined();
|
|
1442
|
+
expect(mine).toEqual([]);
|
|
1443
|
+
} else {
|
|
1444
|
+
expect(item("owned")).toMatchObject({
|
|
1445
|
+
titleText: kind === "rename" ? "Saved" : "Original",
|
|
1446
|
+
pinned: kind === "pin",
|
|
1447
|
+
});
|
|
1448
|
+
expect(mine[0]).toMatchObject({
|
|
1449
|
+
title: kind === "rename" ? "Saved" : "Original",
|
|
1450
|
+
pinnedAt: kind === "pin" ? 2 : null,
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith(
|
|
1454
|
+
kind,
|
|
1455
|
+
"owned",
|
|
1456
|
+
...(failure === "throw" ? [cause] : []),
|
|
1457
|
+
);
|
|
1458
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
1459
|
+
failMine = false;
|
|
1460
|
+
await act(async () => {
|
|
1461
|
+
await latest.refresh();
|
|
1462
|
+
});
|
|
1463
|
+
expect(latest.mineRows).toEqual(mine);
|
|
1464
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
1465
|
+
},
|
|
1466
|
+
);
|
|
1467
|
+
|
|
1468
|
+
it.each(
|
|
1469
|
+
(["rename", "pin"] as const).flatMap((kind) =>
|
|
1470
|
+
(["older-success-first", "newer-failure-first"] as const).map(
|
|
1471
|
+
(completion) => ({ kind, completion }),
|
|
1472
|
+
),
|
|
1473
|
+
),
|
|
1474
|
+
)(
|
|
1475
|
+
"persisted baseline: $kind $completion retains the successful older write through refresh error and Retry",
|
|
1476
|
+
async ({ kind, completion }) => {
|
|
1477
|
+
mine = [row("owned", { title: "Original", pinnedAt: null })];
|
|
1478
|
+
const older = deferred<boolean>();
|
|
1479
|
+
const newer = deferred<boolean>();
|
|
1480
|
+
const mutation = vi
|
|
1481
|
+
.fn()
|
|
1482
|
+
.mockReturnValueOnce(older.promise)
|
|
1483
|
+
.mockReturnValueOnce(newer.promise);
|
|
1484
|
+
const onMutationFailed = vi.fn();
|
|
1485
|
+
await render({
|
|
1486
|
+
pinned: true,
|
|
1487
|
+
mutations: { rename: mutation, pin: mutation },
|
|
1488
|
+
onMutationFailed,
|
|
1489
|
+
});
|
|
1490
|
+
const start = (title: string) =>
|
|
1491
|
+
kind === "rename"
|
|
1492
|
+
? latest.listProps.onRename!("owned", title)
|
|
1493
|
+
: latest.listProps.onTogglePin!("owned");
|
|
1494
|
+
let oldResult!: Promise<boolean>;
|
|
1495
|
+
let newResult!: Promise<boolean>;
|
|
1496
|
+
act(() => {
|
|
1497
|
+
oldResult = start("First");
|
|
1498
|
+
});
|
|
1499
|
+
act(() => {
|
|
1500
|
+
newResult = start("Second");
|
|
1501
|
+
});
|
|
1502
|
+
const cause = new Error("Newer failed");
|
|
1503
|
+
const failNewer = async () => {
|
|
1504
|
+
const requests = fetchMock.mock.calls.length;
|
|
1505
|
+
await act(async () => {
|
|
1506
|
+
if (kind === "pin") newer.reject(cause);
|
|
1507
|
+
else newer.resolve(false);
|
|
1508
|
+
expect(await newResult).toBe(false);
|
|
1509
|
+
});
|
|
1510
|
+
expect(fetchMock).toHaveBeenCalledTimes(requests);
|
|
1511
|
+
};
|
|
1512
|
+
if (completion === "newer-failure-first") newer.resolve(false);
|
|
1513
|
+
mine = [
|
|
1514
|
+
row("owned", {
|
|
1515
|
+
title: kind === "rename" ? "First" : "Original",
|
|
1516
|
+
pinnedAt: kind === "pin" ? 2 : null,
|
|
1517
|
+
}),
|
|
1518
|
+
];
|
|
1519
|
+
failMine = true;
|
|
1520
|
+
await act(async () => {
|
|
1521
|
+
older.resolve(true);
|
|
1522
|
+
expect(await oldResult).toBe(true);
|
|
1523
|
+
});
|
|
1524
|
+
expect(latest.error?.message).toContain("503");
|
|
1525
|
+
if (completion === "older-success-first") {
|
|
1526
|
+
expect(item("owned")).toMatchObject({
|
|
1527
|
+
titleText: kind === "rename" ? "Second" : "Original",
|
|
1528
|
+
pinned: false,
|
|
1529
|
+
});
|
|
1530
|
+
await failNewer();
|
|
1531
|
+
} else {
|
|
1532
|
+
expect(await newResult).toBe(false);
|
|
1533
|
+
}
|
|
1534
|
+
expect(item("owned")).toMatchObject({
|
|
1535
|
+
titleText: kind === "rename" ? "First" : "Original",
|
|
1536
|
+
pinned: kind === "pin",
|
|
1537
|
+
});
|
|
1538
|
+
expect(mine[0]).toMatchObject({
|
|
1539
|
+
title: kind === "rename" ? "First" : "Original",
|
|
1540
|
+
pinnedAt: kind === "pin" ? 2 : null,
|
|
1541
|
+
});
|
|
1542
|
+
expect(latest.mineRows[0]).toMatchObject({
|
|
1543
|
+
title: "Original",
|
|
1544
|
+
pinnedAt: null,
|
|
1545
|
+
});
|
|
1546
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith(
|
|
1547
|
+
kind,
|
|
1548
|
+
"owned",
|
|
1549
|
+
...(kind === "pin" && completion === "older-success-first"
|
|
1550
|
+
? [cause]
|
|
1551
|
+
: []),
|
|
1552
|
+
);
|
|
1553
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
1554
|
+
failMine = false;
|
|
1555
|
+
await act(async () => {
|
|
1556
|
+
await latest.refresh();
|
|
1557
|
+
});
|
|
1558
|
+
expect(latest.mineRows).toEqual(mine);
|
|
1559
|
+
mine = [row("owned", { title: "External", pinnedAt: null })];
|
|
1560
|
+
await act(async () => {
|
|
1561
|
+
await latest.refresh();
|
|
1562
|
+
});
|
|
1563
|
+
expect(item("owned")).toMatchObject({
|
|
1564
|
+
titleText: "External",
|
|
1565
|
+
pinned: false,
|
|
1566
|
+
});
|
|
1567
|
+
expect(fetchMock).toHaveBeenCalledTimes(8);
|
|
1568
|
+
},
|
|
1569
|
+
);
|
|
1570
|
+
|
|
1571
|
+
it.each(
|
|
1572
|
+
(["rename", "pin"] as const).flatMap((kind) =>
|
|
1573
|
+
(["pending", "confirmed"] as const).map((newerState) => ({
|
|
1574
|
+
kind,
|
|
1575
|
+
newerState,
|
|
1576
|
+
})),
|
|
1577
|
+
),
|
|
1578
|
+
)(
|
|
1579
|
+
"persisted baseline: older $kind success cannot erase a newer $newerState intent",
|
|
1580
|
+
async ({ kind, newerState }) => {
|
|
1581
|
+
mine = [row("owned", { title: "Original", pinnedAt: null })];
|
|
1582
|
+
const older = deferred<boolean>();
|
|
1583
|
+
const newer = deferred<boolean>();
|
|
1584
|
+
const mutation = vi
|
|
1585
|
+
.fn()
|
|
1586
|
+
.mockReturnValueOnce(older.promise)
|
|
1587
|
+
.mockReturnValueOnce(newer.promise);
|
|
1588
|
+
const onMutationFailed = vi.fn();
|
|
1589
|
+
await render({
|
|
1590
|
+
pinned: true,
|
|
1591
|
+
mutations: { rename: mutation, pin: mutation },
|
|
1592
|
+
onMutationFailed,
|
|
1593
|
+
});
|
|
1594
|
+
const start = (title: string) =>
|
|
1595
|
+
kind === "rename"
|
|
1596
|
+
? latest.listProps.onRename!("owned", title)
|
|
1597
|
+
: latest.listProps.onTogglePin!("owned");
|
|
1598
|
+
let oldResult!: Promise<boolean>;
|
|
1599
|
+
let newResult!: Promise<boolean>;
|
|
1600
|
+
act(() => {
|
|
1601
|
+
oldResult = start("First");
|
|
1602
|
+
});
|
|
1603
|
+
act(() => {
|
|
1604
|
+
newResult = start("Second");
|
|
1605
|
+
});
|
|
1606
|
+
const oldMineProof = deferred<Response>();
|
|
1607
|
+
const oldSharedProof = deferred<Response>();
|
|
1608
|
+
fetchMock
|
|
1609
|
+
.mockImplementationOnce(() => oldMineProof.promise)
|
|
1610
|
+
.mockImplementationOnce(() => oldSharedProof.promise);
|
|
1611
|
+
await act(async () => {
|
|
1612
|
+
older.resolve(true);
|
|
1613
|
+
});
|
|
1614
|
+
expect(mutation).toHaveBeenCalledTimes(2);
|
|
1615
|
+
failMine = true;
|
|
1616
|
+
if (newerState === "confirmed") {
|
|
1617
|
+
mine = [
|
|
1618
|
+
row("owned", {
|
|
1619
|
+
title: kind === "rename" ? "Second" : "Original",
|
|
1620
|
+
pinnedAt: null,
|
|
1621
|
+
}),
|
|
1622
|
+
];
|
|
1623
|
+
await act(async () => {
|
|
1624
|
+
newer.resolve(true);
|
|
1625
|
+
expect(await newResult).toBe(true);
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
await act(async () => {
|
|
1629
|
+
oldMineProof.resolve(
|
|
1630
|
+
Response.json({
|
|
1631
|
+
threads: [
|
|
1632
|
+
row("owned", {
|
|
1633
|
+
title: "Original",
|
|
1634
|
+
pinnedAt: kind === "pin" ? 2 : null,
|
|
1635
|
+
}),
|
|
1636
|
+
],
|
|
1637
|
+
hasMore: false,
|
|
1638
|
+
}),
|
|
1639
|
+
);
|
|
1640
|
+
oldSharedProof.resolve(Response.json({ threads: [], hasMore: false }));
|
|
1641
|
+
expect(await oldResult).toBe(true);
|
|
1642
|
+
});
|
|
1643
|
+
expect(item("owned")).toMatchObject({
|
|
1644
|
+
titleText: kind === "rename" ? "Second" : "Original",
|
|
1645
|
+
pinned: false,
|
|
1646
|
+
});
|
|
1647
|
+
expect(fetchMock).toHaveBeenCalledTimes(
|
|
1648
|
+
newerState === "confirmed" ? 6 : 4,
|
|
1649
|
+
);
|
|
1650
|
+
if (newerState === "pending") {
|
|
1651
|
+
mine = [
|
|
1652
|
+
row("owned", {
|
|
1653
|
+
title: kind === "rename" ? "Second" : "Original",
|
|
1654
|
+
pinnedAt: null,
|
|
1655
|
+
}),
|
|
1656
|
+
];
|
|
1657
|
+
await act(async () => {
|
|
1658
|
+
newer.resolve(true);
|
|
1659
|
+
expect(await newResult).toBe(true);
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
expect(item("owned")).toMatchObject({
|
|
1663
|
+
titleText: kind === "rename" ? "Second" : "Original",
|
|
1664
|
+
pinned: false,
|
|
1665
|
+
});
|
|
1666
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
1667
|
+
failMine = false;
|
|
1668
|
+
await act(async () => {
|
|
1669
|
+
await latest.refresh();
|
|
1670
|
+
});
|
|
1671
|
+
expect(latest.mineRows).toEqual(mine);
|
|
1672
|
+
expect(item("owned")).toMatchObject({
|
|
1673
|
+
titleText: mine[0].title,
|
|
1674
|
+
pinned: mine[0].pinnedAt != null,
|
|
1675
|
+
});
|
|
1676
|
+
expect(fetchMock).toHaveBeenCalledTimes(8);
|
|
1677
|
+
},
|
|
1678
|
+
);
|
|
1679
|
+
|
|
1680
|
+
it("persisted baseline: a captured older refresh cannot clear a newer confirmed fallback", async () => {
|
|
1681
|
+
mine = [row("owned", { title: "Original" })];
|
|
1682
|
+
const first = deferred<boolean>();
|
|
1683
|
+
const second = deferred<boolean>();
|
|
1684
|
+
const third = deferred<boolean>();
|
|
1685
|
+
const rename = vi
|
|
1686
|
+
.fn()
|
|
1687
|
+
.mockReturnValueOnce(first.promise)
|
|
1688
|
+
.mockReturnValueOnce(second.promise)
|
|
1689
|
+
.mockReturnValueOnce(third.promise);
|
|
1690
|
+
const onMutationFailed = vi.fn();
|
|
1691
|
+
await render({ mutations: { rename }, onMutationFailed });
|
|
1692
|
+
const firstMineRefresh = deferred<Response>();
|
|
1693
|
+
const firstSharedRefresh = deferred<Response>();
|
|
1694
|
+
fetchMock
|
|
1695
|
+
.mockImplementationOnce(() => firstMineRefresh.promise)
|
|
1696
|
+
.mockImplementationOnce(() => firstSharedRefresh.promise);
|
|
1697
|
+
let firstResult!: Promise<boolean>;
|
|
1698
|
+
let secondResult!: Promise<boolean>;
|
|
1699
|
+
let thirdResult!: Promise<boolean>;
|
|
1700
|
+
act(() => {
|
|
1701
|
+
firstResult = latest.listProps.onRename!("owned", "First");
|
|
1702
|
+
});
|
|
1703
|
+
await act(async () => {
|
|
1704
|
+
first.resolve(true);
|
|
1705
|
+
});
|
|
1706
|
+
act(() => {
|
|
1707
|
+
secondResult = latest.listProps.onRename!("owned", "Second");
|
|
1708
|
+
});
|
|
1709
|
+
mine = [row("owned", { title: "Second" })];
|
|
1710
|
+
failMine = true;
|
|
1711
|
+
await act(async () => {
|
|
1712
|
+
second.resolve(true);
|
|
1713
|
+
expect(await secondResult).toBe(true);
|
|
1714
|
+
});
|
|
1715
|
+
await act(async () => {
|
|
1716
|
+
firstMineRefresh.resolve(
|
|
1717
|
+
Response.json({
|
|
1718
|
+
threads: [row("owned", { title: "First" })],
|
|
1719
|
+
hasMore: false,
|
|
1720
|
+
}),
|
|
1721
|
+
);
|
|
1722
|
+
firstSharedRefresh.resolve(
|
|
1723
|
+
Response.json({ threads: [], hasMore: false }),
|
|
1724
|
+
);
|
|
1725
|
+
expect(await firstResult).toBe(true);
|
|
1726
|
+
});
|
|
1727
|
+
expect(item("owned")?.titleText).toBe("Second");
|
|
1728
|
+
act(() => {
|
|
1729
|
+
thirdResult = latest.listProps.onRename!("owned", "Third");
|
|
1730
|
+
});
|
|
1731
|
+
await act(async () => {
|
|
1732
|
+
third.resolve(false);
|
|
1733
|
+
expect(await thirdResult).toBe(false);
|
|
1734
|
+
});
|
|
1735
|
+
expect(item("owned")?.titleText).toBe("Second");
|
|
1736
|
+
expect(latest.mineRows[0].title).toBe("Original");
|
|
1737
|
+
expect(mine[0].title).toBe("Second");
|
|
1738
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith("rename", "owned");
|
|
1739
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
1740
|
+
failMine = false;
|
|
1741
|
+
await act(async () => {
|
|
1742
|
+
await latest.refresh();
|
|
1743
|
+
});
|
|
1744
|
+
expect(latest.mineRows).toEqual(mine);
|
|
1745
|
+
mine = [row("owned", { title: "External" })];
|
|
1746
|
+
await act(async () => {
|
|
1747
|
+
await latest.refresh();
|
|
1748
|
+
});
|
|
1749
|
+
expect(item("owned")?.titleText).toBe("External");
|
|
1750
|
+
expect(fetchMock).toHaveBeenCalledTimes(10);
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
it.each(
|
|
1754
|
+
(["pin", "rename", "archive"] as const).flatMap((kind) =>
|
|
1755
|
+
(["false", "throw"] as const).map((failure) => ({ kind, failure })),
|
|
1756
|
+
),
|
|
1757
|
+
)(
|
|
1758
|
+
"Activity lifetime: $kind $failure while hidden settles without an orphan or callback",
|
|
1759
|
+
async ({ kind, failure }) => {
|
|
1760
|
+
renderRail = true;
|
|
1761
|
+
mine = [row("owned", { title: "Original", pinnedAt: null })];
|
|
1762
|
+
const pending = deferred<boolean>();
|
|
1763
|
+
const onMutationFailed = vi.fn();
|
|
1764
|
+
const navigate = vi.fn();
|
|
1765
|
+
const consoleError = vi.spyOn(console, "error");
|
|
1766
|
+
const options = {
|
|
1767
|
+
pinned: true,
|
|
1768
|
+
mutations: {
|
|
1769
|
+
pin: () => pending.promise,
|
|
1770
|
+
rename: () => pending.promise,
|
|
1771
|
+
archive: () => pending.promise,
|
|
1772
|
+
},
|
|
1773
|
+
onMutationFailed,
|
|
1774
|
+
};
|
|
1775
|
+
await renderActivity(options, "visible");
|
|
1776
|
+
let result!: Promise<boolean>;
|
|
1777
|
+
act(() => {
|
|
1778
|
+
const mutation =
|
|
1779
|
+
kind === "pin"
|
|
1780
|
+
? latest.listProps.onTogglePin!("owned")
|
|
1781
|
+
: kind === "rename"
|
|
1782
|
+
? latest.listProps.onRename!("owned", "Optimistic")
|
|
1783
|
+
: latest.listProps.onDelete!("owned");
|
|
1784
|
+
result = mutation.then((succeeded) => {
|
|
1785
|
+
if (succeeded) navigate();
|
|
1786
|
+
return succeeded;
|
|
1787
|
+
});
|
|
1788
|
+
});
|
|
1789
|
+
await renderActivity(options, "hidden");
|
|
1790
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
1791
|
+
await act(async () => {
|
|
1792
|
+
if (failure === "throw") pending.reject(new Error("Hidden failure"));
|
|
1793
|
+
else pending.resolve(false);
|
|
1794
|
+
expect(await result).toBe(false);
|
|
1795
|
+
window.dispatchEvent(new Event("focus"));
|
|
1796
|
+
window.dispatchEvent(
|
|
1797
|
+
new CustomEvent("agentNative.chatRunning", {
|
|
1798
|
+
detail: { isRunning: false },
|
|
1799
|
+
}),
|
|
1800
|
+
);
|
|
1801
|
+
});
|
|
1802
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
1803
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
1804
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
1805
|
+
await renderActivity(options, "visible");
|
|
1806
|
+
expect(item("owned")).toMatchObject({
|
|
1807
|
+
titleText: "Original",
|
|
1808
|
+
pinned: false,
|
|
1809
|
+
});
|
|
1810
|
+
expect(latest.mineRows).toEqual(mine);
|
|
1811
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
1812
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
1813
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
1814
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
1815
|
+
},
|
|
1816
|
+
);
|
|
1817
|
+
|
|
1818
|
+
it.each(["pin", "rename", "archive"] as const)(
|
|
1819
|
+
"Activity lifetime: hidden successful %s survives reactivation refresh error and failed retry",
|
|
1820
|
+
async (kind) => {
|
|
1821
|
+
renderRail = true;
|
|
1822
|
+
mine = [row("owned", { title: "Original", pinnedAt: null })];
|
|
1823
|
+
const pending = deferred<boolean>();
|
|
1824
|
+
const retry = deferred<boolean>();
|
|
1825
|
+
const mutation = vi
|
|
1826
|
+
.fn()
|
|
1827
|
+
.mockReturnValueOnce(pending.promise)
|
|
1828
|
+
.mockReturnValueOnce(retry.promise);
|
|
1829
|
+
const onMutationFailed = vi.fn();
|
|
1830
|
+
const navigate = vi.fn();
|
|
1831
|
+
const consoleError = vi.spyOn(console, "error");
|
|
1832
|
+
const options = {
|
|
1833
|
+
pinned: true,
|
|
1834
|
+
mutations: { pin: mutation, rename: mutation, archive: mutation },
|
|
1835
|
+
onMutationFailed,
|
|
1836
|
+
};
|
|
1837
|
+
await renderActivity(options, "visible");
|
|
1838
|
+
const start = (title: string) =>
|
|
1839
|
+
kind === "pin"
|
|
1840
|
+
? latest.listProps.onTogglePin!("owned")
|
|
1841
|
+
: kind === "rename"
|
|
1842
|
+
? latest.listProps.onRename!("owned", title)
|
|
1843
|
+
: latest.listProps.onDelete!("owned");
|
|
1844
|
+
let result!: Promise<boolean>;
|
|
1845
|
+
act(() => {
|
|
1846
|
+
result = start("Saved").then((succeeded) => {
|
|
1847
|
+
if (succeeded) navigate();
|
|
1848
|
+
return succeeded;
|
|
1849
|
+
});
|
|
1850
|
+
});
|
|
1851
|
+
await renderActivity(options, "hidden");
|
|
1852
|
+
mine =
|
|
1853
|
+
kind === "archive"
|
|
1854
|
+
? []
|
|
1855
|
+
: [
|
|
1856
|
+
row("owned", {
|
|
1857
|
+
title: kind === "rename" ? "Saved" : "Original",
|
|
1858
|
+
pinnedAt: kind === "pin" ? 2 : null,
|
|
1859
|
+
}),
|
|
1860
|
+
];
|
|
1861
|
+
failMine = true;
|
|
1862
|
+
await act(async () => {
|
|
1863
|
+
pending.resolve(true);
|
|
1864
|
+
expect(await result).toBe(false);
|
|
1865
|
+
});
|
|
1866
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
1867
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
1868
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
1869
|
+
await renderActivity(options, "visible");
|
|
1870
|
+
expect(latest.error?.message).toContain("503");
|
|
1871
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
1872
|
+
let retryResult!: Promise<boolean>;
|
|
1873
|
+
act(() => {
|
|
1874
|
+
retryResult = start("Retry");
|
|
1875
|
+
});
|
|
1876
|
+
await act(async () => {
|
|
1877
|
+
retry.resolve(false);
|
|
1878
|
+
expect(await retryResult).toBe(false);
|
|
1879
|
+
});
|
|
1880
|
+
if (kind === "archive") expect(item("owned")).toBeUndefined();
|
|
1881
|
+
else
|
|
1882
|
+
expect(item("owned")).toMatchObject({
|
|
1883
|
+
titleText: kind === "rename" ? "Saved" : "Original",
|
|
1884
|
+
pinned: kind === "pin",
|
|
1885
|
+
});
|
|
1886
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith(kind, "owned");
|
|
1887
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
1888
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
1889
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
1890
|
+
failMine = false;
|
|
1891
|
+
await act(async () => {
|
|
1892
|
+
await latest.refresh();
|
|
1893
|
+
});
|
|
1894
|
+
expect(latest.mineRows).toEqual(mine);
|
|
1895
|
+
mine = [row("owned", { title: "External", pinnedAt: null })];
|
|
1896
|
+
await act(async () => {
|
|
1897
|
+
await latest.refresh();
|
|
1898
|
+
});
|
|
1899
|
+
expect(item("owned")).toMatchObject({
|
|
1900
|
+
titleText: "External",
|
|
1901
|
+
pinned: false,
|
|
1902
|
+
});
|
|
1903
|
+
expect(fetchMock).toHaveBeenCalledTimes(10);
|
|
1904
|
+
},
|
|
1905
|
+
);
|
|
1906
|
+
|
|
1907
|
+
it("Activity lifetime: hidden old-scope completion cannot publish into the new scope", async () => {
|
|
1908
|
+
mine = [row("owned")];
|
|
1909
|
+
const pending = deferred<boolean>();
|
|
1910
|
+
const onMutationFailed = vi.fn();
|
|
1911
|
+
const consoleError = vi.spyOn(console, "error");
|
|
1912
|
+
const options = {
|
|
1913
|
+
scope: { type: "project", id: "old" },
|
|
1914
|
+
mutations: { rename: () => pending.promise },
|
|
1915
|
+
onMutationFailed,
|
|
1916
|
+
};
|
|
1917
|
+
await renderActivity(options, "visible");
|
|
1918
|
+
let result!: Promise<boolean>;
|
|
1919
|
+
act(() => {
|
|
1920
|
+
result = latest.listProps.onRename!("owned", "Old title");
|
|
1921
|
+
});
|
|
1922
|
+
await renderActivity(options, "hidden");
|
|
1923
|
+
const newOptions = {
|
|
1924
|
+
scope: { type: "project", id: "new" },
|
|
1925
|
+
onMutationFailed,
|
|
1926
|
+
};
|
|
1927
|
+
await renderActivity(newOptions, "hidden");
|
|
1928
|
+
await act(async () => {
|
|
1929
|
+
pending.resolve(true);
|
|
1930
|
+
expect(await result).toBe(false);
|
|
1931
|
+
});
|
|
1932
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
1933
|
+
mine = [row("current")];
|
|
1934
|
+
await renderActivity(newOptions, "visible");
|
|
1935
|
+
expect(latest.mineRows.map((row) => row.id)).toEqual(["current"]);
|
|
1936
|
+
expect(item("owned")).toBeUndefined();
|
|
1937
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
1938
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
1939
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
it("Activity lifetime: an old captured refresh cannot regain navigation permission or clear a new intent", async () => {
|
|
1943
|
+
mine = [row("owned", { title: "Original" })];
|
|
1944
|
+
const first = deferred<boolean>();
|
|
1945
|
+
const second = deferred<boolean>();
|
|
1946
|
+
const rename = vi
|
|
1947
|
+
.fn()
|
|
1948
|
+
.mockReturnValueOnce(first.promise)
|
|
1949
|
+
.mockReturnValueOnce(second.promise);
|
|
1950
|
+
const onMutationFailed = vi.fn();
|
|
1951
|
+
const navigate = vi.fn();
|
|
1952
|
+
const consoleError = vi.spyOn(console, "error");
|
|
1953
|
+
const options = { mutations: { rename }, onMutationFailed };
|
|
1954
|
+
await renderActivity(options, "visible");
|
|
1955
|
+
const oldMineRefresh = deferred<Response>();
|
|
1956
|
+
const oldSharedRefresh = deferred<Response>();
|
|
1957
|
+
fetchMock
|
|
1958
|
+
.mockImplementationOnce(() => oldMineRefresh.promise)
|
|
1959
|
+
.mockImplementationOnce(() => oldSharedRefresh.promise);
|
|
1960
|
+
let firstResult!: Promise<boolean>;
|
|
1961
|
+
let secondResult!: Promise<boolean>;
|
|
1962
|
+
act(() => {
|
|
1963
|
+
firstResult = latest.listProps.onRename!("owned", "First").then(
|
|
1964
|
+
(succeeded) => {
|
|
1965
|
+
if (succeeded) navigate();
|
|
1966
|
+
return succeeded;
|
|
1967
|
+
},
|
|
1968
|
+
);
|
|
1969
|
+
});
|
|
1970
|
+
mine = [row("owned", { title: "First" })];
|
|
1971
|
+
await act(async () => {
|
|
1972
|
+
first.resolve(true);
|
|
1973
|
+
});
|
|
1974
|
+
await renderActivity(options, "hidden");
|
|
1975
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
1976
|
+
await renderActivity(options, "visible");
|
|
1977
|
+
act(() => {
|
|
1978
|
+
secondResult = latest.listProps.onRename!("owned", "Second");
|
|
1979
|
+
});
|
|
1980
|
+
await act(async () => {
|
|
1981
|
+
oldMineRefresh.resolve(
|
|
1982
|
+
Response.json({
|
|
1983
|
+
threads: [row("owned", { title: "First" })],
|
|
1984
|
+
hasMore: false,
|
|
1985
|
+
}),
|
|
1986
|
+
);
|
|
1987
|
+
oldSharedRefresh.resolve(Response.json({ threads: [], hasMore: false }));
|
|
1988
|
+
expect(await firstResult).toBe(false);
|
|
1989
|
+
});
|
|
1990
|
+
expect(item("owned")?.titleText).toBe("Second");
|
|
1991
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
1992
|
+
await act(async () => {
|
|
1993
|
+
second.resolve(false);
|
|
1994
|
+
expect(await secondResult).toBe(false);
|
|
1995
|
+
});
|
|
1996
|
+
expect(item("owned")?.titleText).toBe("First");
|
|
1997
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith("rename", "owned");
|
|
1998
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
1999
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
it("Activity lifetime: true unmount settles successful physical work without publication or requests", async () => {
|
|
2003
|
+
mine = [row("owned")];
|
|
2004
|
+
const pending = deferred<boolean>();
|
|
2005
|
+
const onMutationFailed = vi.fn();
|
|
2006
|
+
const navigate = vi.fn();
|
|
2007
|
+
const consoleError = vi.spyOn(console, "error");
|
|
2008
|
+
await renderActivity(
|
|
2009
|
+
{ mutations: { archive: () => pending.promise }, onMutationFailed },
|
|
2010
|
+
"visible",
|
|
2011
|
+
);
|
|
2012
|
+
const staleRefresh = latest.refresh;
|
|
2013
|
+
let result!: Promise<boolean>;
|
|
2014
|
+
act(() => {
|
|
2015
|
+
result = latest.listProps.onDelete!("owned").then((succeeded) => {
|
|
2016
|
+
if (succeeded) navigate();
|
|
2017
|
+
return succeeded;
|
|
2018
|
+
});
|
|
2019
|
+
});
|
|
2020
|
+
act(() => {
|
|
2021
|
+
root.render(null);
|
|
2022
|
+
});
|
|
2023
|
+
mine = [];
|
|
2024
|
+
await act(async () => {
|
|
2025
|
+
pending.resolve(true);
|
|
2026
|
+
expect(await result).toBe(false);
|
|
2027
|
+
await staleRefresh();
|
|
2028
|
+
window.dispatchEvent(new Event("focus"));
|
|
2029
|
+
window.dispatchEvent(
|
|
2030
|
+
new CustomEvent("agentNative.chatRunning", {
|
|
2031
|
+
detail: { isRunning: false },
|
|
2032
|
+
}),
|
|
2033
|
+
);
|
|
2034
|
+
});
|
|
2035
|
+
expect(mine).toEqual([]);
|
|
2036
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
2037
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
2038
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
2039
|
+
expect(consoleError).not.toHaveBeenCalled();
|
|
2040
|
+
});
|
|
2041
|
+
|
|
2042
|
+
it.each(
|
|
2043
|
+
(["rename", "pin", "archive"] as const).flatMap((kind) =>
|
|
2044
|
+
(["false", "throw"] as const).map((failure) => ({ kind, failure })),
|
|
2045
|
+
),
|
|
2046
|
+
)(
|
|
2047
|
+
"eligible projection: $kind restores the older queued success after newer $failure",
|
|
2048
|
+
async ({ kind, failure }) => {
|
|
2049
|
+
mine = [row("owned", { title: "Original", pinnedAt: null })];
|
|
2050
|
+
const older = deferred<boolean>();
|
|
2051
|
+
const newer = deferred<boolean>();
|
|
2052
|
+
const mutation = vi
|
|
2053
|
+
.fn()
|
|
2054
|
+
.mockReturnValueOnce(older.promise)
|
|
2055
|
+
.mockReturnValueOnce(newer.promise);
|
|
2056
|
+
const onMutationFailed = vi.fn();
|
|
2057
|
+
await render({
|
|
2058
|
+
pinned: true,
|
|
2059
|
+
mutations: { rename: mutation, pin: mutation, archive: mutation },
|
|
2060
|
+
decorateItem: (item, row) => ({ ...item, titleText: row.title }),
|
|
2061
|
+
onMutationFailed,
|
|
2062
|
+
});
|
|
2063
|
+
const start = (title: string) =>
|
|
2064
|
+
kind === "rename"
|
|
2065
|
+
? latest.listProps.onRename!("owned", title)
|
|
2066
|
+
: kind === "pin"
|
|
2067
|
+
? latest.listProps.onTogglePin!("owned")
|
|
2068
|
+
: latest.listProps.onDelete!("owned");
|
|
2069
|
+
let oldResult!: Promise<boolean>;
|
|
2070
|
+
let newResult!: Promise<boolean>;
|
|
2071
|
+
act(() => {
|
|
2072
|
+
oldResult = start("First");
|
|
2073
|
+
});
|
|
2074
|
+
act(() => {
|
|
2075
|
+
newResult = start("Second");
|
|
2076
|
+
});
|
|
2077
|
+
expect(mutation).toHaveBeenCalledTimes(1);
|
|
2078
|
+
mine =
|
|
2079
|
+
kind === "archive"
|
|
2080
|
+
? []
|
|
2081
|
+
: [
|
|
2082
|
+
row("owned", {
|
|
2083
|
+
title: kind === "rename" ? "First" : "Original",
|
|
2084
|
+
pinnedAt: kind === "pin" ? 2 : null,
|
|
2085
|
+
}),
|
|
2086
|
+
];
|
|
2087
|
+
failMine = true;
|
|
2088
|
+
await act(async () => {
|
|
2089
|
+
older.resolve(true);
|
|
2090
|
+
expect(await oldResult).toBe(true);
|
|
2091
|
+
});
|
|
2092
|
+
const cause = new Error("Newer failed");
|
|
2093
|
+
await act(async () => {
|
|
2094
|
+
if (failure === "throw") newer.reject(cause);
|
|
2095
|
+
else newer.resolve(false);
|
|
2096
|
+
expect(await newResult).toBe(false);
|
|
2097
|
+
});
|
|
2098
|
+
if (kind === "archive") expect(item("owned")).toBeUndefined();
|
|
2099
|
+
else {
|
|
2100
|
+
expect(item("owned")).toMatchObject({
|
|
2101
|
+
titleText: kind === "rename" ? "First" : "Original",
|
|
2102
|
+
pinned: kind === "pin",
|
|
2103
|
+
});
|
|
2104
|
+
expect(section(kind === "pin" ? "pinned" : "mine").items[0].id).toBe(
|
|
2105
|
+
"owned",
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
expect(latest.mineRows[0]).toMatchObject({
|
|
2109
|
+
title: "Original",
|
|
2110
|
+
pinnedAt: null,
|
|
2111
|
+
});
|
|
2112
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith(
|
|
2113
|
+
kind,
|
|
2114
|
+
"owned",
|
|
2115
|
+
...(failure === "throw" ? [cause] : []),
|
|
2116
|
+
);
|
|
2117
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
2118
|
+
failMine = false;
|
|
2119
|
+
await act(async () => {
|
|
2120
|
+
await latest.refresh();
|
|
2121
|
+
});
|
|
2122
|
+
expect(latest.mineRows).toEqual(mine);
|
|
2123
|
+
expect(mutation).toHaveBeenCalledTimes(2);
|
|
2124
|
+
expect(onMutationFailed).toHaveBeenCalledTimes(1);
|
|
2125
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
2126
|
+
},
|
|
2127
|
+
);
|
|
2128
|
+
|
|
2129
|
+
it.each(["latest-ready-first", "head-first"] as const)(
|
|
2130
|
+
"eligible projection: three queued renames retain latest UI intent in %s order",
|
|
2131
|
+
async (completion) => {
|
|
2132
|
+
mine = [row("owned", { title: "Original" })];
|
|
2133
|
+
const first = deferred<boolean>();
|
|
2134
|
+
const second = deferred<boolean>();
|
|
2135
|
+
const third = deferred<boolean>();
|
|
2136
|
+
const rename = vi
|
|
2137
|
+
.fn()
|
|
2138
|
+
.mockReturnValueOnce(first.promise)
|
|
2139
|
+
.mockReturnValueOnce(second.promise)
|
|
2140
|
+
.mockReturnValueOnce(third.promise);
|
|
2141
|
+
const onMutationFailed = vi.fn();
|
|
2142
|
+
await render({ mutations: { rename }, onMutationFailed });
|
|
2143
|
+
let firstResult!: Promise<boolean>;
|
|
2144
|
+
let secondResult!: Promise<boolean>;
|
|
2145
|
+
let thirdResult!: Promise<boolean>;
|
|
2146
|
+
act(() => {
|
|
2147
|
+
firstResult = latest.listProps.onRename!("owned", "First");
|
|
2148
|
+
});
|
|
2149
|
+
act(() => {
|
|
2150
|
+
secondResult = latest.listProps.onRename!("owned", "Second");
|
|
2151
|
+
});
|
|
2152
|
+
act(() => {
|
|
2153
|
+
thirdResult = latest.listProps.onRename!("owned", "Third");
|
|
2154
|
+
});
|
|
2155
|
+
expect(rename).toHaveBeenCalledTimes(1);
|
|
2156
|
+
if (completion === "latest-ready-first") third.resolve(false);
|
|
2157
|
+
await act(async () => {
|
|
2158
|
+
first.resolve(false);
|
|
2159
|
+
expect(await firstResult).toBe(false);
|
|
2160
|
+
});
|
|
2161
|
+
expect(item("owned")?.titleText).toBe("Third");
|
|
2162
|
+
expect(rename).toHaveBeenCalledTimes(2);
|
|
2163
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
2164
|
+
expect(mine[0].title).toBe("Original");
|
|
2165
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
2166
|
+
await act(async () => {
|
|
2167
|
+
second.reject(new Error("Middle failed"));
|
|
2168
|
+
expect(await secondResult).toBe(false);
|
|
2169
|
+
});
|
|
2170
|
+
if (completion === "head-first") {
|
|
2171
|
+
expect(item("owned")?.titleText).toBe("Third");
|
|
2172
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
2173
|
+
}
|
|
2174
|
+
await act(async () => {
|
|
2175
|
+
third.resolve(false);
|
|
2176
|
+
expect(await thirdResult).toBe(false);
|
|
2177
|
+
});
|
|
2178
|
+
expect(item("owned")?.titleText).toBe("Original");
|
|
2179
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith(
|
|
2180
|
+
"rename",
|
|
2181
|
+
"owned",
|
|
2182
|
+
);
|
|
2183
|
+
expect(rename).toHaveBeenCalledTimes(3);
|
|
2184
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
2185
|
+
},
|
|
2186
|
+
);
|
|
2187
|
+
|
|
2188
|
+
it.each(
|
|
2189
|
+
(["rename", "pin"] as const).flatMap((kind) =>
|
|
2190
|
+
(["refresh-error", "reconciled"] as const).map((baseline) => ({
|
|
2191
|
+
kind,
|
|
2192
|
+
baseline,
|
|
2193
|
+
})),
|
|
2194
|
+
),
|
|
2195
|
+
)(
|
|
2196
|
+
"eligible projection: $kind retains a $baseline success through an older proof and failed retry",
|
|
2197
|
+
async ({ kind, baseline }) => {
|
|
2198
|
+
mine = [row("owned", { title: "Original", pinnedAt: null })];
|
|
2199
|
+
const older = deferred<boolean>();
|
|
2200
|
+
const newer = deferred<boolean>();
|
|
2201
|
+
const retry = deferred<boolean>();
|
|
2202
|
+
const mutation = vi
|
|
2203
|
+
.fn()
|
|
2204
|
+
.mockReturnValueOnce(older.promise)
|
|
2205
|
+
.mockReturnValueOnce(newer.promise)
|
|
2206
|
+
.mockReturnValueOnce(retry.promise);
|
|
2207
|
+
const onMutationFailed = vi.fn();
|
|
2208
|
+
await render({
|
|
2209
|
+
pinned: true,
|
|
2210
|
+
mutations: { rename: mutation, pin: mutation },
|
|
2211
|
+
onMutationFailed,
|
|
2212
|
+
});
|
|
2213
|
+
const start = (title: string) =>
|
|
2214
|
+
kind === "rename"
|
|
2215
|
+
? latest.listProps.onRename!("owned", title)
|
|
2216
|
+
: latest.listProps.onTogglePin!("owned");
|
|
2217
|
+
let oldResult!: Promise<boolean>;
|
|
2218
|
+
let newResult!: Promise<boolean>;
|
|
2219
|
+
let retryResult!: Promise<boolean>;
|
|
2220
|
+
act(() => {
|
|
2221
|
+
oldResult = start("First");
|
|
2222
|
+
});
|
|
2223
|
+
act(() => {
|
|
2224
|
+
newResult = start("Saved");
|
|
2225
|
+
});
|
|
2226
|
+
const oldMineProof = deferred<Response>();
|
|
2227
|
+
const oldSharedProof = deferred<Response>();
|
|
2228
|
+
fetchMock
|
|
2229
|
+
.mockImplementationOnce(() => oldMineProof.promise)
|
|
2230
|
+
.mockImplementationOnce(() => oldSharedProof.promise);
|
|
2231
|
+
await act(async () => {
|
|
2232
|
+
older.resolve(true);
|
|
2233
|
+
});
|
|
2234
|
+
mine = [
|
|
2235
|
+
row("owned", {
|
|
2236
|
+
title: kind === "rename" ? "Saved" : "Original",
|
|
2237
|
+
pinnedAt: null,
|
|
2238
|
+
}),
|
|
2239
|
+
];
|
|
2240
|
+
failMine = baseline === "refresh-error";
|
|
2241
|
+
await act(async () => {
|
|
2242
|
+
newer.resolve(true);
|
|
2243
|
+
expect(await newResult).toBe(true);
|
|
2244
|
+
});
|
|
2245
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
2246
|
+
act(() => {
|
|
2247
|
+
retryResult = start("Retry");
|
|
2248
|
+
});
|
|
2249
|
+
await act(async () => {
|
|
2250
|
+
retry.resolve(false);
|
|
2251
|
+
expect(await retryResult).toBe(false);
|
|
2252
|
+
});
|
|
2253
|
+
expect(item("owned")).toMatchObject({
|
|
2254
|
+
titleText: kind === "rename" ? "Saved" : "Original",
|
|
2255
|
+
pinned: false,
|
|
2256
|
+
});
|
|
2257
|
+
expect(section("mine").items[0].id).toBe("owned");
|
|
2258
|
+
expect(mine[0]).toMatchObject({
|
|
2259
|
+
title: kind === "rename" ? "Saved" : "Original",
|
|
2260
|
+
pinnedAt: null,
|
|
2261
|
+
});
|
|
2262
|
+
expect(latest.mineRows[0].title).toBe(
|
|
2263
|
+
baseline === "refresh-error" ? "Original" : mine[0].title,
|
|
2264
|
+
);
|
|
2265
|
+
await act(async () => {
|
|
2266
|
+
oldMineProof.resolve(
|
|
2267
|
+
Response.json({
|
|
2268
|
+
threads: [row("owned", { title: "Old", pinnedAt: 1 })],
|
|
2269
|
+
hasMore: false,
|
|
2270
|
+
}),
|
|
2271
|
+
);
|
|
2272
|
+
oldSharedProof.resolve(Response.json({ threads: [], hasMore: false }));
|
|
2273
|
+
expect(await oldResult).toBe(true);
|
|
2274
|
+
});
|
|
2275
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith(kind, "owned");
|
|
2276
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
2277
|
+
failMine = false;
|
|
2278
|
+
await act(async () => {
|
|
2279
|
+
await latest.refresh();
|
|
2280
|
+
});
|
|
2281
|
+
expect(latest.mineRows).toEqual(mine);
|
|
2282
|
+
expect(item("owned")).toMatchObject({
|
|
2283
|
+
titleText: mine[0].title,
|
|
2284
|
+
pinned: false,
|
|
2285
|
+
});
|
|
2286
|
+
expect(mutation).toHaveBeenCalledTimes(3);
|
|
2287
|
+
expect(fetchMock).toHaveBeenCalledTimes(8);
|
|
2288
|
+
},
|
|
2289
|
+
);
|
|
2290
|
+
|
|
2291
|
+
it.each(["rename", "pin"] as const)(
|
|
2292
|
+
"physical ordering: %s persists the latest accepted value through a fresh mount",
|
|
2293
|
+
async (kind) => {
|
|
2294
|
+
mine = [row("owned", { title: "Original", pinnedAt: null })];
|
|
2295
|
+
const older = deferred<void>();
|
|
2296
|
+
const newer = deferred<void>();
|
|
2297
|
+
let calls = 0;
|
|
2298
|
+
const mutation = vi.fn(async (_id: string, value: string | boolean) => {
|
|
2299
|
+
await (calls++ === 0 ? older : newer).promise;
|
|
2300
|
+
mine = [
|
|
2301
|
+
row("owned", {
|
|
2302
|
+
title: kind === "rename" ? String(value) : "Original",
|
|
2303
|
+
pinnedAt: kind === "pin" && value ? 2 : null,
|
|
2304
|
+
}),
|
|
2305
|
+
];
|
|
2306
|
+
return true;
|
|
2307
|
+
});
|
|
2308
|
+
const onMutationFailed = vi.fn();
|
|
2309
|
+
const options = {
|
|
2310
|
+
pinned: true,
|
|
2311
|
+
mutations: { rename: mutation, pin: mutation },
|
|
2312
|
+
onMutationFailed,
|
|
2313
|
+
};
|
|
2314
|
+
await render(options);
|
|
2315
|
+
const start = (title: string) =>
|
|
2316
|
+
kind === "rename"
|
|
2317
|
+
? latest.listProps.onRename!("owned", title)
|
|
2318
|
+
: latest.listProps.onTogglePin!("owned");
|
|
2319
|
+
let oldResult!: Promise<boolean>;
|
|
2320
|
+
let newResult!: Promise<boolean>;
|
|
2321
|
+
act(() => {
|
|
2322
|
+
oldResult = start("First");
|
|
2323
|
+
});
|
|
2324
|
+
act(() => {
|
|
2325
|
+
newResult = start("Second");
|
|
2326
|
+
});
|
|
2327
|
+
await act(async () => {
|
|
2328
|
+
newer.resolve();
|
|
2329
|
+
});
|
|
2330
|
+
await act(async () => {
|
|
2331
|
+
older.resolve();
|
|
2332
|
+
expect(await oldResult).toBe(true);
|
|
2333
|
+
expect(await newResult).toBe(true);
|
|
2334
|
+
});
|
|
2335
|
+
expect(mine[0]).toMatchObject({
|
|
2336
|
+
title: kind === "rename" ? "Second" : "Original",
|
|
2337
|
+
pinnedAt: null,
|
|
2338
|
+
});
|
|
2339
|
+
expect(mutation.mock.calls).toEqual(
|
|
2340
|
+
kind === "rename"
|
|
2341
|
+
? [
|
|
2342
|
+
["owned", "First"],
|
|
2343
|
+
["owned", "Second"],
|
|
2344
|
+
]
|
|
2345
|
+
: [
|
|
2346
|
+
["owned", true],
|
|
2347
|
+
["owned", false],
|
|
2348
|
+
],
|
|
2349
|
+
);
|
|
2350
|
+
await act(async () => {
|
|
2351
|
+
root.render(null);
|
|
2352
|
+
});
|
|
2353
|
+
await render(options);
|
|
2354
|
+
expect(latest.mineRows).toEqual(mine);
|
|
2355
|
+
expect(item("owned")).toMatchObject({
|
|
2356
|
+
titleText: kind === "rename" ? "Second" : "Original",
|
|
2357
|
+
pinned: false,
|
|
2358
|
+
});
|
|
2359
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
2360
|
+
},
|
|
2361
|
+
);
|
|
2362
|
+
|
|
2363
|
+
it.each(["scope", "query", "enabled", "Activity", "unmount"] as const)(
|
|
2364
|
+
"physical ordering: accepted callbacks survive %s and serialize with the replacement view",
|
|
2365
|
+
async (boundary) => {
|
|
2366
|
+
mine = [row("owned", { title: "Original" })];
|
|
2367
|
+
const first = deferred<void>();
|
|
2368
|
+
const second = deferred<void>();
|
|
2369
|
+
const commits: string[] = [];
|
|
2370
|
+
let calls = 0;
|
|
2371
|
+
const original = vi.fn(async (_id: string, title: string) => {
|
|
2372
|
+
await (calls++ === 0 ? first : second).promise;
|
|
2373
|
+
commits.push(title);
|
|
2374
|
+
mine = [row("owned", { title })];
|
|
2375
|
+
return true;
|
|
2376
|
+
});
|
|
2377
|
+
const replacement = vi.fn(async (_id: string, title: string) => {
|
|
2378
|
+
commits.push(title);
|
|
2379
|
+
mine = [row("owned", { title })];
|
|
2380
|
+
return true;
|
|
2381
|
+
});
|
|
2382
|
+
const onMutationFailed = vi.fn();
|
|
2383
|
+
const originalOptions = {
|
|
2384
|
+
mutations: { rename: original },
|
|
2385
|
+
onMutationFailed,
|
|
2386
|
+
};
|
|
2387
|
+
if (boundary === "Activity")
|
|
2388
|
+
await renderActivity(originalOptions, "visible");
|
|
2389
|
+
else await render(originalOptions);
|
|
2390
|
+
let firstResult!: Promise<boolean>;
|
|
2391
|
+
let secondResult!: Promise<boolean>;
|
|
2392
|
+
act(() => {
|
|
2393
|
+
firstResult = latest.listProps.onRename!("owned", "First");
|
|
2394
|
+
});
|
|
2395
|
+
act(() => {
|
|
2396
|
+
secondResult = latest.listProps.onRename!("owned", "Second");
|
|
2397
|
+
});
|
|
2398
|
+
expect(original).toHaveBeenCalledTimes(1);
|
|
2399
|
+
const nextOptions: UseOwnershipChatHistoryOptions = {
|
|
2400
|
+
apiUrl: agentNativePath("/_agent-native/agent-chat"),
|
|
2401
|
+
...(boundary === "scope"
|
|
2402
|
+
? { scope: { type: "project", id: "replacement" } }
|
|
2403
|
+
: {}),
|
|
2404
|
+
...(boundary === "query" ? { query: "replacement" } : {}),
|
|
2405
|
+
mutations: { rename: replacement },
|
|
2406
|
+
onMutationFailed,
|
|
2407
|
+
};
|
|
2408
|
+
if (boundary === "unmount")
|
|
2409
|
+
await act(async () => {
|
|
2410
|
+
root.render(null);
|
|
2411
|
+
});
|
|
2412
|
+
if (boundary === "enabled")
|
|
2413
|
+
await render({ ...originalOptions, enabled: false });
|
|
2414
|
+
if (boundary === "Activity") {
|
|
2415
|
+
await renderActivity(originalOptions, "hidden");
|
|
2416
|
+
await renderActivity(nextOptions, "visible");
|
|
2417
|
+
} else await render(nextOptions);
|
|
2418
|
+
let thirdResult!: Promise<boolean>;
|
|
2419
|
+
act(() => {
|
|
2420
|
+
thirdResult = latest.listProps.onRename!("owned", "Third");
|
|
2421
|
+
});
|
|
2422
|
+
expect(replacement).not.toHaveBeenCalled();
|
|
2423
|
+
expect(item("owned")?.titleText).toBe("Third");
|
|
2424
|
+
const requests = fetchMock.mock.calls.length;
|
|
2425
|
+
await act(async () => {
|
|
2426
|
+
first.resolve();
|
|
2427
|
+
expect(await firstResult).toBe(false);
|
|
2428
|
+
});
|
|
2429
|
+
expect(original).toHaveBeenCalledTimes(2);
|
|
2430
|
+
expect(original.mock.calls[1]).toEqual(["owned", "Second"]);
|
|
2431
|
+
expect(replacement).not.toHaveBeenCalled();
|
|
2432
|
+
expect(fetchMock).toHaveBeenCalledTimes(requests);
|
|
2433
|
+
await act(async () => {
|
|
2434
|
+
second.resolve();
|
|
2435
|
+
expect(await secondResult).toBe(false);
|
|
2436
|
+
expect(await thirdResult).toBe(true);
|
|
2437
|
+
});
|
|
2438
|
+
expect(commits).toEqual(["First", "Second", "Third"]);
|
|
2439
|
+
expect(replacement).toHaveBeenCalledExactlyOnceWith("owned", "Third");
|
|
2440
|
+
expect(mine[0].title).toBe("Third");
|
|
2441
|
+
expect(latest.mineRows).toEqual(mine);
|
|
2442
|
+
expect(fetchMock).toHaveBeenCalledTimes(requests + 2);
|
|
2443
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
2444
|
+
await act(async () => {
|
|
2445
|
+
root.render(null);
|
|
2446
|
+
});
|
|
2447
|
+
await render(nextOptions);
|
|
2448
|
+
expect(item("owned")?.titleText).toBe("Third");
|
|
2449
|
+
let finalResult!: Promise<boolean>;
|
|
2450
|
+
act(() => {
|
|
2451
|
+
finalResult = latest.listProps.onRename!("owned", "After cleanup");
|
|
2452
|
+
expect(replacement).toHaveBeenCalledTimes(2);
|
|
2453
|
+
});
|
|
2454
|
+
await act(async () => {
|
|
2455
|
+
expect(await finalResult).toBe(true);
|
|
2456
|
+
});
|
|
2457
|
+
expect(mine[0].title).toBe("After cleanup");
|
|
2458
|
+
},
|
|
2459
|
+
);
|
|
2460
|
+
|
|
2461
|
+
it.each(["false", "throw"] as const)(
|
|
2462
|
+
"physical ordering: %s releases the next accepted transport and the final tail",
|
|
2463
|
+
async (failure) => {
|
|
2464
|
+
mine = [row("owned", { title: "Original" })];
|
|
2465
|
+
const pending = deferred<boolean>();
|
|
2466
|
+
const cause = new Error("First failed");
|
|
2467
|
+
const rename = vi
|
|
2468
|
+
.fn()
|
|
2469
|
+
.mockReturnValueOnce(pending.promise)
|
|
2470
|
+
.mockImplementation(async (_id: string, title: string) => {
|
|
2471
|
+
mine = [row("owned", { title })];
|
|
2472
|
+
return true;
|
|
2473
|
+
});
|
|
2474
|
+
const onMutationFailed = vi.fn();
|
|
2475
|
+
await render({ mutations: { rename }, onMutationFailed });
|
|
2476
|
+
let older!: Promise<boolean>;
|
|
2477
|
+
let newer!: Promise<boolean>;
|
|
2478
|
+
act(() => {
|
|
2479
|
+
older = latest.listProps.onRename!("owned", "Failed");
|
|
2480
|
+
});
|
|
2481
|
+
act(() => {
|
|
2482
|
+
newer = latest.listProps.onRename!("owned", "Saved");
|
|
2483
|
+
});
|
|
2484
|
+
expect(rename).toHaveBeenCalledTimes(1);
|
|
2485
|
+
await act(async () => {
|
|
2486
|
+
if (failure === "throw") pending.reject(cause);
|
|
2487
|
+
else pending.resolve(false);
|
|
2488
|
+
expect(await older).toBe(false);
|
|
2489
|
+
expect(await newer).toBe(true);
|
|
2490
|
+
});
|
|
2491
|
+
expect(mine[0].title).toBe("Saved");
|
|
2492
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
2493
|
+
let next!: Promise<boolean>;
|
|
2494
|
+
act(() => {
|
|
2495
|
+
next = latest.listProps.onRename!("owned", "Next");
|
|
2496
|
+
expect(rename).toHaveBeenCalledTimes(3);
|
|
2497
|
+
});
|
|
2498
|
+
await act(async () => {
|
|
2499
|
+
expect(await next).toBe(true);
|
|
2500
|
+
});
|
|
2501
|
+
expect(latest.mineRows).toEqual(mine);
|
|
2502
|
+
},
|
|
2503
|
+
);
|
|
2504
|
+
|
|
2505
|
+
it.each(["id", "endpoint"] as const)(
|
|
2506
|
+
"physical ordering: a different %s is independent of a blocked transport",
|
|
2507
|
+
async (identity) => {
|
|
2508
|
+
mine = [row("owned"), row("other")];
|
|
2509
|
+
const blocked = deferred<boolean>();
|
|
2510
|
+
const original = vi.fn(() => blocked.promise);
|
|
2511
|
+
await render({ mutations: { rename: original } });
|
|
2512
|
+
let older!: Promise<boolean>;
|
|
2513
|
+
act(() => {
|
|
2514
|
+
older = latest.listProps.onRename!("owned", "Pending");
|
|
2515
|
+
});
|
|
2516
|
+
const independent = vi.fn(async () => true);
|
|
2517
|
+
await render({
|
|
2518
|
+
...(identity === "endpoint" ? { apiUrl: "/other-chat" } : {}),
|
|
2519
|
+
mutations: { rename: independent },
|
|
2520
|
+
});
|
|
2521
|
+
await act(async () => {
|
|
2522
|
+
expect(
|
|
2523
|
+
await latest.listProps.onRename!(
|
|
2524
|
+
identity === "id" ? "other" : "owned",
|
|
2525
|
+
"Independent",
|
|
2526
|
+
),
|
|
2527
|
+
).toBe(true);
|
|
2528
|
+
});
|
|
2529
|
+
expect(independent).toHaveBeenCalledTimes(1);
|
|
2530
|
+
await act(async () => {
|
|
2531
|
+
blocked.resolve(false);
|
|
2532
|
+
expect(await older).toBe(false);
|
|
2533
|
+
});
|
|
2534
|
+
},
|
|
2535
|
+
);
|
|
2536
|
+
|
|
2537
|
+
it("an older failure cannot roll back a newer optimistic intent", async () => {
|
|
2538
|
+
mine = [row("owned")];
|
|
2539
|
+
const first = deferred<boolean>();
|
|
2540
|
+
const second = deferred<boolean>();
|
|
2541
|
+
const rename = vi
|
|
2542
|
+
.fn()
|
|
2543
|
+
.mockReturnValueOnce(first.promise)
|
|
2544
|
+
.mockReturnValueOnce(second.promise);
|
|
2545
|
+
await render({ mutations: { rename } });
|
|
2546
|
+
let oldResult!: Promise<boolean>;
|
|
2547
|
+
let newResult!: Promise<boolean>;
|
|
2548
|
+
act(() => {
|
|
2549
|
+
oldResult = latest.listProps.onRename!("owned", "First");
|
|
2550
|
+
});
|
|
2551
|
+
act(() => {
|
|
2552
|
+
newResult = latest.listProps.onRename!("owned", "Second");
|
|
2553
|
+
});
|
|
2554
|
+
await act(async () => {
|
|
2555
|
+
first.resolve(false);
|
|
2556
|
+
await oldResult;
|
|
2557
|
+
});
|
|
2558
|
+
expect(item("owned")?.title).toBe("Second");
|
|
2559
|
+
mine = [row("owned", { title: "Second" })];
|
|
2560
|
+
await act(async () => {
|
|
2561
|
+
second.resolve(true);
|
|
2562
|
+
await newResult;
|
|
2563
|
+
});
|
|
2564
|
+
expect(item("owned")?.title).toBe("Second");
|
|
2565
|
+
});
|
|
2566
|
+
|
|
2567
|
+
it.each(["false", "throw"])(
|
|
2568
|
+
"suppresses an older %s failure callback when a newer same-id rename is accepted",
|
|
2569
|
+
async (failure) => {
|
|
2570
|
+
mine = [row("owned")];
|
|
2571
|
+
const first = deferred<boolean>();
|
|
2572
|
+
const second = deferred<boolean>();
|
|
2573
|
+
const rename = vi
|
|
2574
|
+
.fn()
|
|
2575
|
+
.mockReturnValueOnce(first.promise)
|
|
2576
|
+
.mockReturnValueOnce(second.promise);
|
|
2577
|
+
const onMutationFailed = vi.fn();
|
|
2578
|
+
await render({ mutations: { rename }, onMutationFailed });
|
|
2579
|
+
let oldResult!: Promise<boolean>;
|
|
2580
|
+
let newResult!: Promise<boolean>;
|
|
2581
|
+
act(() => {
|
|
2582
|
+
oldResult = latest.listProps.onRename!("owned", "First");
|
|
2583
|
+
});
|
|
2584
|
+
act(() => {
|
|
2585
|
+
newResult = latest.listProps.onRename!("owned", "Second");
|
|
2586
|
+
});
|
|
2587
|
+
const requestCount = fetchMock.mock.calls.length;
|
|
2588
|
+
await act(async () => {
|
|
2589
|
+
if (failure === "throw") first.reject(new Error("Older rename failed"));
|
|
2590
|
+
else first.resolve(false);
|
|
2591
|
+
expect(await oldResult).toBe(false);
|
|
2592
|
+
});
|
|
2593
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
2594
|
+
expect(fetchMock).toHaveBeenCalledTimes(requestCount);
|
|
2595
|
+
mine = [row("owned", { title: "Second" })];
|
|
2596
|
+
await act(async () => {
|
|
2597
|
+
second.resolve(true);
|
|
2598
|
+
expect(await newResult).toBe(true);
|
|
2599
|
+
});
|
|
2600
|
+
expect(item("owned")?.title).toBe("Second");
|
|
2601
|
+
expect(latest.mineRows[0].title).toBe("Second");
|
|
2602
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
2603
|
+
expect(fetchMock).toHaveBeenCalledTimes(requestCount + 2);
|
|
2604
|
+
},
|
|
2605
|
+
);
|
|
2606
|
+
|
|
2607
|
+
it("keeps pending changes through stale refreshes and settled changes through failed authoritative refreshes", async () => {
|
|
2608
|
+
mine = [row("owned")];
|
|
2609
|
+
const pending = deferred<boolean>();
|
|
2610
|
+
await render({ mutations: { rename: () => pending.promise } });
|
|
2611
|
+
let result!: Promise<boolean>;
|
|
2612
|
+
act(() => {
|
|
2613
|
+
result = latest.listProps.onRename!("owned", "Optimistic");
|
|
2614
|
+
});
|
|
2615
|
+
await act(async () => {
|
|
2616
|
+
await latest.refresh();
|
|
2617
|
+
});
|
|
2618
|
+
expect(item("owned")?.title).toBe("Optimistic");
|
|
2619
|
+
failMine = true;
|
|
2620
|
+
await act(async () => {
|
|
2621
|
+
pending.resolve(true);
|
|
2622
|
+
expect(await result).toBe(true);
|
|
2623
|
+
});
|
|
2624
|
+
expect(item("owned")?.title).toBe("Optimistic");
|
|
2625
|
+
expect(latest.mineRows[0].title).toBe("owned");
|
|
2626
|
+
expect(latest.error?.message).toContain("503");
|
|
2627
|
+
failMine = false;
|
|
2628
|
+
mine = [row("owned", { title: "Optimistic" })];
|
|
2629
|
+
await act(async () => {
|
|
2630
|
+
await latest.refresh();
|
|
2631
|
+
});
|
|
2632
|
+
mine = [row("owned", { title: "Later server title" })];
|
|
2633
|
+
await act(async () => {
|
|
2634
|
+
await latest.refresh();
|
|
2635
|
+
});
|
|
2636
|
+
expect(item("owned")?.title).toBe("Later server title");
|
|
2637
|
+
});
|
|
2638
|
+
|
|
2639
|
+
it("accepts a conflicting authoritative post-persistence value after exactly one own refresh", async () => {
|
|
2640
|
+
mine = [row("owned")];
|
|
2641
|
+
await render({ mutations: { rename: async () => true } });
|
|
2642
|
+
await act(async () => {
|
|
2643
|
+
expect(await latest.listProps.onRename!("owned", "Optimistic")).toBe(
|
|
2644
|
+
true,
|
|
2645
|
+
);
|
|
2646
|
+
});
|
|
2647
|
+
expect(item("owned")?.title).toBe("owned");
|
|
2648
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
2649
|
+
});
|
|
2650
|
+
|
|
2651
|
+
it("Retry accepts a conflicting server value after a failed post-persistence refresh", async () => {
|
|
2652
|
+
mine = [row("owned")];
|
|
2653
|
+
await render({ mutations: { rename: async () => true } });
|
|
2654
|
+
failMine = true;
|
|
2655
|
+
await act(async () => {
|
|
2656
|
+
await latest.listProps.onRename!("owned", "Optimistic");
|
|
2657
|
+
});
|
|
2658
|
+
expect(item("owned")?.title).toBe("Optimistic");
|
|
2659
|
+
failMine = false;
|
|
2660
|
+
mine = [row("owned", { title: "Changed elsewhere" })];
|
|
2661
|
+
await act(async () => {
|
|
2662
|
+
await section("mine").onRetry!();
|
|
2663
|
+
});
|
|
2664
|
+
expect(item("owned")?.title).toBe("Changed elsewhere");
|
|
2665
|
+
expect(latest.error).toBeNull();
|
|
2666
|
+
});
|
|
2667
|
+
|
|
2668
|
+
it("retains rows and paging on a failed refresh and exposes retry", async () => {
|
|
2669
|
+
renderRail = true;
|
|
2670
|
+
mine = [row("one"), row("two"), row("three")];
|
|
2671
|
+
await render({ pageSize: 1 });
|
|
2672
|
+
await act(async () => {
|
|
2673
|
+
await section("mine").onLoadMore!();
|
|
2674
|
+
});
|
|
2675
|
+
failMine = true;
|
|
2676
|
+
await act(async () => {
|
|
2677
|
+
await latest.refresh();
|
|
2678
|
+
});
|
|
2679
|
+
expect(latest.mineRows.map((row) => row.id)).toEqual(["one", "two"]);
|
|
2680
|
+
expect(section("mine").hasMore).toBe(true);
|
|
2681
|
+
expect(section("mine").error).toBe("Couldn't load more chats");
|
|
2682
|
+
expect(
|
|
2683
|
+
sectionElement("mine").querySelectorAll(".an-chat-history-row"),
|
|
2684
|
+
).toHaveLength(2);
|
|
2685
|
+
expect(
|
|
2686
|
+
sectionElement("mine").querySelector('[role="alert"]')?.textContent,
|
|
2687
|
+
).toContain("Couldn't load more chats");
|
|
2688
|
+
failMine = false;
|
|
2689
|
+
await act(async () => {
|
|
2690
|
+
await section("mine").onRetry!();
|
|
2691
|
+
});
|
|
2692
|
+
expect(latest.error).toBeNull();
|
|
2693
|
+
});
|
|
2694
|
+
|
|
2695
|
+
it("hides Shared during its initial empty load, then shows recoverable errors", async () => {
|
|
2696
|
+
const initial = deferred<Response>();
|
|
2697
|
+
fetchMock.mockImplementation((url) =>
|
|
2698
|
+
String(url).includes("ownership=shared")
|
|
2699
|
+
? initial.promise
|
|
2700
|
+
: Promise.resolve(pageResponse(url)),
|
|
2701
|
+
);
|
|
2702
|
+
await render();
|
|
2703
|
+
expect(latest.sections.map((section) => section.id)).toEqual(["mine"]);
|
|
2704
|
+
await act(async () => {
|
|
2705
|
+
initial.resolve(new Response("failed", { status: 503 }));
|
|
2706
|
+
});
|
|
2707
|
+
expect(section("shared").error).toBe("Couldn't load chats");
|
|
2708
|
+
});
|
|
2709
|
+
|
|
2710
|
+
it("rejects mutations on Shared and unknown ids even if a decorator exposes actions", async () => {
|
|
2711
|
+
shared = [row("shared", { accessBasis: "direct" })];
|
|
2712
|
+
const pin = vi.fn();
|
|
2713
|
+
const rename = vi.fn();
|
|
2714
|
+
const archive = vi.fn();
|
|
2715
|
+
await render({
|
|
2716
|
+
mutations: { pin, rename, archive },
|
|
2717
|
+
decorateItem: (item) => ({ ...item, pinned: true, readOnly: false }),
|
|
2718
|
+
});
|
|
2719
|
+
expect(item("shared")).toMatchObject({ pinned: false, readOnly: true });
|
|
2720
|
+
await act(async () => {
|
|
2721
|
+
expect(await latest.listProps.onTogglePin!("shared")).toBe(false);
|
|
2722
|
+
expect(await latest.listProps.onRename!("shared", "title")).toBe(false);
|
|
2723
|
+
expect(await latest.listProps.onDelete!("unknown")).toBe(false);
|
|
2724
|
+
});
|
|
2725
|
+
expect(pin).not.toHaveBeenCalled();
|
|
2726
|
+
expect(rename).not.toHaveBeenCalled();
|
|
2727
|
+
expect(archive).not.toHaveBeenCalled();
|
|
2728
|
+
});
|
|
2729
|
+
|
|
2730
|
+
it("copies an absolute thread link from any row, Shared included", async () => {
|
|
2731
|
+
shared = [row("shared", { accessBasis: "direct" })];
|
|
2732
|
+
await render({ getThreadUrl: (id) => `/chat/${id}` });
|
|
2733
|
+
await act(async () => {
|
|
2734
|
+
await latest.listProps.onCopyLink!("shared");
|
|
2735
|
+
});
|
|
2736
|
+
expect(clipboard.write).toHaveBeenCalledWith(
|
|
2737
|
+
new URL("/chat/shared", window.location.href).toString(),
|
|
2738
|
+
);
|
|
2739
|
+
});
|
|
2740
|
+
|
|
2741
|
+
it("offers no Copy link when the app gives no thread URL", async () => {
|
|
2742
|
+
await render();
|
|
2743
|
+
expect(latest.listProps.onCopyLink).toBeUndefined();
|
|
2744
|
+
});
|
|
2745
|
+
|
|
2746
|
+
it("does not fetch while disabled or refresh after unmount", async () => {
|
|
2747
|
+
await render({ enabled: false });
|
|
2748
|
+
await act(async () => {
|
|
2749
|
+
window.dispatchEvent(new Event("focus"));
|
|
2750
|
+
window.dispatchEvent(
|
|
2751
|
+
new CustomEvent("agentNative.chatRunning", {
|
|
2752
|
+
detail: { isRunning: false },
|
|
2753
|
+
}),
|
|
2754
|
+
);
|
|
2755
|
+
await latest.refresh();
|
|
2756
|
+
});
|
|
2757
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
2758
|
+
expect(latest.loading).toBe(false);
|
|
2759
|
+
await render({ enabled: true });
|
|
2760
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
2761
|
+
const refresh = latest.refresh;
|
|
2762
|
+
act(() => {
|
|
2763
|
+
root.render(null);
|
|
2764
|
+
});
|
|
2765
|
+
await refresh();
|
|
2766
|
+
window.dispatchEvent(new Event("focus"));
|
|
2767
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
2768
|
+
});
|
|
2769
|
+
|
|
2770
|
+
it.each(["none", "once", "burst"] as const)(
|
|
2771
|
+
"notification coordination: %s own events use one authoritative loaded-window refresh",
|
|
2772
|
+
async (notifications) => {
|
|
2773
|
+
mine = [row("owned", { title: "Original" }), row("other")];
|
|
2774
|
+
const rename = vi.fn(async (_id: string, title: string) => {
|
|
2775
|
+
mine = [row("owned", { title }), row("other")];
|
|
2776
|
+
if (notifications !== "none") {
|
|
2777
|
+
window.dispatchEvent(new Event("agent-chat:threads-updated"));
|
|
2778
|
+
if (notifications === "burst")
|
|
2779
|
+
window.dispatchEvent(new Event("agent-chat:threads-updated"));
|
|
2780
|
+
}
|
|
2781
|
+
return true;
|
|
2782
|
+
});
|
|
2783
|
+
await render({ pageSize: 1, mutations: { rename } });
|
|
2784
|
+
await act(async () => {
|
|
2785
|
+
await section("mine").onLoadMore!();
|
|
2786
|
+
});
|
|
2787
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
2788
|
+
await act(async () => {
|
|
2789
|
+
expect(await latest.listProps.onRename!("owned", "Saved")).toBe(true);
|
|
2790
|
+
});
|
|
2791
|
+
expect(latest.mineRows).toEqual(mine);
|
|
2792
|
+
expect(item("owned")?.titleText).toBe("Saved");
|
|
2793
|
+
expect(fetchMock).toHaveBeenCalledTimes(5);
|
|
2794
|
+
mine = [row("owned", { title: "External" }), row("other")];
|
|
2795
|
+
await act(async () => {
|
|
2796
|
+
window.dispatchEvent(new Event("agent-chat:threads-updated"));
|
|
2797
|
+
});
|
|
2798
|
+
expect(item("owned")?.titleText).toBe("External");
|
|
2799
|
+
expect(fetchMock).toHaveBeenCalledTimes(7);
|
|
2800
|
+
},
|
|
2801
|
+
);
|
|
2802
|
+
|
|
2803
|
+
it.each(["false", "throw"] as const)(
|
|
2804
|
+
"notification coordination: external events during a %s mutation refresh after failure without changing its outcome",
|
|
2805
|
+
async (failure) => {
|
|
2806
|
+
mine = [row("owned", { title: "Original" })];
|
|
2807
|
+
const pending = deferred<boolean>();
|
|
2808
|
+
const onMutationFailed = vi.fn();
|
|
2809
|
+
await render({
|
|
2810
|
+
mutations: { rename: () => pending.promise },
|
|
2811
|
+
onMutationFailed,
|
|
2812
|
+
});
|
|
2813
|
+
let result!: Promise<boolean>;
|
|
2814
|
+
act(() => {
|
|
2815
|
+
result = latest.listProps.onRename!("owned", "Unsaved");
|
|
2816
|
+
});
|
|
2817
|
+
mine = [row("owned", { title: "External" })];
|
|
2818
|
+
await act(async () => {
|
|
2819
|
+
window.dispatchEvent(new Event("agent-chat:threads-updated"));
|
|
2820
|
+
window.dispatchEvent(new Event("agent-chat:threads-updated"));
|
|
2821
|
+
});
|
|
2822
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
2823
|
+
const cause = new Error("Not persisted");
|
|
2824
|
+
await act(async () => {
|
|
2825
|
+
if (failure === "throw") pending.reject(cause);
|
|
2826
|
+
else pending.resolve(false);
|
|
2827
|
+
expect(await result).toBe(false);
|
|
2828
|
+
});
|
|
2829
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
2830
|
+
expect(latest.mineRows).toEqual(mine);
|
|
2831
|
+
expect(item("owned")?.titleText).toBe("External");
|
|
2832
|
+
expect(onMutationFailed).toHaveBeenCalledExactlyOnceWith(
|
|
2833
|
+
"rename",
|
|
2834
|
+
"owned",
|
|
2835
|
+
...(failure === "throw" ? [cause] : []),
|
|
2836
|
+
);
|
|
2837
|
+
},
|
|
2838
|
+
);
|
|
2839
|
+
|
|
2840
|
+
it.each(["success", "error"] as const)(
|
|
2841
|
+
"notification coordination: dirty external events during a %s proof get one trailing refresh",
|
|
2842
|
+
async (outcome) => {
|
|
2843
|
+
mine = [row("owned", { title: "Original" })];
|
|
2844
|
+
const onMutationFailed = vi.fn();
|
|
2845
|
+
await render({
|
|
2846
|
+
mutations: {
|
|
2847
|
+
rename: async (_id, title) => {
|
|
2848
|
+
mine = [row("owned", { title })];
|
|
2849
|
+
return true;
|
|
2850
|
+
},
|
|
2851
|
+
},
|
|
2852
|
+
onMutationFailed,
|
|
2853
|
+
});
|
|
2854
|
+
const proofMine = deferred<Response>();
|
|
2855
|
+
const proofShared = deferred<Response>();
|
|
2856
|
+
fetchMock
|
|
2857
|
+
.mockImplementationOnce(() => proofMine.promise)
|
|
2858
|
+
.mockImplementationOnce(() => proofShared.promise);
|
|
2859
|
+
let result!: Promise<boolean>;
|
|
2860
|
+
await act(async () => {
|
|
2861
|
+
result = latest.listProps.onRename!("owned", "Saved");
|
|
2862
|
+
});
|
|
2863
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
2864
|
+
const saved = mine;
|
|
2865
|
+
mine = [row("owned", { title: "External" })];
|
|
2866
|
+
await act(async () => {
|
|
2867
|
+
window.dispatchEvent(new Event("agent-chat:threads-updated"));
|
|
2868
|
+
window.dispatchEvent(new Event("agent-chat:threads-updated"));
|
|
2869
|
+
});
|
|
2870
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
2871
|
+
await act(async () => {
|
|
2872
|
+
proofMine.resolve(
|
|
2873
|
+
outcome === "success"
|
|
2874
|
+
? Response.json({ threads: saved, hasMore: false })
|
|
2875
|
+
: new Response("failed", { status: 503 }),
|
|
2876
|
+
);
|
|
2877
|
+
proofShared.resolve(Response.json({ threads: [], hasMore: false }));
|
|
2878
|
+
expect(await result).toBe(true);
|
|
2879
|
+
});
|
|
2880
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
2881
|
+
expect(latest.mineRows).toEqual(mine);
|
|
2882
|
+
expect(item("owned")?.titleText).toBe("External");
|
|
2883
|
+
expect(latest.error).toBeNull();
|
|
2884
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
2885
|
+
},
|
|
2886
|
+
);
|
|
2887
|
+
|
|
2888
|
+
it("coalesces focus and run-end refreshes for ten seconds with one thread-update subscription", async () => {
|
|
2889
|
+
await render();
|
|
2890
|
+
const clock = vi.spyOn(Date, "now").mockReturnValue(10_000);
|
|
2891
|
+
await act(async () => {
|
|
2892
|
+
window.dispatchEvent(new Event("focus"));
|
|
2893
|
+
window.dispatchEvent(
|
|
2894
|
+
new CustomEvent("agentNative.chatRunning", {
|
|
2895
|
+
detail: { isRunning: false },
|
|
2896
|
+
}),
|
|
2897
|
+
);
|
|
2898
|
+
window.dispatchEvent(new Event("focus"));
|
|
2899
|
+
});
|
|
2900
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
2901
|
+
await act(async () => {
|
|
2902
|
+
window.dispatchEvent(new Event("focus"));
|
|
2903
|
+
});
|
|
2904
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
2905
|
+
clock.mockReturnValue(20_001);
|
|
2906
|
+
await act(async () => {
|
|
2907
|
+
window.dispatchEvent(
|
|
2908
|
+
new CustomEvent("agentNative.chatRunning", {
|
|
2909
|
+
detail: { isRunning: true },
|
|
2910
|
+
}),
|
|
2911
|
+
);
|
|
2912
|
+
});
|
|
2913
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
2914
|
+
await act(async () => {
|
|
2915
|
+
window.dispatchEvent(
|
|
2916
|
+
new CustomEvent("agentNative.chatRunning", {
|
|
2917
|
+
detail: { isRunning: false },
|
|
2918
|
+
}),
|
|
2919
|
+
);
|
|
2920
|
+
});
|
|
2921
|
+
expect(fetchMock).toHaveBeenCalledTimes(6);
|
|
2922
|
+
await act(async () => {
|
|
2923
|
+
window.dispatchEvent(new Event("agent-chat:threads-updated"));
|
|
2924
|
+
});
|
|
2925
|
+
expect(fetchMock).toHaveBeenCalledTimes(8);
|
|
2926
|
+
});
|
|
2927
|
+
|
|
2928
|
+
it("ignores old mutation completion after a scope change or unmount", async () => {
|
|
2929
|
+
mine = [row("owned")];
|
|
2930
|
+
const pending = deferred<boolean>();
|
|
2931
|
+
const onMutationFailed = vi.fn();
|
|
2932
|
+
await render({
|
|
2933
|
+
scope: { type: "project", id: "old" },
|
|
2934
|
+
mutations: { rename: () => pending.promise },
|
|
2935
|
+
onMutationFailed,
|
|
2936
|
+
});
|
|
2937
|
+
let result!: Promise<boolean>;
|
|
2938
|
+
act(() => {
|
|
2939
|
+
result = latest.listProps.onRename!("owned", "Old scope title");
|
|
2940
|
+
});
|
|
2941
|
+
mine = [row("new-scope")];
|
|
2942
|
+
await render({ scope: { type: "project", id: "new" }, onMutationFailed });
|
|
2943
|
+
const requests = fetchMock.mock.calls.length;
|
|
2944
|
+
await act(async () => {
|
|
2945
|
+
pending.resolve(true);
|
|
2946
|
+
await result;
|
|
2947
|
+
});
|
|
2948
|
+
expect(fetchMock).toHaveBeenCalledTimes(requests);
|
|
2949
|
+
expect(item("new-scope")?.title).toBe("new-scope");
|
|
2950
|
+
expect(item("owned")).toBeUndefined();
|
|
2951
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
2952
|
+
});
|
|
2953
|
+
|
|
2954
|
+
it("late list responses cannot replace the current scope", async () => {
|
|
2955
|
+
const oldMine = deferred<Response>();
|
|
2956
|
+
const oldShared = deferred<Response>();
|
|
2957
|
+
fetchMock
|
|
2958
|
+
.mockImplementationOnce(() => oldMine.promise)
|
|
2959
|
+
.mockImplementationOnce(() => oldShared.promise);
|
|
2960
|
+
await render({ scope: { type: "project", id: "old" } });
|
|
2961
|
+
mine = [row("current")];
|
|
2962
|
+
await render({ scope: { type: "project", id: "current" } });
|
|
2963
|
+
await act(async () => {
|
|
2964
|
+
oldMine.resolve(
|
|
2965
|
+
Response.json({ threads: [row("stale")], hasMore: false }),
|
|
2966
|
+
);
|
|
2967
|
+
oldShared.resolve(
|
|
2968
|
+
Response.json({
|
|
2969
|
+
threads: [row("stale-shared", { accessBasis: "direct" })],
|
|
2970
|
+
hasMore: false,
|
|
2971
|
+
}),
|
|
2972
|
+
);
|
|
2973
|
+
});
|
|
2974
|
+
expect(latest.mineRows.map((row) => row.id)).toEqual(["current"]);
|
|
2975
|
+
expect(latest.sharedRows).toEqual([]);
|
|
2976
|
+
expect(item("stale")).toBeUndefined();
|
|
2977
|
+
expect(item("stale-shared")).toBeUndefined();
|
|
2978
|
+
});
|
|
2979
|
+
|
|
2980
|
+
it("does not clear a settled override when its authoritative request is superseded", async () => {
|
|
2981
|
+
mine = [row("owned")];
|
|
2982
|
+
const pending = deferred<boolean>();
|
|
2983
|
+
await render({ mutations: { rename: () => pending.promise } });
|
|
2984
|
+
let mutation!: Promise<boolean>;
|
|
2985
|
+
act(() => {
|
|
2986
|
+
mutation = latest.listProps.onRename!("owned", "Optimistic");
|
|
2987
|
+
});
|
|
2988
|
+
const oldMineRefresh = deferred<Response>();
|
|
2989
|
+
const oldSharedRefresh = deferred<Response>();
|
|
2990
|
+
fetchMock
|
|
2991
|
+
.mockImplementationOnce(() => oldMineRefresh.promise)
|
|
2992
|
+
.mockImplementationOnce(() => oldSharedRefresh.promise);
|
|
2993
|
+
await act(async () => {
|
|
2994
|
+
pending.resolve(true);
|
|
2995
|
+
});
|
|
2996
|
+
await act(async () => {
|
|
2997
|
+
failMine = true;
|
|
2998
|
+
await latest.refresh();
|
|
2999
|
+
});
|
|
3000
|
+
await act(async () => {
|
|
3001
|
+
oldMineRefresh.resolve(pageResponse("/threads?ownership=mine&limit=50"));
|
|
3002
|
+
oldSharedRefresh.resolve(
|
|
3003
|
+
pageResponse("/threads?ownership=shared&limit=50"),
|
|
3004
|
+
);
|
|
3005
|
+
expect(await mutation).toBe(true);
|
|
3006
|
+
});
|
|
3007
|
+
expect(item("owned")?.title).toBe("Optimistic");
|
|
3008
|
+
failMine = false;
|
|
3009
|
+
mine = [row("owned", { title: "Optimistic" })];
|
|
3010
|
+
await act(async () => {
|
|
3011
|
+
await latest.refresh();
|
|
3012
|
+
});
|
|
3013
|
+
mine = [row("owned", { title: "Changed later" })];
|
|
3014
|
+
await act(async () => {
|
|
3015
|
+
await latest.refresh();
|
|
3016
|
+
});
|
|
3017
|
+
expect(item("owned")?.title).toBe("Changed later");
|
|
3018
|
+
});
|
|
3019
|
+
|
|
3020
|
+
it("a pending mutation cannot notify or refetch after unmount", async () => {
|
|
3021
|
+
mine = [row("owned")];
|
|
3022
|
+
const pending = deferred<boolean>();
|
|
3023
|
+
const onMutationFailed = vi.fn();
|
|
3024
|
+
await render({
|
|
3025
|
+
mutations: { rename: () => pending.promise },
|
|
3026
|
+
onMutationFailed,
|
|
3027
|
+
});
|
|
3028
|
+
let mutation!: Promise<boolean>;
|
|
3029
|
+
act(() => {
|
|
3030
|
+
mutation = latest.listProps.onRename!("owned", "Pending");
|
|
3031
|
+
});
|
|
3032
|
+
act(() => {
|
|
3033
|
+
root.render(null);
|
|
3034
|
+
});
|
|
3035
|
+
await act(async () => {
|
|
3036
|
+
pending.reject(new Error("Late failure"));
|
|
3037
|
+
expect(await mutation).toBe(false);
|
|
3038
|
+
});
|
|
3039
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
3040
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
3041
|
+
});
|
|
3042
|
+
|
|
3043
|
+
it.each(["scope", "apiUrl", "enabled", "unmount"] as const)(
|
|
3044
|
+
"returns false when %s is invalidated during the post-persistence refresh",
|
|
3045
|
+
async (invalidation) => {
|
|
3046
|
+
mine = [row("owned")];
|
|
3047
|
+
const persistence = deferred<boolean>();
|
|
3048
|
+
const onMutationFailed = vi.fn();
|
|
3049
|
+
await render({
|
|
3050
|
+
mutations: { archive: () => persistence.promise },
|
|
3051
|
+
onMutationFailed,
|
|
3052
|
+
});
|
|
3053
|
+
const oldMineRefresh = deferred<Response>();
|
|
3054
|
+
const oldSharedRefresh = deferred<Response>();
|
|
3055
|
+
fetchMock
|
|
3056
|
+
.mockImplementationOnce(() => oldMineRefresh.promise)
|
|
3057
|
+
.mockImplementationOnce(() => oldSharedRefresh.promise);
|
|
3058
|
+
const navigate = vi.fn();
|
|
3059
|
+
let completion!: Promise<boolean>;
|
|
3060
|
+
act(() => {
|
|
3061
|
+
completion = latest.listProps.onDelete!("owned").then((succeeded) => {
|
|
3062
|
+
if (succeeded) navigate();
|
|
3063
|
+
return succeeded;
|
|
3064
|
+
});
|
|
3065
|
+
});
|
|
3066
|
+
await act(async () => {
|
|
3067
|
+
persistence.resolve(true);
|
|
3068
|
+
});
|
|
3069
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
3070
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
3071
|
+
|
|
3072
|
+
mine = [row("current")];
|
|
3073
|
+
if (invalidation === "unmount") {
|
|
3074
|
+
act(() => {
|
|
3075
|
+
root.render(null);
|
|
3076
|
+
});
|
|
3077
|
+
} else {
|
|
3078
|
+
await render({
|
|
3079
|
+
...(invalidation === "scope"
|
|
3080
|
+
? { scope: { type: "project", id: "current" } }
|
|
3081
|
+
: {}),
|
|
3082
|
+
...(invalidation === "apiUrl" ? { apiUrl: "/current-chat" } : {}),
|
|
3083
|
+
...(invalidation === "enabled" ? { enabled: false } : {}),
|
|
3084
|
+
onMutationFailed,
|
|
3085
|
+
});
|
|
3086
|
+
}
|
|
3087
|
+
const requestsAfterInvalidation = fetchMock.mock.calls.length;
|
|
3088
|
+
await act(async () => {
|
|
3089
|
+
oldMineRefresh.resolve(Response.json({ threads: [], hasMore: false }));
|
|
3090
|
+
oldSharedRefresh.resolve(
|
|
3091
|
+
Response.json({ threads: [], hasMore: false }),
|
|
3092
|
+
);
|
|
3093
|
+
expect(await completion).toBe(false);
|
|
3094
|
+
});
|
|
3095
|
+
expect(navigate).not.toHaveBeenCalled();
|
|
3096
|
+
expect(onMutationFailed).not.toHaveBeenCalled();
|
|
3097
|
+
expect(fetchMock).toHaveBeenCalledTimes(requestsAfterInvalidation);
|
|
3098
|
+
if (invalidation === "scope" || invalidation === "apiUrl") {
|
|
3099
|
+
expect(latest.mineRows.map((row) => row.id)).toEqual(["current"]);
|
|
3100
|
+
} else if (invalidation === "enabled") {
|
|
3101
|
+
expect(latest.mineRows).toEqual([]);
|
|
3102
|
+
}
|
|
3103
|
+
},
|
|
3104
|
+
);
|
|
3105
|
+
|
|
3106
|
+
it("does not let an older successful refresh clear a newer intent", async () => {
|
|
3107
|
+
mine = [row("owned")];
|
|
3108
|
+
const first = deferred<boolean>();
|
|
3109
|
+
const second = deferred<boolean>();
|
|
3110
|
+
const rename = vi
|
|
3111
|
+
.fn()
|
|
3112
|
+
.mockReturnValueOnce(first.promise)
|
|
3113
|
+
.mockReturnValueOnce(second.promise);
|
|
3114
|
+
await render({ mutations: { rename } });
|
|
3115
|
+
const oldMineRefresh = deferred<Response>();
|
|
3116
|
+
const oldSharedRefresh = deferred<Response>();
|
|
3117
|
+
let oldMutation!: Promise<boolean>;
|
|
3118
|
+
let newMutation!: Promise<boolean>;
|
|
3119
|
+
act(() => {
|
|
3120
|
+
oldMutation = latest.listProps.onRename!("owned", "First");
|
|
3121
|
+
});
|
|
3122
|
+
fetchMock
|
|
3123
|
+
.mockImplementationOnce(() => oldMineRefresh.promise)
|
|
3124
|
+
.mockImplementationOnce(() => oldSharedRefresh.promise);
|
|
3125
|
+
await act(async () => {
|
|
3126
|
+
first.resolve(true);
|
|
3127
|
+
});
|
|
3128
|
+
act(() => {
|
|
3129
|
+
newMutation = latest.listProps.onRename!("owned", "Second");
|
|
3130
|
+
});
|
|
3131
|
+
await act(async () => {
|
|
3132
|
+
oldMineRefresh.resolve(pageResponse("/threads?ownership=mine&limit=50"));
|
|
3133
|
+
oldSharedRefresh.resolve(
|
|
3134
|
+
pageResponse("/threads?ownership=shared&limit=50"),
|
|
3135
|
+
);
|
|
3136
|
+
await oldMutation;
|
|
3137
|
+
});
|
|
3138
|
+
expect(item("owned")?.title).toBe("Second");
|
|
3139
|
+
mine = [row("owned", { title: "Second" })];
|
|
3140
|
+
await act(async () => {
|
|
3141
|
+
second.resolve(true);
|
|
3142
|
+
await newMutation;
|
|
3143
|
+
});
|
|
3144
|
+
expect(item("owned")?.title).toBe("Second");
|
|
3145
|
+
});
|
|
3146
|
+
});
|