@happyvertical/smrt-chat 0.53.0 → 0.53.2
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/dist/index.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/smrt-knowledge.json +3 -3
- package/package.json +13 -13
- package/dist/__tests__/chat-test-db.d.ts +0 -8
- package/dist/__tests__/chat-test-db.d.ts.map +0 -1
- package/dist/__tests__/raw-collections.d.ts +0 -35
- package/dist/__tests__/raw-collections.d.ts.map +0 -1
- package/dist/svelte/components/agent/__tests__/ToolCallDisplay.test.js +0 -135
- package/dist/svelte/components/agent/message-blocks.test.js +0 -31
- package/dist/svelte/components/assistant/__tests__/AssistantComposer.test.js +0 -75
- package/dist/svelte/components/assistant/__tests__/AssistantDock.test.js +0 -515
- package/dist/svelte/components/assistant/__tests__/AssistantThreadList.test.js +0 -45
- package/dist/svelte/components/assistant/__tests__/action-status.test.js +0 -25
- package/dist/svelte/components/assistant/__tests__/assistant-composer-dictation.test.js +0 -197
- package/dist/svelte/components/assistant/__tests__/assistant-dock-action-outcome.test.js +0 -320
- package/dist/svelte/components/assistant/__tests__/assistant-dock-choices-pending.test.js +0 -224
- package/dist/svelte/components/assistant/__tests__/assistant-dock-choices-preview.test.js +0 -312
- package/dist/svelte/components/assistant/__tests__/assistant-dock-choices.test.js +0 -338
- package/dist/svelte/components/assistant/__tests__/assistant-dock-client-tools.test.js +0 -442
- package/dist/svelte/components/assistant/__tests__/assistant-dock-draft-settled.test.js +0 -448
- package/dist/svelte/components/assistant/__tests__/assistant-dock-host-actions.test.js +0 -185
- package/dist/svelte/components/assistant/__tests__/assistant-dock-run.test.js +0 -303
- package/dist/svelte/components/assistant/__tests__/assistant-dock-settle.test.js +0 -146
- package/dist/svelte/components/assistant/__tests__/assistant-transport.test.js +0 -253
- package/dist/svelte/components/assistant/__tests__/attachment-href.test.js +0 -31
- package/dist/svelte/components/assistant/__tests__/create-assistant-dock-controller.test.js +0 -2491
- package/dist/svelte/components/layout/__tests__/ChatLayout.test.js +0 -67
- package/dist/svelte/components/messages/__tests__/MessageInput.test.js +0 -38
- package/dist/svelte/components/messages/__tests__/MessageItem.test.js +0 -108
- package/dist/svelte/components/shared/__tests__/Avatar.test.js +0 -20
- package/dist/svelte/components/shared/__tests__/LinkPreview.test.js +0 -29
- package/dist/svelte/components/shared/__tests__/MentionAutocomplete.test.js +0 -66
- package/dist/svelte/components/shared/__tests__/ModelPicker.test.js +0 -36
- package/dist/svelte/components/shared/__tests__/ReactionPicker.test.js +0 -35
- package/dist/svelte/components/shared/__tests__/ReadReceipts.test.js +0 -28
- package/dist/svelte/components/shared/__tests__/UserPresence.test.js +0 -23
|
@@ -1,515 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* Component-level coverage for AssistantDock (#2904 review finding F1).
|
|
4
|
-
*
|
|
5
|
-
* The unit suite for `createAssistantDockController` and the smrt-svelte
|
|
6
|
-
* conformance-style integration test both drive a SEPARATE controller
|
|
7
|
-
* instance than the one the mounted component owns, so neither one could
|
|
8
|
-
* have caught F1: `AssistantDock`'s `$effect` read `$state` synchronously
|
|
9
|
-
* (via `startPolling` → `pendingSends`), so every real `send()` through the
|
|
10
|
-
* component's OWN controller re-ran the effect and its cleanup permanently
|
|
11
|
-
* unsubscribed the registry listener after the first message. This file
|
|
12
|
-
* renders the real component and drives it through its own DOM.
|
|
13
|
-
*/
|
|
14
|
-
import { createDataSurfaceRegistry, } from '@happyvertical/smrt-ui/data-surface';
|
|
15
|
-
import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
|
|
16
|
-
import { createRawSnippet } from 'svelte';
|
|
17
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
18
|
-
import AssistantDock from '../AssistantDock.svelte';
|
|
19
|
-
import { createInMemoryAssistantTransport, } from '../assistant-transport.js';
|
|
20
|
-
const identity = {
|
|
21
|
-
surfaceId: 'orders',
|
|
22
|
-
kind: 'table',
|
|
23
|
-
subject: { type: 'tenant', id: 'tenant-a' },
|
|
24
|
-
};
|
|
25
|
-
const descriptor = {
|
|
26
|
-
version: 1,
|
|
27
|
-
identity,
|
|
28
|
-
schemaVersion: 1,
|
|
29
|
-
label: 'Orders',
|
|
30
|
-
rowKey: 'id',
|
|
31
|
-
columns: [{ id: 'id', label: 'ID', capabilities: ['read'], role: 'row-key' }],
|
|
32
|
-
query: {
|
|
33
|
-
modes: ['rows'],
|
|
34
|
-
projectableColumnIds: ['id'],
|
|
35
|
-
searchableColumnIds: [],
|
|
36
|
-
filterableColumnIds: [],
|
|
37
|
-
sortableColumnIds: [],
|
|
38
|
-
},
|
|
39
|
-
actions: [],
|
|
40
|
-
controls: [],
|
|
41
|
-
limits: { maxQueryRows: 10, maxQueryBytes: 10_000, maxSelectionSize: 10 },
|
|
42
|
-
};
|
|
43
|
-
describe('AssistantDock (mounted component)', () => {
|
|
44
|
-
it('loadThreads/loadModels fire exactly once per mount, and the registry subscription survives a send (F1)', async () => {
|
|
45
|
-
const registry = createDataSurfaceRegistry();
|
|
46
|
-
const transport = createInMemoryAssistantTransport({
|
|
47
|
-
respond: (threadId, userMessage) => ({
|
|
48
|
-
id: 'assistant-1',
|
|
49
|
-
threadId,
|
|
50
|
-
content: `echo: ${userMessage.content}`,
|
|
51
|
-
role: 'assistant',
|
|
52
|
-
createdAt: new Date(),
|
|
53
|
-
}),
|
|
54
|
-
});
|
|
55
|
-
const listThreadsSpy = vi.spyOn(transport, 'listThreads');
|
|
56
|
-
render(AssistantDock, { props: { transport, registry } });
|
|
57
|
-
expect(await screen.findByText(/Nothing on this page can be changed from the chat/i)).toBeInTheDocument();
|
|
58
|
-
expect(listThreadsSpy).toHaveBeenCalledTimes(1);
|
|
59
|
-
// Create + open a thread through the UI, then send a real message
|
|
60
|
-
// through the component's OWN mounted controller.
|
|
61
|
-
await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
|
|
62
|
-
const textarea = await screen.findByLabelText('Message');
|
|
63
|
-
await userEvent.type(textarea, 'hello there');
|
|
64
|
-
const sendButton = screen.getByRole('button', { name: 'Send' });
|
|
65
|
-
await userEvent.click(sendButton);
|
|
66
|
-
expect(await screen.findByText('echo: hello there')).toBeInTheDocument();
|
|
67
|
-
// F1 regression: before the fix, the send above re-ran the mount effect
|
|
68
|
-
// and its cleanup permanently disposed the controller (unsubscribing
|
|
69
|
-
// the registry listener), so a surface registered afterward was never
|
|
70
|
-
// discovered and the "no surfaces" notice never cleared.
|
|
71
|
-
registry.register({
|
|
72
|
-
descriptor,
|
|
73
|
-
getSnapshot: () => ({ revision: 1, state: {} }),
|
|
74
|
-
});
|
|
75
|
-
// Poll until the notice clears (or fail): the registry event handler
|
|
76
|
-
// runs synchronously, so this should resolve on the very next microtask.
|
|
77
|
-
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
78
|
-
if (!screen.queryByText(/Nothing on this page can be changed from the chat/i)) {
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
81
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
82
|
-
}
|
|
83
|
-
expect(screen.queryByText(/Nothing on this page can be changed from the chat/i)).not.toBeInTheDocument();
|
|
84
|
-
// loadThreads must still have fired only once for the mount — not once
|
|
85
|
-
// per send/poll re-run of a re-triggered effect.
|
|
86
|
-
expect(listThreadsSpy).toHaveBeenCalledTimes(1);
|
|
87
|
-
});
|
|
88
|
-
// Finding B (#2904 review, third final pass): reassigning the `registry`
|
|
89
|
-
// prop (a host swapping tenant/workspace context) must be observed by the
|
|
90
|
-
// MOUNTED component's own controller, not just a freshly-constructed one.
|
|
91
|
-
// The corresponding controller-level test in
|
|
92
|
-
// create-assistant-dock-controller.test.ts asserts the harder-to-observe
|
|
93
|
-
// parts (surfaces content, previewAction rejection, preview invalidation)
|
|
94
|
-
// directly against syncRegistry(); this test proves the DOM-visible
|
|
95
|
-
// surfaces-empty notice reacts to the same prop swap through the real
|
|
96
|
-
// component, and that F1's "mount effect runs once" guarantee still
|
|
97
|
-
// holds afterward.
|
|
98
|
-
it('re-subscribes when the registry prop is reassigned to a different instance', async () => {
|
|
99
|
-
const r1 = createDataSurfaceRegistry();
|
|
100
|
-
r1.register({
|
|
101
|
-
descriptor,
|
|
102
|
-
getSnapshot: () => ({ revision: 1, state: {} }),
|
|
103
|
-
});
|
|
104
|
-
const transport = createInMemoryAssistantTransport();
|
|
105
|
-
const listThreadsSpy = vi.spyOn(transport, 'listThreads');
|
|
106
|
-
const { rerender } = render(AssistantDock, {
|
|
107
|
-
props: { transport, registry: r1 },
|
|
108
|
-
});
|
|
109
|
-
// R1 has a mounted surface — the empty notice must not show.
|
|
110
|
-
expect(screen.queryByText(/Nothing on this page can be changed from the chat/i)).not.toBeInTheDocument();
|
|
111
|
-
// Reassign the prop to a DIFFERENT, empty registry instance (R2).
|
|
112
|
-
const r2 = createDataSurfaceRegistry();
|
|
113
|
-
await rerender({ transport, registry: r2 });
|
|
114
|
-
expect(await screen.findByText(/Nothing on this page can be changed from the chat/i)).toBeInTheDocument();
|
|
115
|
-
// Registering a surface on R2 must be discovered — proves the
|
|
116
|
-
// subscription actually moved to R2, not just a one-time resync.
|
|
117
|
-
const r2Identity = { ...identity, surfaceId: 'products' };
|
|
118
|
-
r2.register({
|
|
119
|
-
descriptor: { ...descriptor, identity: r2Identity },
|
|
120
|
-
getSnapshot: () => ({ revision: 1, state: {} }),
|
|
121
|
-
});
|
|
122
|
-
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
123
|
-
if (!screen.queryByText(/Nothing on this page can be changed from the chat/i)) {
|
|
124
|
-
break;
|
|
125
|
-
}
|
|
126
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
127
|
-
}
|
|
128
|
-
expect(screen.queryByText(/Nothing on this page can be changed from the chat/i)).not.toBeInTheDocument();
|
|
129
|
-
// F1 guarantee preserved: the registry-scoped effect must not have
|
|
130
|
-
// caused the SEPARATE mount effect to re-run — but Copilot PR #2919
|
|
131
|
-
// jAwsd's fix means the swap ITSELF now legitimately triggers exactly
|
|
132
|
-
// one more loadThreads() call, via syncRegistry()'s
|
|
133
|
-
// resetConversationStateForContextSwap() reloading from the new
|
|
134
|
-
// context. Two total: one from the mount effect, one from the swap.
|
|
135
|
-
expect(listThreadsSpy).toHaveBeenCalledTimes(2);
|
|
136
|
-
});
|
|
137
|
-
// Finding 1 (#2904 review, fresh cycle): the documented `surfaces` override
|
|
138
|
-
// prop did not exist on <AssistantDock> — only reachable by constructing
|
|
139
|
-
// the controller directly. Discovery half asserted here through the DOM:
|
|
140
|
-
// the override, not the registry's live contents, decides whether the
|
|
141
|
-
// "no surfaces" notice renders.
|
|
142
|
-
it("scopes discovery to the explicit `surfaces` override, ignoring the registry's live contents", async () => {
|
|
143
|
-
const registry = createDataSurfaceRegistry();
|
|
144
|
-
// Registry has ORDERS mounted, but the override is an explicit EMPTY
|
|
145
|
-
// list — the notice must still render "no surfaces" because the
|
|
146
|
-
// override, not the registry, is authoritative (surfaces is decoupled
|
|
147
|
-
// from what's actually registered once an override is set).
|
|
148
|
-
registry.register({
|
|
149
|
-
descriptor,
|
|
150
|
-
getSnapshot: () => ({ revision: 1, state: {} }),
|
|
151
|
-
});
|
|
152
|
-
const transport = createInMemoryAssistantTransport();
|
|
153
|
-
render(AssistantDock, {
|
|
154
|
-
props: { transport, registry, surfaces: [] },
|
|
155
|
-
});
|
|
156
|
-
expect(await screen.findByText(/Nothing on this page can be changed from the chat/i)).toBeInTheDocument();
|
|
157
|
-
});
|
|
158
|
-
// Copilot PR #2919 jAwr0: `surfaces` is a NARROWING filter over the live
|
|
159
|
-
// registry — an override identity that isn't genuinely registered must
|
|
160
|
-
// NOT make discovery non-empty (that broke the documented fail-closed
|
|
161
|
-
// route scoping). Renamed from "the `surfaces` override alone makes
|
|
162
|
-
// discovery non-empty, even with nothing registered", which asserted the
|
|
163
|
-
// now-fixed behavior.
|
|
164
|
-
it('the `surfaces` override does NOT make discovery non-empty when the override identity is not registered', async () => {
|
|
165
|
-
const registry = createDataSurfaceRegistry(); // nothing registered
|
|
166
|
-
const transport = createInMemoryAssistantTransport();
|
|
167
|
-
render(AssistantDock, {
|
|
168
|
-
props: { transport, registry, surfaces: [identity] },
|
|
169
|
-
});
|
|
170
|
-
expect(await screen.findByText(/Nothing on this page can be changed from the chat/i)).toBeInTheDocument();
|
|
171
|
-
});
|
|
172
|
-
it('the `surfaces` override makes discovery non-empty only when the identity IS also registered', async () => {
|
|
173
|
-
const registry = createDataSurfaceRegistry();
|
|
174
|
-
registry.register({
|
|
175
|
-
descriptor,
|
|
176
|
-
getSnapshot: () => ({ revision: 1, state: {} }),
|
|
177
|
-
});
|
|
178
|
-
const transport = createInMemoryAssistantTransport();
|
|
179
|
-
render(AssistantDock, {
|
|
180
|
-
props: { transport, registry, surfaces: [identity] },
|
|
181
|
-
});
|
|
182
|
-
expect(screen.queryByText(/Nothing on this page can be changed from the chat/i)).not.toBeInTheDocument();
|
|
183
|
-
});
|
|
184
|
-
// Finding 4 (#2904 review, fresh cycle): a failed mount-time listThreads
|
|
185
|
-
// must render as a dock-level error, not an empty, explanation-free
|
|
186
|
-
// thread list.
|
|
187
|
-
it('renders a dock-level error when the mount-time loadThreads() call rejects', async () => {
|
|
188
|
-
const registry = createDataSurfaceRegistry();
|
|
189
|
-
const transport = createInMemoryAssistantTransport();
|
|
190
|
-
transport.listThreads = async () => {
|
|
191
|
-
throw new Error('offline');
|
|
192
|
-
};
|
|
193
|
-
render(AssistantDock, { props: { transport, registry } });
|
|
194
|
-
expect(await screen.findByText(/Something went wrong: offline/i)).toBeInTheDocument();
|
|
195
|
-
});
|
|
196
|
-
// Cycle-2 second final finding 1: the `surfaces` override was captured
|
|
197
|
-
// once at construction — a mounted component's own controller never
|
|
198
|
-
// observed a reassignment of the prop. Drives it through the real
|
|
199
|
-
// component (rerender), not just the controller directly, mirroring how
|
|
200
|
-
// Finding B's registry-swap test complements the controller-level test.
|
|
201
|
-
it('re-scopes discovery and the action gate when the `surfaces` prop is reassigned, in both directions', async () => {
|
|
202
|
-
const registry = createDataSurfaceRegistry();
|
|
203
|
-
registry.register({
|
|
204
|
-
descriptor,
|
|
205
|
-
getSnapshot: () => ({ revision: 1, state: {} }),
|
|
206
|
-
});
|
|
207
|
-
const transport = createInMemoryAssistantTransport();
|
|
208
|
-
const productsIdentity = {
|
|
209
|
-
...identity,
|
|
210
|
-
surfaceId: 'products',
|
|
211
|
-
};
|
|
212
|
-
// Copilot PR #2919 jAwr0: `surfaces` narrows against the live registry,
|
|
213
|
-
// so `products` must be genuinely registered too, or it would never
|
|
214
|
-
// pass the mount gate regardless of the override.
|
|
215
|
-
registry.register({
|
|
216
|
-
descriptor: { ...descriptor, identity: productsIdentity },
|
|
217
|
-
getSnapshot: () => ({ revision: 1, state: {} }),
|
|
218
|
-
});
|
|
219
|
-
const { rerender } = render(AssistantDock, {
|
|
220
|
-
props: { transport, registry, surfaces: [productsIdentity] },
|
|
221
|
-
});
|
|
222
|
-
// Override only includes `products`; the registered `orders` surface is
|
|
223
|
-
// gated out even though it's genuinely registered — the "no surfaces"
|
|
224
|
-
// notice must NOT show (the override list is non-empty).
|
|
225
|
-
expect(screen.queryByText(/Nothing on this page can be changed from the chat/i)).not.toBeInTheDocument();
|
|
226
|
-
// Narrow the override to an EMPTY list.
|
|
227
|
-
await rerender({ transport, registry, surfaces: [] });
|
|
228
|
-
expect(await screen.findByText(/Nothing on this page can be changed from the chat/i)).toBeInTheDocument();
|
|
229
|
-
// Widen back to include the registered `orders` surface — discovery
|
|
230
|
-
// must follow the reassignment and the notice must clear.
|
|
231
|
-
await rerender({ transport, registry, surfaces: [identity] });
|
|
232
|
-
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
233
|
-
if (!screen.queryByText(/Nothing on this page can be changed from the chat/i)) {
|
|
234
|
-
break;
|
|
235
|
-
}
|
|
236
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
237
|
-
}
|
|
238
|
-
expect(screen.queryByText(/Nothing on this page can be changed from the chat/i)).not.toBeInTheDocument();
|
|
239
|
-
});
|
|
240
|
-
// Cycle-2 second final finding 1: the F1 "mount effect runs exactly once"
|
|
241
|
-
// guarantee must hold even with the new `surfaces`-scoped effect added
|
|
242
|
-
// alongside the existing `registry` one.
|
|
243
|
-
it('loadThreads still fires exactly once per mount when `surfaces` is reassigned', async () => {
|
|
244
|
-
const registry = createDataSurfaceRegistry();
|
|
245
|
-
const transport = createInMemoryAssistantTransport();
|
|
246
|
-
const listThreadsSpy = vi.spyOn(transport, 'listThreads');
|
|
247
|
-
const { rerender } = render(AssistantDock, {
|
|
248
|
-
props: { transport, registry, surfaces: [] },
|
|
249
|
-
});
|
|
250
|
-
await screen.findByText(/Nothing on this page can be changed from the chat/i);
|
|
251
|
-
await rerender({ transport, registry, surfaces: [identity] });
|
|
252
|
-
await rerender({ transport, registry, surfaces: [] });
|
|
253
|
-
expect(listThreadsSpy).toHaveBeenCalledTimes(1);
|
|
254
|
-
});
|
|
255
|
-
// Cycle-2 second final finding 2: "+ New conversation" and thread
|
|
256
|
-
// selection previously produced an unhandled rejection with zero
|
|
257
|
-
// user-visible surface when the transport failed — the exact path a
|
|
258
|
-
// `createSmrtAssistantTransport` without `writeEndpoint` is documented to
|
|
259
|
-
// hit.
|
|
260
|
-
it('a rejecting createThread (clicking "+ New conversation") shows the dock-level error banner', async () => {
|
|
261
|
-
const registry = createDataSurfaceRegistry();
|
|
262
|
-
const transport = createInMemoryAssistantTransport();
|
|
263
|
-
transport.createThread = async () => {
|
|
264
|
-
throw new Error('no writeEndpoint configured');
|
|
265
|
-
};
|
|
266
|
-
render(AssistantDock, { props: { transport, registry } });
|
|
267
|
-
await screen.findByText(/Nothing on this page can be changed from the chat/i);
|
|
268
|
-
await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
|
|
269
|
-
expect(await screen.findByText(/Something went wrong: no writeEndpoint configured/i)).toBeInTheDocument();
|
|
270
|
-
});
|
|
271
|
-
// Cycle-2 third final: a rejecting uploadAttachment previously had no
|
|
272
|
-
// catch in AssistantDock's handleUpload, so the dock-level banner never
|
|
273
|
-
// reflected an attachment failure the way it does for send/thread
|
|
274
|
-
// failures.
|
|
275
|
-
it('a rejecting uploadAttachment shows the dock-level error banner', async () => {
|
|
276
|
-
const registry = createDataSurfaceRegistry();
|
|
277
|
-
const transport = createInMemoryAssistantTransport();
|
|
278
|
-
transport.uploadAttachment = async () => {
|
|
279
|
-
throw new Error('no writeEndpoint configured');
|
|
280
|
-
};
|
|
281
|
-
const { container } = render(AssistantDock, {
|
|
282
|
-
props: { transport, registry },
|
|
283
|
-
});
|
|
284
|
-
await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
|
|
285
|
-
await screen.findByLabelText('Message');
|
|
286
|
-
const fileInput = container.querySelector('input[type="file"]');
|
|
287
|
-
if (!fileInput)
|
|
288
|
-
throw new Error('file input not found');
|
|
289
|
-
await userEvent.upload(fileInput, new File(['data'], 'photo.png', { type: 'image/png' }));
|
|
290
|
-
expect(await screen.findByText(/Something went wrong: no writeEndpoint configured/i)).toBeInTheDocument();
|
|
291
|
-
});
|
|
292
|
-
// Cycle-3 second final finding 1: message.attachments was populated by the
|
|
293
|
-
// transport but never rendered anywhere — an uploaded, sent attachment
|
|
294
|
-
// became permanently invisible the instant the composer's chip row
|
|
295
|
-
// cleared on success.
|
|
296
|
-
it('renders an attachment chip on a message after send()', async () => {
|
|
297
|
-
const registry = createDataSurfaceRegistry();
|
|
298
|
-
const transport = createInMemoryAssistantTransport();
|
|
299
|
-
const { container } = render(AssistantDock, {
|
|
300
|
-
props: { transport, registry },
|
|
301
|
-
});
|
|
302
|
-
await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
|
|
303
|
-
const textarea = await screen.findByLabelText('Message');
|
|
304
|
-
const fileInput = container.querySelector('input[type="file"]');
|
|
305
|
-
if (!fileInput)
|
|
306
|
-
throw new Error('file input not found');
|
|
307
|
-
await userEvent.upload(fileInput, new File(['data'], 'report.pdf', { type: 'application/pdf' }));
|
|
308
|
-
await userEvent.type(textarea, 'here is the report');
|
|
309
|
-
await userEvent.click(screen.getByRole('button', { name: 'Send' }));
|
|
310
|
-
expect(await screen.findByText('report.pdf')).toBeInTheDocument();
|
|
311
|
-
});
|
|
312
|
-
it('renders an attachment chip on a message loaded via loadMessages()', async () => {
|
|
313
|
-
const registry = createDataSurfaceRegistry();
|
|
314
|
-
const transport = createInMemoryAssistantTransport();
|
|
315
|
-
const originalLoadMessages = transport.loadMessages.bind(transport);
|
|
316
|
-
transport.loadMessages = async (threadId) => {
|
|
317
|
-
const existing = await originalLoadMessages(threadId);
|
|
318
|
-
if (existing.length > 0)
|
|
319
|
-
return existing;
|
|
320
|
-
return [
|
|
321
|
-
{
|
|
322
|
-
id: 'seeded-1',
|
|
323
|
-
threadId,
|
|
324
|
-
content: 'attached earlier',
|
|
325
|
-
role: 'user',
|
|
326
|
-
createdAt: new Date(),
|
|
327
|
-
attachments: [{ id: 'att-seeded', name: 'contract.docx' }],
|
|
328
|
-
},
|
|
329
|
-
];
|
|
330
|
-
};
|
|
331
|
-
render(AssistantDock, { props: { transport, registry } });
|
|
332
|
-
await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
|
|
333
|
-
expect(await screen.findByText('contract.docx')).toBeInTheDocument();
|
|
334
|
-
});
|
|
335
|
-
// Cycle-3 second final finding 2: a `models`-bearing transport mounts
|
|
336
|
-
// ModelPicker (previously untested and unnamed anywhere in this suite);
|
|
337
|
-
// an attachment-bearing message exercises the chip/link list added for
|
|
338
|
-
// finding 1 above. Neither path had ever been axe-checked.
|
|
339
|
-
it('is axe-clean with ModelPicker mounted and an attachment-bearing message', async () => {
|
|
340
|
-
const registry = createDataSurfaceRegistry();
|
|
341
|
-
const transport = createInMemoryAssistantTransport({
|
|
342
|
-
models: [
|
|
343
|
-
{ id: 'model-a', label: 'Model A' },
|
|
344
|
-
{ id: 'model-b', label: 'Model B' },
|
|
345
|
-
],
|
|
346
|
-
});
|
|
347
|
-
const { container } = render(AssistantDock, {
|
|
348
|
-
props: { transport, registry },
|
|
349
|
-
});
|
|
350
|
-
await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
|
|
351
|
-
const textarea = await screen.findByLabelText('Message');
|
|
352
|
-
const fileInput = container.querySelector('input[type="file"]');
|
|
353
|
-
if (!fileInput)
|
|
354
|
-
throw new Error('file input not found');
|
|
355
|
-
await userEvent.upload(fileInput, new File(['data'], 'report.pdf', { type: 'application/pdf' }));
|
|
356
|
-
await userEvent.type(textarea, 'here is the report');
|
|
357
|
-
await userEvent.click(screen.getByRole('button', { name: 'Send' }));
|
|
358
|
-
await screen.findByText('report.pdf');
|
|
359
|
-
// ModelPicker must have mounted (its accessible name resolves).
|
|
360
|
-
expect(screen.getByLabelText('Model')).toBeInTheDocument();
|
|
361
|
-
await expectNoA11yViolations(container);
|
|
362
|
-
});
|
|
363
|
-
// Cycle-3 second final F1 addendum: attachment.url is transport-supplied
|
|
364
|
-
// data bound to <a href> — a javascript: URL must never render a
|
|
365
|
-
// clickable anchor.
|
|
366
|
-
it('renders a javascript: attachment URL as plain text, not an anchor', async () => {
|
|
367
|
-
const registry = createDataSurfaceRegistry();
|
|
368
|
-
const transport = createInMemoryAssistantTransport();
|
|
369
|
-
const originalLoadMessages = transport.loadMessages.bind(transport);
|
|
370
|
-
transport.loadMessages = async (threadId) => {
|
|
371
|
-
const existing = await originalLoadMessages(threadId);
|
|
372
|
-
if (existing.length > 0)
|
|
373
|
-
return existing;
|
|
374
|
-
return [
|
|
375
|
-
{
|
|
376
|
-
id: 'seeded-xss',
|
|
377
|
-
threadId,
|
|
378
|
-
content: 'attached earlier',
|
|
379
|
-
role: 'user',
|
|
380
|
-
createdAt: new Date(),
|
|
381
|
-
attachments: [
|
|
382
|
-
{
|
|
383
|
-
id: 'att-xss',
|
|
384
|
-
name: 'evil.txt',
|
|
385
|
-
url: 'javascript:alert(1)',
|
|
386
|
-
},
|
|
387
|
-
],
|
|
388
|
-
},
|
|
389
|
-
];
|
|
390
|
-
};
|
|
391
|
-
render(AssistantDock, { props: { transport, registry } });
|
|
392
|
-
await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
|
|
393
|
-
const attachmentText = await screen.findByText('evil.txt');
|
|
394
|
-
expect(attachmentText.closest('a')).toBeNull();
|
|
395
|
-
});
|
|
396
|
-
// #3000: in a narrow container the thread list collapses behind a
|
|
397
|
-
// "Conversations" disclosure (the `@container` rule decides visibility;
|
|
398
|
-
// jsdom has no layout, so this pins the state + ARIA contract the CSS
|
|
399
|
-
// keys off). Widths are measured in a real browser, see the PR.
|
|
400
|
-
it('exposes a labelled Conversations disclosure that controls the thread list and closes when a conversation starts (#3000)', async () => {
|
|
401
|
-
const registry = createDataSurfaceRegistry();
|
|
402
|
-
const transport = createInMemoryAssistantTransport();
|
|
403
|
-
const { container } = render(AssistantDock, {
|
|
404
|
-
props: { transport, registry },
|
|
405
|
-
});
|
|
406
|
-
const toggle = screen.getByRole('button', { name: 'Conversations' });
|
|
407
|
-
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
|
408
|
-
const controlsId = toggle.getAttribute('aria-controls');
|
|
409
|
-
expect(controlsId).toBeTruthy();
|
|
410
|
-
const region = container.querySelector(`#${CSS.escape(controlsId ?? '')}`);
|
|
411
|
-
expect(region).not.toBeNull();
|
|
412
|
-
expect(region?.querySelector('nav[aria-label="Assistant conversations"]')).not.toBeNull();
|
|
413
|
-
const layout = container.querySelector('.assistant-dock-layout');
|
|
414
|
-
expect(layout).not.toHaveAttribute('data-threads-open');
|
|
415
|
-
await userEvent.click(toggle);
|
|
416
|
-
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
|
417
|
-
expect(layout).toHaveAttribute('data-threads-open');
|
|
418
|
-
toggle.focus();
|
|
419
|
-
await userEvent.keyboard('{Enter}');
|
|
420
|
-
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
|
421
|
-
await userEvent.keyboard(' ');
|
|
422
|
-
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
|
423
|
-
// Starting a conversation from the open list hands the width back.
|
|
424
|
-
await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
|
|
425
|
-
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
|
426
|
-
expect(layout).not.toHaveAttribute('data-threads-open');
|
|
427
|
-
// The activated button is now inside a hidden region; focus returns to
|
|
428
|
-
// the toggle instead of falling back to <body>.
|
|
429
|
-
await vi.waitFor(() => expect(document.activeElement).toBe(toggle));
|
|
430
|
-
await expectNoA11yViolations(container);
|
|
431
|
-
});
|
|
432
|
-
it('closes the narrow list and returns focus to the toggle when an existing thread is selected (#3000)', async () => {
|
|
433
|
-
const registry = createDataSurfaceRegistry();
|
|
434
|
-
const transport = createInMemoryAssistantTransport();
|
|
435
|
-
await transport.createThread('Existing chat');
|
|
436
|
-
render(AssistantDock, { props: { transport, registry } });
|
|
437
|
-
const toggle = screen.getByRole('button', { name: 'Conversations' });
|
|
438
|
-
await userEvent.click(toggle);
|
|
439
|
-
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
|
440
|
-
await userEvent.click(await screen.findByRole('button', { name: /Existing chat/ }));
|
|
441
|
-
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
|
442
|
-
await vi.waitFor(() => expect(document.activeElement).toBe(toggle));
|
|
443
|
-
});
|
|
444
|
-
// #2988: a host renders a message's own toolCallData through the
|
|
445
|
-
// `toolCall` snippet; without one the dock never renders the payload.
|
|
446
|
-
describe('toolCall snippet (#2988)', () => {
|
|
447
|
-
const hostile = '<img src=x onerror="window.__pwned=1">';
|
|
448
|
-
function transportWithToolCall() {
|
|
449
|
-
return createInMemoryAssistantTransport({
|
|
450
|
-
respond: (threadId, userMessage) => ({
|
|
451
|
-
id: `assistant-${userMessage.id}`,
|
|
452
|
-
threadId,
|
|
453
|
-
content: userMessage.content === 'plain' ? 'no tool' : 'with tool',
|
|
454
|
-
role: 'assistant',
|
|
455
|
-
createdAt: new Date(),
|
|
456
|
-
toolCallData: userMessage.content === 'plain'
|
|
457
|
-
? undefined
|
|
458
|
-
: { kind: 'imageEditCandidate', label: hostile },
|
|
459
|
-
}),
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
async function sendThroughDock(text) {
|
|
463
|
-
const textarea = await screen.findByLabelText('Message');
|
|
464
|
-
await userEvent.type(textarea, text);
|
|
465
|
-
await userEvent.click(screen.getByRole('button', { name: 'Send' }));
|
|
466
|
-
}
|
|
467
|
-
it('renders the host snippet inside the bubble only for messages with toolCallData', async () => {
|
|
468
|
-
const seen = [];
|
|
469
|
-
const toolCall = createRawSnippet((message) => ({
|
|
470
|
-
render: () => '<span class="host-tool-call"></span>',
|
|
471
|
-
setup(node) {
|
|
472
|
-
const m = message();
|
|
473
|
-
seen.push(m);
|
|
474
|
-
const data = m.toolCallData;
|
|
475
|
-
node.textContent = `${data.kind}: ${data.label}`;
|
|
476
|
-
},
|
|
477
|
-
}));
|
|
478
|
-
const { container } = render(AssistantDock, {
|
|
479
|
-
props: {
|
|
480
|
-
transport: transportWithToolCall(),
|
|
481
|
-
registry: createDataSurfaceRegistry(),
|
|
482
|
-
toolCall,
|
|
483
|
-
},
|
|
484
|
-
});
|
|
485
|
-
await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
|
|
486
|
-
await sendThroughDock('plain');
|
|
487
|
-
expect(await screen.findByText('no tool')).toBeInTheDocument();
|
|
488
|
-
await sendThroughDock('tool');
|
|
489
|
-
expect(await screen.findByText('with tool')).toBeInTheDocument();
|
|
490
|
-
const regions = container.querySelectorAll('.assistant-dock-tool-call');
|
|
491
|
-
expect(regions).toHaveLength(1);
|
|
492
|
-
expect(regions[0].textContent).toBe(`imageEditCandidate: ${hostile}`);
|
|
493
|
-
// Rendered as text, never parsed as markup.
|
|
494
|
-
expect(container.querySelector('img')).toBeNull();
|
|
495
|
-
expect(regions[0]
|
|
496
|
-
.closest('li')
|
|
497
|
-
?.querySelector('.assistant-dock-message-content')?.textContent).toBe('with tool');
|
|
498
|
-
expect(seen.every((m) => m.toolCallData != null)).toBe(true);
|
|
499
|
-
});
|
|
500
|
-
it('renders no tool-call region and never the raw payload without a snippet', async () => {
|
|
501
|
-
const { container } = render(AssistantDock, {
|
|
502
|
-
props: {
|
|
503
|
-
transport: transportWithToolCall(),
|
|
504
|
-
registry: createDataSurfaceRegistry(),
|
|
505
|
-
},
|
|
506
|
-
});
|
|
507
|
-
await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
|
|
508
|
-
await sendThroughDock('tool');
|
|
509
|
-
expect(await screen.findByText('with tool')).toBeInTheDocument();
|
|
510
|
-
expect(container.querySelector('.assistant-dock-tool-call')).toBeNull();
|
|
511
|
-
expect(container.innerHTML).not.toContain('imageEditCandidate');
|
|
512
|
-
expect(container.querySelector('img')).toBeNull();
|
|
513
|
-
});
|
|
514
|
-
});
|
|
515
|
-
});
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
/**
|
|
3
|
-
* Component-level coverage for AssistantThreadList (#2904 review, cycle-3
|
|
4
|
-
* second final F2 — none of the three new assistant components carried an
|
|
5
|
-
* axe assertion; this one had no test file at all).
|
|
6
|
-
*/
|
|
7
|
-
import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
|
|
8
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
-
import AssistantThreadList from '../AssistantThreadList.svelte';
|
|
10
|
-
const threads = [
|
|
11
|
-
{ id: 't1', title: 'Order question', isResolved: false, messageCount: 3 },
|
|
12
|
-
{ id: 't2', title: '', isResolved: false, messageCount: 0 },
|
|
13
|
-
];
|
|
14
|
-
describe('AssistantThreadList', () => {
|
|
15
|
-
it('renders each thread and fires onselect with its id', async () => {
|
|
16
|
-
const onselect = vi.fn();
|
|
17
|
-
render(AssistantThreadList, {
|
|
18
|
-
props: { threads, activeThreadId: 't1', onselect },
|
|
19
|
-
});
|
|
20
|
-
expect(screen.getByText('Order question')).toBeInTheDocument();
|
|
21
|
-
// Untitled fallback for a thread with an empty title.
|
|
22
|
-
expect(screen.getByText('Untitled')).toBeInTheDocument();
|
|
23
|
-
await userEvent.click(screen.getByText('Untitled'));
|
|
24
|
-
expect(onselect).toHaveBeenCalledWith('t2');
|
|
25
|
-
});
|
|
26
|
-
it('renders a "New conversation" row and fires oncreate when supplied', async () => {
|
|
27
|
-
const oncreate = vi.fn();
|
|
28
|
-
render(AssistantThreadList, {
|
|
29
|
-
props: { threads: [], onselect: vi.fn(), oncreate },
|
|
30
|
-
});
|
|
31
|
-
await userEvent.click(screen.getByRole('button', { name: /New conversation/i }));
|
|
32
|
-
expect(oncreate).toHaveBeenCalledOnce();
|
|
33
|
-
});
|
|
34
|
-
it('is axe-clean', async () => {
|
|
35
|
-
const { container } = render(AssistantThreadList, {
|
|
36
|
-
props: {
|
|
37
|
-
threads,
|
|
38
|
-
activeThreadId: 't1',
|
|
39
|
-
onselect: vi.fn(),
|
|
40
|
-
oncreate: vi.fn(),
|
|
41
|
-
},
|
|
42
|
-
});
|
|
43
|
-
await expectNoA11yViolations(container);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit coverage for toolCallStatusForAction (#2904 review, Copilot PR #2919
|
|
3
|
-
* jAwua/jAwtb) — both AssistantDock.svelte and the demo route previously let
|
|
4
|
-
* `status === 'previewing'` fall through to 'success', showing "Completed"
|
|
5
|
-
* while a preview request was still in flight or about to fail.
|
|
6
|
-
*/
|
|
7
|
-
import { describe, expect, it } from 'vitest';
|
|
8
|
-
import { toolCallStatusForAction } from '../action-status.js';
|
|
9
|
-
describe('toolCallStatusForAction', () => {
|
|
10
|
-
it('maps previewing to running', () => {
|
|
11
|
-
expect(toolCallStatusForAction('previewing')).toBe('running');
|
|
12
|
-
});
|
|
13
|
-
it('maps applying to running', () => {
|
|
14
|
-
expect(toolCallStatusForAction('applying')).toBe('running');
|
|
15
|
-
});
|
|
16
|
-
it('maps failed to error', () => {
|
|
17
|
-
expect(toolCallStatusForAction('failed')).toBe('error');
|
|
18
|
-
});
|
|
19
|
-
it('maps previewed to success', () => {
|
|
20
|
-
expect(toolCallStatusForAction('previewed')).toBe('success');
|
|
21
|
-
});
|
|
22
|
-
it('maps applied to success', () => {
|
|
23
|
-
expect(toolCallStatusForAction('applied')).toBe('success');
|
|
24
|
-
});
|
|
25
|
-
});
|