main
py 215 lines 7.5 KB
Raw
1 import base64
2 from pathlib import Path
3 import shutil
4 import subprocess
5
6 import pytest
7
8
9 PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 CHATS_STORE_JS = (
11 PROJECT_ROOT / "webui" / "components" / "sidebar" / "chats" / "chats-store.js"
12 )
13
14
15 def test_chat_deletion_is_optimistic_and_rejects_stale_snapshots() -> None:
16 if not shutil.which("node"):
17 pytest.skip("Node.js is required to execute the chat-deletion regression.")
18
19 source = CHATS_STORE_JS.read_text(encoding="utf-8")
20 model_start = source.index("const model =")
21 store_start = source.index('const store = createStore("chats", model);')
22 model_source = source[model_start:store_start] + "\nexport { model };\n"
23 stubs = """
24 const callJsonApi = (...args) => globalThis.__callJsonApi(...args);
25 const sendJsonData = (...args) => globalThis.__sendJsonData(...args);
26 const getContext = () => globalThis.__context;
27 const setContext = (id) => { globalThis.__context = id; };
28 const toastFetchError = (...args) => globalThis.__toastFetchError(...args);
29 const toast = () => {};
30 const justToast = (...args) => globalThis.__justToast(...args);
31 const getConnectionStatus = () => true;
32 const notificationStore = {};
33 const sidebarStore = { sortRows: (_kind, rows) => [...rows] };
34 const tasksStore = { tasks: [] };
35 const syncStore = { mode: "HEALTHY" };
36 const chatInputStore = {};
37 """
38 module_url = "data:text/javascript;base64," + base64.b64encode(
39 (stubs + model_source).encode("utf-8")
40 ).decode("ascii")
41 script = f"""
42 globalThis.sessionStorage = {{
43 values: new Map(),
44 getItem(key) {{ return this.values.get(key) ?? null; }},
45 setItem(key, value) {{ this.values.set(key, String(value)); }},
46 removeItem(key) {{ this.values.delete(key); }},
47 }};
48 globalThis.__callJsonApi = async () => ({{}});
49 globalThis.__toastFetchError = () => {{}};
50 globalThis.__justToast = () => {{}};
51
52 const {{ model }} = await import({module_url!r});
53
54 function assert(condition, message) {{
55 if (!condition) throw new Error(message);
56 }}
57
58 function reset(contexts, selected) {{
59 model.contexts = contexts.map((context) => ({{ ...context }}));
60 model.contextsJson = "";
61 model.selected = selected;
62 model.selectedContext = model.contexts.find((context) => context.id === selected);
63 model.deletedContextIds = {{}};
64 globalThis.__context = selected;
65 }}
66
67 const chats = [
68 {{ id: "a", created_at: 30 }},
69 {{ id: "b", created_at: 20 }},
70 {{ id: "c", created_at: 10 }},
71 ];
72
73 reset(chats, "");
74 model.applyContexts(chats);
75 const unchangedContexts = model.contexts;
76 model.applyContexts(chats.map((context) => ({{ ...context }})));
77 assert(
78 model.contexts === unchangedContexts,
79 "unchanged snapshots must preserve the Alpine contexts array",
80 );
81 const unchangedFirstRow = model.contexts[0];
82 model.applyContexts([{{ ...chats[0], name: "Renamed" }}, ...chats.slice(1)]);
83 assert(
84 model.contexts === unchangedContexts &&
85 model.contexts[0] === unchangedFirstRow &&
86 model.contexts[0].name === "Renamed",
87 "changed context metadata must update its existing row in place",
88 );
89 model.applyContexts([...chats, {{ id: "d", created_at: 40 }}]);
90 assert(
91 model.contexts !== unchangedContexts && model.contexts[0].id === "d",
92 "structural context changes must replace and reorder the contexts array",
93 );
94
95 const tree = [
96 {{ id: "parent", created_at: 20 }},
97 {{ id: "child", parent_context_id: "parent", created_at: 10 }},
98 ];
99 reset(tree, "");
100 model.applyContexts(tree);
101 const unchangedTree = model.contexts;
102 model.selected = "parent";
103 model.expandedParents = {{}};
104 model.applyContexts(tree.map((context) => ({{ ...context }})));
105 assert(model.contexts === unchangedTree, "unchanged chat trees must preserve identity");
106 assert(
107 model.expandedParents.parent === true,
108 "selection synchronization must still run for unchanged contexts",
109 );
110
111 reset(chats, "b");
112 globalThis.__context = "a";
113 await model.selectChat("a");
114 assert(
115 model.selected === "a" && model.selectedContext?.id === "a",
116 "selection state must catch up when the low-level context already switched",
117 );
118
119 let resolveDelete;
120 globalThis.__sendJsonData = () => new Promise((resolve) => {{ resolveDelete = resolve; }});
121 reset(chats, "a");
122 const deletion = model.killChat("a");
123
124 assert(
125 model.contexts.map((context) => context.id).join(",") === "b,c",
126 "the deleted row must disappear before the request completes",
127 );
128 assert(model.selected === "b", "fallback selection must happen in the same turn");
129 assert(model.deletedContextIds.a === true, "the in-flight delete needs a tombstone");
130 await model.selectChat("a");
131 assert(
132 model.selected === "b" && globalThis.__context === "b",
133 "a queued click must not navigate back to a context being deleted",
134 );
135
136 model.applyContexts(chats);
137 assert(
138 model.contexts.map((context) => context.id).join(",") === "b,c",
139 "an in-flight stale snapshot must not restore the deleted row",
140 );
141
142 await new Promise((resolve) => setTimeout(resolve, 0));
143 assert(typeof resolveDelete === "function", "the delete request should be in flight");
144 resolveDelete({{ message: "Context removed." }});
145 await deletion;
146 assert(model.deletedContextIds.a === true, "the tombstone must survive the HTTP acknowledgement");
147
148 model.applyContexts(chats);
149 assert(
150 model.contexts.map((context) => context.id).join(",") === "b,c",
151 "a stale post-acknowledgement snapshot must remain filtered",
152 );
153 model.applyContexts(chats.slice(1));
154 assert(
155 model.deletedContextIds.a === true,
156 "an absent snapshot must not retire the tombstone while older polls can still arrive",
157 );
158 model.applyContexts(chats);
159 assert(
160 model.contexts.map((context) => context.id).join(",") === "b,c",
161 "an older present snapshot arriving after an absent one must remain filtered",
162 );
163
164 const rapidDeleteResolvers = {{}};
165 globalThis.__sendJsonData = (_url, payload) => new Promise((resolve) => {{
166 rapidDeleteResolvers[payload.context] = resolve;
167 }});
168 reset(chats, "a");
169 const deleteA = model.killChat("a");
170 await new Promise((resolve) => setTimeout(resolve, 0));
171 const deleteB = model.killChat("b");
172 assert(
173 model.contexts.map((context) => context.id).join(",") === "c",
174 "rapid deletes must remove every pending row immediately",
175 );
176 assert(model.selected === "c", "rapid selected-chat deletes must advance without a stale row");
177 model.applyContexts(chats);
178 assert(
179 model.contexts.map((context) => context.id).join(",") === "c",
180 "one stale snapshot must not reinsert any concurrently deleted row",
181 );
182 await new Promise((resolve) => setTimeout(resolve, 0));
183 rapidDeleteResolvers.b({{ message: "Context removed." }});
184 rapidDeleteResolvers.a({{ message: "Context removed." }});
185 await Promise.all([deleteA, deleteB]);
186 model.applyContexts(chats.slice(2));
187 assert(
188 model.deletedContextIds.a === true && model.deletedContextIds.b === true,
189 "rapid-delete tombstones must survive an absent snapshot",
190 );
191 model.applyContexts(chats);
192 assert(
193 model.contexts.map((context) => context.id).join(",") === "c",
194 "late snapshots must not reinsert any rapidly deleted row",
195 );
196
197 globalThis.__sendJsonData = async () => {{ throw new Error("delete failed"); }};
198 reset(chats, "a");
199 const originalConsoleError = console.error;
200 console.error = () => {{}};
201 await model.killChat("a");
202 console.error = originalConsoleError;
203 assert(
204 model.contexts.map((context) => context.id).join(",") === "a,b,c",
205 "a failed delete must restore the optimistically removed row",
206 );
207 assert(!model.deletedContextIds.a, "a failed delete must clear its tombstone");
208 assert(model.selected === "b", "rollback must not override the fallback or a later user selection");
209 """
210
211 subprocess.run(
212 ["node", "--input-type=module", "-e", script],
213 check=True,
214 text=True,
215 )