@agent-native/toolkit 0.211.0 → 0.212.0-nightly-20261021010049

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.
Files changed (143) hide show
  1. package/agent-native.eject.json +1 -0
  2. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  3. package/dist/app/i18n/catalogs/ar-SA.js +23 -0
  4. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  5. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  6. package/dist/app/i18n/catalogs/de-DE.js +23 -0
  7. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  8. package/dist/app/i18n/catalogs/en-US.d.ts +23 -0
  9. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  10. package/dist/app/i18n/catalogs/en-US.js +23 -0
  11. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  12. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  13. package/dist/app/i18n/catalogs/es-ES.js +23 -0
  14. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  15. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  16. package/dist/app/i18n/catalogs/fr-FR.js +23 -0
  17. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  18. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  19. package/dist/app/i18n/catalogs/hi-IN.js +23 -0
  20. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  21. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  22. package/dist/app/i18n/catalogs/ja-JP.js +23 -0
  23. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  24. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  25. package/dist/app/i18n/catalogs/ko-KR.js +23 -0
  26. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  27. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  28. package/dist/app/i18n/catalogs/pt-BR.js +23 -0
  29. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  30. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  31. package/dist/app/i18n/catalogs/zh-CN.js +23 -0
  32. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  33. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  34. package/dist/app/i18n/catalogs/zh-TW.js +23 -0
  35. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  36. package/dist/app/sharing/OwnershipFilter.d.ts +8 -0
  37. package/dist/app/sharing/OwnershipFilter.d.ts.map +1 -0
  38. package/dist/app/sharing/OwnershipFilter.js +20 -0
  39. package/dist/app/sharing/OwnershipFilter.js.map +1 -0
  40. package/dist/app/sharing/ShareButton.d.ts +43 -1
  41. package/dist/app/sharing/ShareButton.d.ts.map +1 -1
  42. package/dist/app/sharing/ShareButton.js +102 -63
  43. package/dist/app/sharing/ShareButton.js.map +1 -1
  44. package/dist/app/sharing/ShareDialog.d.ts +3 -1
  45. package/dist/app/sharing/ShareDialog.d.ts.map +1 -1
  46. package/dist/app/sharing/ShareDialog.js +18 -12
  47. package/dist/app/sharing/ShareDialog.js.map +1 -1
  48. package/dist/app/sharing/ShareVisibilityConfirmDialog.d.ts +20 -0
  49. package/dist/app/sharing/ShareVisibilityConfirmDialog.d.ts.map +1 -0
  50. package/dist/app/sharing/ShareVisibilityConfirmDialog.js +57 -0
  51. package/dist/app/sharing/ShareVisibilityConfirmDialog.js.map +1 -0
  52. package/dist/app/sharing/VisibilityBadge.d.ts +8 -1
  53. package/dist/app/sharing/VisibilityBadge.d.ts.map +1 -1
  54. package/dist/app/sharing/VisibilityBadge.js +16 -1
  55. package/dist/app/sharing/VisibilityBadge.js.map +1 -1
  56. package/dist/app/sharing/index.d.ts +2 -0
  57. package/dist/app/sharing/index.d.ts.map +1 -1
  58. package/dist/app/sharing/index.js +2 -0
  59. package/dist/app/sharing/index.js.map +1 -1
  60. package/dist/chat-history/ChatHistoryList.d.ts +19 -0
  61. package/dist/chat-history/ChatHistoryList.d.ts.map +1 -1
  62. package/dist/chat-history/ChatHistoryList.js +19 -7
  63. package/dist/chat-history/ChatHistoryList.js.map +1 -1
  64. package/dist/chat-history/ChatHistoryRail.d.ts +55 -8
  65. package/dist/chat-history/ChatHistoryRail.d.ts.map +1 -1
  66. package/dist/chat-history/ChatHistoryRail.js +33 -7
  67. package/dist/chat-history/ChatHistoryRail.js.map +1 -1
  68. package/dist/chat-history/index.d.ts +3 -3
  69. package/dist/chat-history/index.d.ts.map +1 -1
  70. package/dist/chat-history/index.js +1 -1
  71. package/dist/chat-history/index.js.map +1 -1
  72. package/dist/chat-history/useChatHistoryRailController.d.ts +44 -0
  73. package/dist/chat-history/useChatHistoryRailController.d.ts.map +1 -1
  74. package/dist/chat-history/useChatHistoryRailController.js +46 -0
  75. package/dist/chat-history/useChatHistoryRailController.js.map +1 -1
  76. package/dist/chat-history.css +29 -0
  77. package/dist/composer/TiptapComposer.d.ts +7 -0
  78. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  79. package/dist/composer/TiptapComposer.js +15 -2
  80. package/dist/composer/TiptapComposer.js.map +1 -1
  81. package/dist/sharing/OwnershipFilter.d.ts +21 -0
  82. package/dist/sharing/OwnershipFilter.d.ts.map +1 -0
  83. package/dist/sharing/OwnershipFilter.js +30 -0
  84. package/dist/sharing/OwnershipFilter.js.map +1 -0
  85. package/dist/sharing/VisibilityBadge.d.ts +12 -2
  86. package/dist/sharing/VisibilityBadge.d.ts.map +1 -1
  87. package/dist/sharing/VisibilityBadge.js +13 -4
  88. package/dist/sharing/VisibilityBadge.js.map +1 -1
  89. package/dist/sharing/index.d.ts +2 -1
  90. package/dist/sharing/index.d.ts.map +1 -1
  91. package/dist/sharing/index.js +2 -1
  92. package/dist/sharing/index.js.map +1 -1
  93. package/dist/ui/alert-dialog.d.ts +2 -0
  94. package/dist/ui/alert-dialog.d.ts.map +1 -1
  95. package/dist/ui/alert-dialog.js +1 -1
  96. package/dist/ui/alert-dialog.js.map +1 -1
  97. package/dist/ui/tooltip.d.ts +6 -1
  98. package/dist/ui/tooltip.d.ts.map +1 -1
  99. package/dist/ui/tooltip.js +13 -1
  100. package/dist/ui/tooltip.js.map +1 -1
  101. package/package.json +4 -4
  102. package/src/app/i18n/catalogs/ar-SA.ts +26 -0
  103. package/src/app/i18n/catalogs/de-DE.ts +25 -0
  104. package/src/app/i18n/catalogs/en-US.ts +25 -0
  105. package/src/app/i18n/catalogs/es-ES.ts +25 -0
  106. package/src/app/i18n/catalogs/fr-FR.ts +25 -0
  107. package/src/app/i18n/catalogs/hi-IN.ts +25 -0
  108. package/src/app/i18n/catalogs/ja-JP.ts +25 -0
  109. package/src/app/i18n/catalogs/ko-KR.ts +25 -0
  110. package/src/app/i18n/catalogs/pt-BR.ts +25 -0
  111. package/src/app/i18n/catalogs/zh-CN.ts +25 -0
  112. package/src/app/i18n/catalogs/zh-TW.ts +25 -0
  113. package/src/app/sharing/OwnershipFilter.tsx +35 -0
  114. package/src/app/sharing/ShareButton.tsx +384 -259
  115. package/src/app/sharing/ShareDialog.spec.tsx +50 -0
  116. package/src/app/sharing/ShareDialog.tsx +51 -15
  117. package/src/app/sharing/ShareVisibilityConfirmDialog.spec.tsx +138 -0
  118. package/src/app/sharing/ShareVisibilityConfirmDialog.tsx +124 -0
  119. package/src/app/sharing/ShareVisibilityKeyboard.spec.tsx +244 -0
  120. package/src/app/sharing/VisibilityBadge.spec.tsx +65 -0
  121. package/src/app/sharing/VisibilityBadge.tsx +31 -3
  122. package/src/app/sharing/index.ts +2 -0
  123. package/src/app/sharing/share-visibility-mutation-lifecycle.spec.tsx +1107 -0
  124. package/src/app/sharing/useShareButtonController.spec.tsx +226 -37
  125. package/src/app/sharing/useShareDialogController.spec.tsx +357 -9
  126. package/src/chat-history/ChatHistoryList.spec.tsx +157 -0
  127. package/src/chat-history/ChatHistoryList.tsx +104 -10
  128. package/src/chat-history/ChatHistoryRail.spec.tsx +50 -1
  129. package/src/chat-history/ChatHistoryRail.tsx +177 -26
  130. package/src/chat-history/ChatHistoryRailSections.spec.tsx +410 -0
  131. package/src/chat-history/index.ts +8 -0
  132. package/src/chat-history/useChatHistoryRailController.ts +94 -0
  133. package/src/chat-history/useChatHistoryRailSectionsController.spec.tsx +133 -0
  134. package/src/chat-history.css +29 -0
  135. package/src/composer/TiptapComposer.spec.ts +19 -0
  136. package/src/composer/TiptapComposer.tsx +26 -5
  137. package/src/sharing/OwnershipFilter.spec.tsx +108 -0
  138. package/src/sharing/OwnershipFilter.tsx +103 -0
  139. package/src/sharing/VisibilityBadge.spec.ts +53 -35
  140. package/src/sharing/VisibilityBadge.tsx +23 -3
  141. package/src/sharing/index.ts +11 -0
  142. package/src/ui/alert-dialog.tsx +7 -2
  143. package/src/ui/tooltip.tsx +22 -1
@@ -0,0 +1,1107 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import {
4
+ useShareButtonController,
5
+ type ShareButtonController,
6
+ } from "@agent-native/core/client/sharing/useShareButtonController";
7
+ import {
8
+ useShareDialogController,
9
+ type ResourceSharesResponse,
10
+ type ShareDialogController,
11
+ type ShareVisibility,
12
+ } from "@agent-native/core/client/sharing/useShareDialogController";
13
+ import { ShareVisibilityNotSavedError } from "@agent-native/core/client/sharing/useShareVisibilityTransition";
14
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
15
+ import React, { act, useState } from "react";
16
+ import { createRoot, type Root } from "react-dom/client";
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+
19
+ // These specs drive the real TanStack `useMutation` / `useQuery` observers
20
+ // behind a controllable transport. A per-call `mutate(vars, { onSuccess })`
21
+ // callback only fires for the observer's latest call and only while it is
22
+ // mounted, so code built on those callbacks leaves the promise it returned
23
+ // pending after a newer write or an unmount. Nothing here invokes those
24
+ // callbacks by hand: the library decides, which is the behavior under test.
25
+ const transport = vi.hoisted(() => {
26
+ interface WriteCall {
27
+ resourceId: string;
28
+ visibility: "private" | "org" | "public";
29
+ resolve: (visibility?: "private" | "org" | "public") => void;
30
+ reject: (error: Error) => void;
31
+ }
32
+ return {
33
+ server: { visibility: "private" as "private" | "org" | "public" },
34
+ resources: new Map<string, "private" | "org" | "public">(),
35
+ reads: [] as string[],
36
+ pauseReads: false,
37
+ readFailure: null as Error | null,
38
+ releaseReads: [] as Array<() => void>,
39
+ shared: vi.fn(),
40
+ writes: [] as WriteCall[],
41
+ };
42
+ });
43
+
44
+ vi.mock("@agent-native/core/client/use-action", async () => {
45
+ const { useMutation, useQuery } = await import("@tanstack/react-query");
46
+ return {
47
+ useActionQuery: (
48
+ name: string,
49
+ params: { resourceId?: string },
50
+ options?: { enabled?: boolean; staleTime?: number },
51
+ ) =>
52
+ useQuery({
53
+ queryKey: ["action", name, params],
54
+ queryFn: async () => {
55
+ if (name !== "list-resource-shares") return [];
56
+ const id = params.resourceId ?? "doc-1";
57
+ transport.reads.push(id);
58
+ if (transport.readFailure) throw transport.readFailure;
59
+ const data: ResourceSharesResponse = {
60
+ ownerEmail: "owner@example.test",
61
+ orgId: "org-1",
62
+ visibility:
63
+ id === "doc-1"
64
+ ? transport.server.visibility
65
+ : (transport.resources.get(id) ?? "private"),
66
+ role: "owner",
67
+ shares: [],
68
+ policy: { allowPublic: true },
69
+ };
70
+ return transport.pauseReads
71
+ ? new Promise<ResourceSharesResponse>((resolve) =>
72
+ transport.releaseReads.push(() => resolve(data)),
73
+ )
74
+ : data;
75
+ },
76
+ ...options,
77
+ }),
78
+ useActionMutation: (name: string) =>
79
+ useMutation({
80
+ mutationFn: (variables: {
81
+ resourceId: string;
82
+ visibility: ShareVisibility;
83
+ }) => {
84
+ if (name !== "set-resource-visibility") {
85
+ return Promise.reject(new Error(`Unexpected write: ${name}`));
86
+ }
87
+ return new Promise<{ visibility: ShareVisibility }>(
88
+ (resolve, reject) => {
89
+ transport.writes.push({
90
+ resourceId: variables.resourceId,
91
+ visibility: variables.visibility,
92
+ resolve: (visibility = variables.visibility) => {
93
+ if (variables.resourceId === "doc-1")
94
+ transport.server.visibility = visibility;
95
+ else
96
+ transport.resources.set(variables.resourceId, visibility);
97
+ resolve({ visibility });
98
+ },
99
+ reject,
100
+ });
101
+ },
102
+ );
103
+ },
104
+ }),
105
+ };
106
+ });
107
+
108
+ /** What an awaited promise has done so far, without ever awaiting it. */
109
+ interface Outcome {
110
+ status: "pending" | "resolved" | "rejected";
111
+ reason?: unknown;
112
+ }
113
+
114
+ function track(promise: Promise<unknown>): Outcome {
115
+ const outcome: Outcome = { status: "pending" };
116
+ promise.then(
117
+ () => {
118
+ outcome.status = "resolved";
119
+ },
120
+ (reason) => {
121
+ outcome.status = "rejected";
122
+ outcome.reason = reason;
123
+ },
124
+ );
125
+ return outcome;
126
+ }
127
+
128
+ /** Starts a call inside `act` and records how its promise ends. */
129
+ async function start(call: () => Promise<unknown>): Promise<Outcome> {
130
+ let outcome!: Outcome;
131
+ await act(async () => {
132
+ outcome = track(call());
133
+ });
134
+ return outcome;
135
+ }
136
+
137
+ /** Settles a transport write inside `act` so React state updates are flushed. */
138
+ async function drive(action: () => void) {
139
+ await act(async () => {
140
+ action();
141
+ });
142
+ }
143
+
144
+ /** Lets timers, microtasks and React effects run for a bounded moment. */
145
+ async function tick(ms = 20) {
146
+ await act(async () => {
147
+ await new Promise((resolve) => setTimeout(resolve, ms));
148
+ });
149
+ }
150
+
151
+ interface Surface {
152
+ mount: () => Promise<void>;
153
+ unmount: () => void;
154
+ /** The Promise API under test for this controller. */
155
+ request: (next: ShareVisibility) => Promise<void>;
156
+ requestFromUi: (next: ShareVisibility) => void;
157
+ setOpen: (open: boolean) => void;
158
+ setResource: (id: string) => void;
159
+ confirm: () => Promise<void>;
160
+ cancel: () => void;
161
+ value: () => ShareVisibility;
162
+ busy: () => boolean;
163
+ /** The error the UI shows for the share surface; null when clean. */
164
+ error: () => unknown;
165
+ confirmationError: () => string | null;
166
+ }
167
+
168
+ interface Env {
169
+ root: () => Root;
170
+ queryClient: () => QueryClient;
171
+ }
172
+
173
+ const surfaces: Array<[string, (env: Env) => Surface]> = [
174
+ ["ShareButton handleVisibilityChange", buttonSurface],
175
+ ["ShareDialog visibility.transition.request", dialogSurface],
176
+ ];
177
+
178
+ describe.each(surfaces)("%s lifecycle", (_name, createSurface) => {
179
+ let container: HTMLDivElement;
180
+ let root: Root;
181
+ let mounted: boolean;
182
+ let queryClient: QueryClient;
183
+ let surface: Surface;
184
+
185
+ beforeEach(async () => {
186
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
187
+ vi.stubGlobal(
188
+ "fetch",
189
+ vi.fn().mockResolvedValue({
190
+ ok: true,
191
+ json: async () => ({
192
+ orgId: "org-1",
193
+ orgName: "Acme",
194
+ orgs: [],
195
+ members: [],
196
+ }),
197
+ }),
198
+ );
199
+ transport.server.visibility = "private";
200
+ transport.resources.clear();
201
+ transport.reads.length = 0;
202
+ transport.pauseReads = false;
203
+ transport.readFailure = null;
204
+ transport.releaseReads.length = 0;
205
+ transport.shared.mockClear();
206
+ transport.writes.length = 0;
207
+ queryClient = new QueryClient({
208
+ defaultOptions: {
209
+ queries: { retry: false },
210
+ mutations: { retry: false },
211
+ },
212
+ });
213
+ container = document.createElement("div");
214
+ document.body.appendChild(container);
215
+ root = createRoot(container);
216
+ mounted = true;
217
+ surface = createSurface({
218
+ root: () => root,
219
+ queryClient: () => queryClient,
220
+ });
221
+ await surface.mount();
222
+ });
223
+
224
+ afterEach(async () => {
225
+ if (mounted) act(() => root.unmount());
226
+ transport.pauseReads = false;
227
+ await drive(() => transport.releaseReads.forEach((release) => release()));
228
+ await drive(() => transport.writes.forEach((write) => write.resolve()));
229
+ queryClient.clear();
230
+ container.remove();
231
+ vi.unstubAllGlobals();
232
+ });
233
+
234
+ const unmount = () => {
235
+ act(() => surface.unmount());
236
+ mounted = false;
237
+ };
238
+
239
+ const cacheKey = (resourceId: string) =>
240
+ [
241
+ "action",
242
+ "list-resource-shares",
243
+ { resourceType: "document", resourceId },
244
+ ] as const;
245
+ const cached = (id: string) =>
246
+ queryClient.getQueryData<ResourceSharesResponse>(cacheKey(id));
247
+ const moveTo = async (id: string) => {
248
+ await drive(() => surface.setResource(id));
249
+ await until(() => cached(id) !== undefined, `the ${id} share state`);
250
+ await tick();
251
+ };
252
+
253
+ it.each([
254
+ ["removed", "fails"],
255
+ ["recreated", "fails"],
256
+ ["removed", "succeeds"],
257
+ ["recreated", "succeeds"],
258
+ ] as const)(
259
+ "clears A's own override when its query is %s and its save %s",
260
+ async (entry, outcome) => {
261
+ const initial = cached("doc-1");
262
+ const heldA = await start(() => surface.request("org"));
263
+ await start(() => surface.confirm());
264
+ await until(() => transport.writes.length === 1, "A's confirmed write");
265
+ await moveTo("doc-2");
266
+ const oldQuery = queryClient
267
+ .getQueryCache()
268
+ .find({ queryKey: cacheKey("doc-1"), exact: true });
269
+ await drive(() => {
270
+ queryClient.removeQueries({ queryKey: cacheKey("doc-1"), exact: true });
271
+ if (entry === "recreated")
272
+ queryClient.setQueryData(cacheKey("doc-1"), initial);
273
+ });
274
+ expect(
275
+ queryClient
276
+ .getQueryCache()
277
+ .find({ queryKey: cacheKey("doc-1"), exact: true }),
278
+ ).not.toBe(oldQuery);
279
+ const bSnapshot = cached("doc-2");
280
+ const readsBeforeSettlement = [...transport.reads];
281
+ await drive(() => {
282
+ if (outcome === "fails")
283
+ transport.writes[0].reject(new Error("A save failed"));
284
+ else transport.writes[0].resolve();
285
+ });
286
+ await tick();
287
+ expect(heldA.status).toBe(outcome === "fails" ? "rejected" : "resolved");
288
+ expect(cached("doc-1")).toEqual(
289
+ entry === "recreated" ? initial : undefined,
290
+ );
291
+ expect(cached("doc-2")).toEqual(bSnapshot);
292
+ expect(surface.value()).toBe("private");
293
+ expect(surface.error()).toBeNull();
294
+ expect(transport.shared).not.toHaveBeenCalled();
295
+ expect(transport.reads).toEqual(readsBeforeSettlement);
296
+ // The next authoritative read may reflect a later access change too.
297
+ transport.server.visibility = "private";
298
+ await moveTo("doc-1");
299
+ expect(cached("doc-1")?.visibility).toBe("private");
300
+ expect(surface.value()).toBe("private");
301
+ const newer = await start(() => surface.request("public"));
302
+ await until(() => transport.writes.length === 2, "A's newer write");
303
+ await drive(() =>
304
+ queryClient.setQueryData<ResourceSharesResponse>(
305
+ cacheKey("doc-1"),
306
+ (previous) =>
307
+ previous ? { ...previous, visibility: "private" } : previous,
308
+ ),
309
+ );
310
+ await tick();
311
+ expect(surface.value()).toBe("public");
312
+ expect(newer.status).toBe("pending");
313
+ await drive(() => transport.writes[1].resolve());
314
+ await tick();
315
+ expect(newer.status).toBe("resolved");
316
+ expect(surface.value()).toBe("public");
317
+ },
318
+ );
319
+
320
+ it("rolls back A without changing pending B or discarding unrelated share updates", async () => {
321
+ const heldA = await start(() => surface.request("org"));
322
+ const confirmedA = await start(() => surface.confirm());
323
+ await until(() => transport.writes.length === 1, "A's confirmed write");
324
+ expect(cached("doc-1")?.visibility).toBe("org");
325
+ await moveTo("doc-2");
326
+ const pendingB = await start(() => surface.request("public"));
327
+ await until(() => transport.writes.length === 2, "B's direct write");
328
+ const bSnapshot = cached("doc-2");
329
+ const addedShare = {
330
+ id: "new-share",
331
+ principalType: "user" as const,
332
+ principalId: "viewer@example.test",
333
+ role: "viewer" as const,
334
+ };
335
+ await drive(() =>
336
+ queryClient.setQueryData<ResourceSharesResponse>(
337
+ cacheKey("doc-1"),
338
+ (previous) =>
339
+ previous
340
+ ? { ...previous, shares: [addedShare], agentReadable: true }
341
+ : previous,
342
+ ),
343
+ );
344
+ const readsBeforeFailure = [...transport.reads];
345
+ await drive(() => transport.writes[0].reject(new Error("A save failed")));
346
+ await tick();
347
+ expect(cached("doc-1")).toMatchObject({
348
+ visibility: "private",
349
+ shares: [addedShare],
350
+ agentReadable: true,
351
+ });
352
+ expect(cached("doc-2")).toEqual(bSnapshot);
353
+ expect(surface.value()).toBe("public");
354
+ expect(surface.error()).toBeNull();
355
+ expect(surface.busy()).toBe(true);
356
+ expect(pendingB.status).toBe("pending");
357
+ expect(heldA).toMatchObject({
358
+ status: "rejected",
359
+ reason: { reason: "dropped" },
360
+ });
361
+ expect(confirmedA.status).toBe("resolved");
362
+ expect(transport.reads).toEqual(readsBeforeFailure);
363
+ await moveTo("doc-1");
364
+ expect(surface.value()).toBe("private");
365
+ await drive(() => transport.writes[1].resolve());
366
+ await tick();
367
+ expect(pendingB.status).toBe("resolved");
368
+ });
369
+
370
+ it("commits A's authoritative success without clearing B's override or invoking B's UI work", async () => {
371
+ const heldA = await start(() => surface.request("org"));
372
+ await start(() => surface.confirm());
373
+ await until(() => transport.writes.length === 1, "A's confirmed write");
374
+ await moveTo("doc-2");
375
+ const pendingB = await start(() => surface.request("public"));
376
+ await until(() => transport.writes.length === 2, "B's direct write");
377
+ const readsBeforeSuccess = [...transport.reads];
378
+ await drive(() => transport.writes[0].resolve("private"));
379
+ await tick();
380
+ expect(cached("doc-1")?.visibility).toBe("private");
381
+ expect(cached("doc-2")?.visibility).toBe("public");
382
+ expect(surface.value()).toBe("public");
383
+ expect(surface.error()).toBeNull();
384
+ expect(surface.busy()).toBe(true);
385
+ expect(heldA.status).toBe("resolved");
386
+ expect(pendingB.status).toBe("pending");
387
+ expect(transport.shared).not.toHaveBeenCalled();
388
+ expect(transport.reads).toEqual(readsBeforeSuccess);
389
+ await drive(() => transport.writes[1].resolve());
390
+ await tick();
391
+ });
392
+
393
+ it("restores A on A to B to A and permits a new intent only after A's failed physical save settles", async () => {
394
+ await start(() => surface.request("org"));
395
+ await start(() => surface.confirm());
396
+ await until(() => transport.writes.length === 1, "A's confirmed write");
397
+ await moveTo("doc-2");
398
+ await moveTo("doc-1");
399
+ const refused = await start(() => surface.request("public"));
400
+ expect(refused).toMatchObject({
401
+ status: "rejected",
402
+ reason: { reason: "ignored" },
403
+ });
404
+ expect(transport.writes).toHaveLength(1);
405
+ await drive(() =>
406
+ transport.writes[0].reject(new Error("A obsolete failure")),
407
+ );
408
+ await tick();
409
+ expect(surface.value()).toBe("private");
410
+ expect(surface.error()).toBeNull();
411
+ const newer = await start(() => surface.request("public"));
412
+ await until(() => transport.writes.length === 2, "A's newer write");
413
+ expect(cached("doc-1")?.visibility).toBe("public");
414
+ await drive(() => transport.writes[1].resolve());
415
+ await tick();
416
+ expect(newer.status).toBe("resolved");
417
+ expect(surface.value()).toBe("public");
418
+ });
419
+
420
+ it("does not refetch B or run share-success UI when A settles after navigation without a B write", async () => {
421
+ const heldA = await start(() => surface.request("org"));
422
+ await start(() => surface.confirm());
423
+ await until(() => transport.writes.length === 1, "A's confirmed write");
424
+ await moveTo("doc-2");
425
+ const readsBeforeSuccess = [...transport.reads];
426
+ await drive(() => transport.writes[0].resolve());
427
+ await tick();
428
+ expect(heldA.status).toBe("resolved");
429
+ expect(cached("doc-1")?.visibility).toBe("org");
430
+ expect(surface.value()).toBe("private");
431
+ expect(surface.error()).toBeNull();
432
+ expect(transport.shared).not.toHaveBeenCalled();
433
+ expect(transport.reads).toEqual(readsBeforeSuccess);
434
+ });
435
+
436
+ // The dialog releases its physical write lock before its background refetch
437
+ // finishes; the button already waits for that refetch before releasing it.
438
+ if (_name === "ShareDialog visibility.transition.request") {
439
+ it("reconciles a confirmed write that committed before its response was lost", async () => {
440
+ await start(() => surface.request("org"));
441
+ const confirmation = await start(() => surface.confirm());
442
+ await until(() => transport.writes.length === 1, "the confirmed write");
443
+ const readsBeforeFailure = transport.reads.length;
444
+ await drive(() => {
445
+ transport.server.visibility = "org";
446
+ transport.writes[0].reject(new Error("save response lost"));
447
+ });
448
+ await tick();
449
+ expect(transport.reads).toHaveLength(readsBeforeFailure + 1);
450
+ expect(confirmation.status).toBe("resolved");
451
+ expect(cached("doc-1")?.visibility).toBe("org");
452
+ expect(surface.value()).toBe("org");
453
+ });
454
+
455
+ it("preserves the confirmed save error when reconciliation also fails", async () => {
456
+ await start(() => surface.request("org"));
457
+ await start(() => surface.confirm());
458
+ await until(() => transport.writes.length === 1, "the confirmed write");
459
+ const readsBeforeFailure = transport.reads.length;
460
+ const failure = new Error("save response lost");
461
+ const readFailure = new Error("share reconciliation unavailable");
462
+ await drive(() => {
463
+ transport.server.visibility = "org";
464
+ transport.readFailure = readFailure;
465
+ transport.writes[0].reject(failure);
466
+ });
467
+ await tick();
468
+ expect(transport.reads).toHaveLength(readsBeforeFailure + 1);
469
+ expect(queryClient.getQueryState(cacheKey("doc-1"))?.error).toBe(
470
+ readFailure,
471
+ );
472
+ expect(surface.confirmationError()).toBe(failure.message);
473
+ expect(surface.busy()).toBe(false);
474
+ });
475
+
476
+ it("preserves the direct save rejection when reconciliation also fails", async () => {
477
+ const save = await start(() => surface.request("public"));
478
+ await until(() => transport.writes.length === 1, "the direct write");
479
+ const readsBeforeFailure = transport.reads.length;
480
+ const failure = new Error("save response lost");
481
+ const readFailure = new Error("share reconciliation unavailable");
482
+ await drive(() => {
483
+ transport.server.visibility = "public";
484
+ transport.readFailure = readFailure;
485
+ transport.writes[0].reject(failure);
486
+ });
487
+ await tick();
488
+ expect(transport.reads).toHaveLength(readsBeforeFailure + 1);
489
+ expect(save).toMatchObject({ status: "rejected", reason: failure });
490
+ expect(queryClient.getQueryState(cacheKey("doc-1"))?.error).toBe(
491
+ readFailure,
492
+ );
493
+ expect(surface.error()).toBe(failure.message);
494
+ });
495
+
496
+ it("does not reconcile an obsolete save into a recreated active query", async () => {
497
+ await start(() => surface.request("org"));
498
+ await start(() => surface.confirm());
499
+ await until(() => transport.writes.length === 1, "the confirmed write");
500
+ const replacement = { ...cached("doc-1")!, visibility: "public" };
501
+ queryClient.setQueryDefaults(cacheKey("doc-1"), {
502
+ staleTime: Infinity,
503
+ });
504
+ await drive(() => {
505
+ queryClient.removeQueries({ queryKey: cacheKey("doc-1"), exact: true });
506
+ queryClient.setQueryData(cacheKey("doc-1"), replacement);
507
+ });
508
+ await drive(() => surface.setOpen(false));
509
+ await drive(() => surface.setOpen(true));
510
+ const readsBeforeFailure = [...transport.reads];
511
+ await drive(() => transport.writes[0].reject(new Error("old save lost")));
512
+ await tick();
513
+ expect(cached("doc-1")).toEqual(replacement);
514
+ expect(surface.value()).toBe("public");
515
+ expect(transport.reads).toEqual(readsBeforeFailure);
516
+ });
517
+
518
+ it("keeps a late A reconciliation separate from B's pending visibility", async () => {
519
+ await start(() => surface.request("org"));
520
+ await start(() => surface.confirm());
521
+ await until(() => transport.writes.length === 1, "A's confirmed write");
522
+ transport.pauseReads = true;
523
+ await drive(() => {
524
+ transport.server.visibility = "org";
525
+ transport.writes[0].reject(new Error("A save response lost"));
526
+ });
527
+ await until(
528
+ () => transport.releaseReads.length === 1,
529
+ "A reconciliation",
530
+ );
531
+ queryClient.setQueryDefaults(cacheKey("doc-2"), { staleTime: Infinity });
532
+ queryClient.setQueryData(cacheKey("doc-2"), {
533
+ ...cached("doc-1"),
534
+ visibility: "private",
535
+ });
536
+ await moveTo("doc-2");
537
+ const saveB = await start(() => surface.request("public"));
538
+ await until(() => transport.writes.length === 2, "B's direct write");
539
+ const snapshotB = cached("doc-2");
540
+ const readsBeforeCompletion = [...transport.reads];
541
+ await drive(() => transport.releaseReads[0]());
542
+ await tick();
543
+ expect(cached("doc-1")?.visibility).toBe("org");
544
+ expect(cached("doc-2")).toEqual(snapshotB);
545
+ expect(surface.value()).toBe("public");
546
+ expect(surface.error()).toBeNull();
547
+ expect(surface.busy()).toBe(true);
548
+ expect(saveB.status).toBe("pending");
549
+ expect(transport.reads).toEqual(readsBeforeCompletion);
550
+ transport.pauseReads = false;
551
+ await drive(() => transport.writes[1].resolve());
552
+ await tick();
553
+ });
554
+
555
+ it.each(["closed", "unmounted"] as const)(
556
+ "does not start reconciliation after the dialog is %s",
557
+ async (state) => {
558
+ const save = await start(() => surface.request("public"));
559
+ await until(() => transport.writes.length === 1, "the direct write");
560
+ if (state === "closed") await drive(() => surface.setOpen(false));
561
+ else unmount();
562
+ const readsBeforeFailure = [...transport.reads];
563
+ await drive(() =>
564
+ transport.writes[0].reject(new Error("inactive save failed")),
565
+ );
566
+ await tick();
567
+ expect(save.status).toBe("rejected");
568
+ expect(transport.reads).toEqual(readsBeforeFailure);
569
+ },
570
+ );
571
+
572
+ it("does not clear a newer A override when query removal settles the old background refetch", async () => {
573
+ const initial = cached("doc-1");
574
+ const first = await start(() => surface.request("public"));
575
+ await until(() => transport.writes.length === 1, "the first write");
576
+ transport.pauseReads = true;
577
+ await drive(() => transport.writes[0].resolve());
578
+ await until(() => transport.releaseReads.length === 1, "the old refetch");
579
+ expect(first.status).toBe("resolved");
580
+ let newer!: Outcome;
581
+ await act(async () => {
582
+ queryClient.removeQueries({ queryKey: cacheKey("doc-1"), exact: true });
583
+ queryClient.setQueryData(cacheKey("doc-1"), initial);
584
+ newer = track(surface.request("org"));
585
+ queryClient.setQueryData(cacheKey("doc-1"), initial);
586
+ });
587
+ await until(() => transport.writes.length === 2, "the newer write");
588
+ await tick();
589
+ expect(cached("doc-1")?.visibility).toBe("private");
590
+ expect(surface.value()).toBe("org");
591
+ expect(newer.status).toBe("pending");
592
+ transport.pauseReads = false;
593
+ await drive(() => transport.writes[1].resolve());
594
+ await tick();
595
+ await drive(() => transport.releaseReads[0]());
596
+ await tick();
597
+ expect(newer.status).toBe("resolved");
598
+ expect(surface.value()).toBe("org");
599
+ });
600
+
601
+ it("does not let the preceding refetch overwrite a newer saved visibility", async () => {
602
+ const first = await start(() => surface.request("public"));
603
+ await until(() => transport.writes.length === 1, "the first write");
604
+ transport.pauseReads = true;
605
+ await drive(() => transport.writes[0].resolve());
606
+ await until(() => transport.releaseReads.length === 1, "the old refetch");
607
+ expect(first.status).toBe("resolved");
608
+ const newer = await start(() => surface.request("private"));
609
+ await until(() => transport.writes.length === 2, "the newer write");
610
+ transport.pauseReads = false;
611
+ await drive(() => transport.writes[1].resolve());
612
+ await tick();
613
+ expect(newer.status).toBe("resolved");
614
+ expect(surface.value()).toBe("private");
615
+ await drive(() => transport.releaseReads[0]());
616
+ await tick();
617
+ expect(cached("doc-1")?.visibility).toBe("private");
618
+ expect(surface.value()).toBe("private");
619
+ });
620
+ }
621
+
622
+ it("refuses a different direct target until the first write persists, then permits retry", async () => {
623
+ const first = await start(() => surface.request("public"));
624
+ await tick();
625
+ const second = await start(() => surface.request("private"));
626
+ await tick();
627
+ expect(transport.writes.map((w) => w.visibility)).toEqual(["public"]);
628
+ expect(first.status).toBe("pending");
629
+ expect(surface.busy()).toBe(true);
630
+ expect(second.status).toBe("rejected");
631
+ expect(second.reason).toMatchObject({
632
+ name: "ShareVisibilityNotSavedError",
633
+ reason: "ignored",
634
+ });
635
+ expect(surface.value()).toBe("public");
636
+ expect(transport.server.visibility).toBe("private");
637
+
638
+ await drive(() => transport.writes[0].resolve());
639
+ await tick();
640
+ expect(surface.busy()).toBe(false);
641
+ const retry = await start(() => surface.request("private"));
642
+ await until(() => transport.writes.length === 2, "the retry write");
643
+ expect(retry.status).toBe("pending");
644
+ await drive(() => transport.writes[1].resolve());
645
+ await tick();
646
+
647
+ expect(first.status).toBe("resolved");
648
+ expect(retry.status).toBe("resolved");
649
+ expect(transport.server.visibility).toBe("private");
650
+ expect(surface.value()).toBe("private");
651
+ });
652
+
653
+ it("prevents Org to Private to Public writes from committing out of order", async () => {
654
+ const held = await start(() => surface.request("org"));
655
+ await start(() => surface.confirm());
656
+ await until(() => transport.writes.length === 1, "the org write");
657
+ await drive(() => transport.writes[0].resolve());
658
+ await tick();
659
+ expect(held.status).toBe("resolved");
660
+ expect(transport.server.visibility).toBe("org");
661
+
662
+ const first = await start(() => surface.request("private"));
663
+ await until(() => transport.writes.length === 2, "the private write");
664
+ const second = await start(() => surface.request("public"));
665
+ expect(second.status).toBe("rejected");
666
+ expect(second.reason).toMatchObject({ reason: "ignored" });
667
+ expect(transport.writes.map((write) => write.visibility)).toEqual([
668
+ "org",
669
+ "private",
670
+ ]);
671
+ expect(first.status).toBe("pending");
672
+ expect(surface.value()).toBe("private");
673
+ expect(transport.server.visibility).toBe("org");
674
+
675
+ await drive(() => transport.writes[1].resolve());
676
+ await tick();
677
+ expect(first.status).toBe("resolved");
678
+ const retry = await start(() => surface.request("public"));
679
+ await until(() => transport.writes.length === 3, "the public retry");
680
+ await drive(() => transport.writes[2].resolve());
681
+ await tick();
682
+ expect(retry.status).toBe("resolved");
683
+ expect(transport.server.visibility).toBe("public");
684
+ expect(surface.value()).toBe("public");
685
+ });
686
+
687
+ it("keeps the actual direct save pending after refusing the newer target", async () => {
688
+ const first = await start(() => surface.request("public"));
689
+ await tick();
690
+ const second = await start(() => surface.request("private"));
691
+ await tick();
692
+
693
+ expect(transport.writes).toHaveLength(1);
694
+ expect(second.status).toBe("rejected");
695
+ expect(second.reason).toMatchObject({ reason: "ignored" });
696
+ expect(first.status).toBe("pending");
697
+ await drive(() => transport.writes[0].resolve());
698
+ await tick();
699
+
700
+ expect(first.status).toBe("resolved");
701
+ expect(surface.value()).toBe("public");
702
+ expect(transport.server.visibility).toBe("public");
703
+ });
704
+
705
+ it("reports the actual save failure after refusing a different target and releases the lock", async () => {
706
+ const first = await start(() => surface.request("public"));
707
+ await tick();
708
+ const second = await start(() => surface.request("private"));
709
+ await tick();
710
+
711
+ const failure = new Error("first save failed");
712
+ await drive(() => transport.writes[0].reject(failure));
713
+ await tick();
714
+ expect(second.status).toBe("rejected");
715
+ expect(second.reason).toMatchObject({ reason: "ignored" });
716
+ expect(transport.writes).toHaveLength(1);
717
+ expect(surface.error()).toBe("first save failed");
718
+ expect(surface.value()).toBe("private");
719
+ const retry = await start(() => surface.request("public"));
720
+ await until(() => transport.writes.length === 2, "the retry write");
721
+ await drive(() => transport.writes[1].resolve());
722
+ await tick();
723
+
724
+ expect(first.status).toBe("rejected");
725
+ expect(first.reason).toBe(failure);
726
+ expect(retry.status).toBe("resolved");
727
+ expect(surface.error()).toBeNull();
728
+ });
729
+
730
+ it("reports a failed retry after the preceding direct save has settled", async () => {
731
+ const first = await start(() => surface.request("public"));
732
+ await tick();
733
+ await drive(() => transport.writes[0].resolve());
734
+ await tick();
735
+ const second = await start(() => surface.request("private"));
736
+ await until(() => transport.writes.length === 2, "the second write");
737
+ const failure = new Error("second save failed");
738
+ await drive(() => transport.writes[1].reject(failure));
739
+ await tick();
740
+
741
+ expect(first.status).toBe("resolved");
742
+ expect(second.status).toBe("rejected");
743
+ expect(second.reason).toBe(failure);
744
+ // The newest request failing is what the UI reports.
745
+ expect(surface.error()).not.toBeNull();
746
+ });
747
+
748
+ it("keeps the actual pending target after a different void UI request is refused", async () => {
749
+ const older = await start(() => surface.request("public"));
750
+ await tick();
751
+ await drive(() => surface.requestFromUi("private"));
752
+ await tick();
753
+ expect(transport.writes).toHaveLength(1);
754
+ expect(surface.value()).toBe("public");
755
+ expect(surface.error()).toBeNull();
756
+ const failure = new Error("actual save failed");
757
+ await drive(() => transport.writes[0].reject(failure));
758
+ await tick();
759
+
760
+ expect(older.status).toBe("rejected");
761
+ expect(older.reason).toBe(failure);
762
+ expect(surface.error()).toBe("actual save failed");
763
+ expect(surface.value()).toBe("private");
764
+ expect(transport.server.visibility).toBe("private");
765
+ });
766
+
767
+ it("resolves a write that finishes after the surface unmounted", async () => {
768
+ const saved = await start(() => surface.request("public"));
769
+ await tick();
770
+ expect(transport.writes).toHaveLength(1);
771
+
772
+ unmount();
773
+ await drive(() => transport.writes[0].resolve());
774
+ await tick();
775
+
776
+ expect(saved.status).toBe("resolved");
777
+ });
778
+
779
+ it("rejects a write that fails after the surface unmounted", async () => {
780
+ const saved = await start(() => surface.request("public"));
781
+ await tick();
782
+
783
+ unmount();
784
+ const failure = new Error("save failed after unmount");
785
+ await drive(() => transport.writes[0].reject(failure));
786
+ await tick();
787
+
788
+ expect(saved.status).toBe("rejected");
789
+ expect(saved.reason).toBe(failure);
790
+ });
791
+
792
+ it("settles the actual write after refusing overlap and unmounting", async () => {
793
+ const first = await start(() => surface.request("public"));
794
+ await tick();
795
+ const second = await start(() => surface.request("private"));
796
+ await tick();
797
+
798
+ unmount();
799
+ await drive(() => transport.writes[0].resolve());
800
+ await tick();
801
+
802
+ expect(first.status).toBe("resolved");
803
+ expect(second.status).toBe("rejected");
804
+ expect(second.reason).toMatchObject({ reason: "ignored" });
805
+ expect(transport.writes).toHaveLength(1);
806
+ expect(transport.server.visibility).toBe("public");
807
+ });
808
+
809
+ it("makes a repeat of the optimistic target wait for the save in flight", async () => {
810
+ const first = await start(() => surface.request("public"));
811
+ await tick();
812
+ expect(surface.value()).toBe("public");
813
+
814
+ const repeat = await start(() => surface.request("public"));
815
+ await tick();
816
+
817
+ // Nothing was persisted yet, so the repeat must not read as saved, and it
818
+ // must reuse the write already running rather than start another.
819
+ expect(transport.writes).toHaveLength(1);
820
+ expect(first.status).toBe("pending");
821
+ expect(repeat.status).toBe("pending");
822
+
823
+ await drive(() => transport.writes[0].resolve());
824
+ await tick();
825
+
826
+ expect(first.status).toBe("resolved");
827
+ expect(repeat.status).toBe("resolved");
828
+ expect(transport.writes).toHaveLength(1);
829
+ });
830
+
831
+ it("fails a repeat of the optimistic target when the save in flight fails", async () => {
832
+ const first = await start(() => surface.request("public"));
833
+ await tick();
834
+ const repeat = await start(() => surface.request("public"));
835
+ await tick();
836
+
837
+ const failure = new Error("save failed");
838
+ await drive(() => transport.writes[0].reject(failure));
839
+ await tick();
840
+
841
+ expect(first.status).toBe("rejected");
842
+ expect(first.reason).toBe(failure);
843
+ expect(repeat.status).toBe("rejected");
844
+ expect(repeat.reason).toBe(failure);
845
+ expect(transport.writes).toHaveLength(1);
846
+ });
847
+
848
+ it("resolves a held organization request whose confirmed save finishes after unmount", async () => {
849
+ const held = await start(() => surface.request("org"));
850
+ await tick();
851
+ expect(transport.writes).toHaveLength(0);
852
+
853
+ const confirmed = await start(() => surface.confirm());
854
+ await tick();
855
+ expect(transport.writes.map((w) => w.visibility)).toEqual(["org"]);
856
+
857
+ unmount();
858
+ await drive(() => transport.writes[0].resolve());
859
+ await tick();
860
+
861
+ expect(confirmed.status).toBe("resolved");
862
+ expect(held.status).toBe("resolved");
863
+ });
864
+
865
+ it("rejects a held organization request whose confirmed save fails after unmount", async () => {
866
+ const held = await start(() => surface.request("org"));
867
+ await tick();
868
+ const confirmed = await start(() => surface.confirm());
869
+ await tick();
870
+
871
+ unmount();
872
+ const failure = new Error("confirmed save failed");
873
+ await drive(() => transport.writes[0].reject(failure));
874
+ await tick();
875
+
876
+ expect(held.status).toBe("rejected");
877
+ expect(held.reason).toBeInstanceOf(ShareVisibilityNotSavedError);
878
+ expect(held.reason).toMatchObject({ reason: "dropped" });
879
+ expect(confirmed.status).toBe("resolved");
880
+ });
881
+
882
+ it.each(["API", "UI"])(
883
+ "keeps the resource locked across close/reopen for a conflicting %s choice",
884
+ async (caller) => {
885
+ const held = await start(() => surface.request("org"));
886
+ const confirmed = await start(() => surface.confirm());
887
+ await until(() => transport.writes.length === 1, "the confirmed write");
888
+
889
+ await drive(() => surface.setOpen(false));
890
+ await drive(() => surface.setOpen(true));
891
+ let newer: Outcome | undefined;
892
+ if (caller === "API") {
893
+ newer = await start(() => surface.request("private"));
894
+ expect(newer.status).toBe("rejected");
895
+ expect(newer.reason).toMatchObject({
896
+ name: "ShareVisibilityNotSavedError",
897
+ reason: "ignored",
898
+ });
899
+ } else {
900
+ await drive(() => surface.requestFromUi("private"));
901
+ }
902
+ await tick();
903
+ expect(transport.writes.map((write) => write.visibility)).toEqual([
904
+ "org",
905
+ ]);
906
+ expect(transport.server.visibility).toBe("private");
907
+ expect(held.status).toBe("pending");
908
+ expect(confirmed.status).toBe("pending");
909
+ expect(surface.error()).toBeNull();
910
+
911
+ await drive(() => transport.writes[0].resolve());
912
+ await tick();
913
+ expect(held.status).toBe("resolved");
914
+ expect(confirmed.status).toBe("resolved");
915
+ expect(transport.server.visibility).toBe("org");
916
+ expect(transport.writes).toHaveLength(1);
917
+
918
+ if (caller === "API") {
919
+ newer = await start(() => surface.request("private"));
920
+ expect(newer.status).toBe("pending");
921
+ } else {
922
+ await drive(() => surface.requestFromUi("private"));
923
+ }
924
+ await until(() => transport.writes.length === 2, "the newer write");
925
+ expect(transport.writes.map((write) => write.visibility)).toEqual([
926
+ "org",
927
+ "private",
928
+ ]);
929
+ expect(transport.server.visibility).toBe("org");
930
+ await drive(() => transport.writes[1].resolve());
931
+ await tick();
932
+ if (caller === "API") expect(newer?.status).toBe("resolved");
933
+ expect(transport.server.visibility).toBe("private");
934
+ expect(surface.value()).toBe("private");
935
+ },
936
+ );
937
+
938
+ it("reuses the confirmed target after close/reopen until it persists", async () => {
939
+ const held = await start(() => surface.request("org"));
940
+ const confirmed = await start(() => surface.confirm());
941
+ await until(() => transport.writes.length === 1, "the confirmed write");
942
+ await drive(() => surface.setOpen(false));
943
+ await drive(() => surface.setOpen(true));
944
+ const repeated = await start(() => surface.request("org"));
945
+ expect(repeated.status).toBe("pending");
946
+ expect(transport.writes).toHaveLength(1);
947
+ await drive(() => transport.writes[0].resolve());
948
+ await tick();
949
+ expect(held.status).toBe("resolved");
950
+ expect(confirmed.status).toBe("resolved");
951
+ expect(repeated.status).toBe("resolved");
952
+ });
953
+
954
+ it("types an invalidated confirmed failure and releases its resource lock", async () => {
955
+ const held = await start(() => surface.request("org"));
956
+ const confirmed = await start(() => surface.confirm());
957
+ await until(() => transport.writes.length === 1, "the confirmed write");
958
+ await drive(() => surface.setOpen(false));
959
+ await drive(() => surface.setOpen(true));
960
+ await drive(() =>
961
+ transport.writes[0].reject(new Error("obsolete failure")),
962
+ );
963
+ await tick();
964
+ expect(held.status).toBe("rejected");
965
+ expect(held.reason).toBeInstanceOf(ShareVisibilityNotSavedError);
966
+ expect(held.reason).toMatchObject({ reason: "dropped" });
967
+ expect(confirmed.status).toBe("resolved");
968
+ expect(surface.error()).toBeNull();
969
+
970
+ const newer = await start(() => surface.request("public"));
971
+ await until(() => transport.writes.length === 2, "the retry write");
972
+ expect(newer.status).toBe("pending");
973
+ await drive(() => transport.writes[1].resolve());
974
+ await tick();
975
+ expect(newer.status).toBe("resolved");
976
+ expect(transport.server.visibility).toBe("public");
977
+ });
978
+
979
+ it("reports an unawaited UI save failure without an unhandled rejection", async () => {
980
+ await drive(() => surface.requestFromUi("public"));
981
+ await until(() => transport.writes.length === 1, "the UI write");
982
+ await drive(() => transport.writes[0].reject(new Error("UI save failed")));
983
+ await tick();
984
+ expect(surface.error()).toBe("UI save failed");
985
+ expect(surface.value()).toBe("private");
986
+ });
987
+
988
+ it("cancels an unawaited UI confirmation without reporting a save failure", async () => {
989
+ await drive(() => surface.requestFromUi("org"));
990
+ await drive(() => surface.cancel());
991
+ await tick();
992
+ expect(transport.writes).toHaveLength(0);
993
+ expect(surface.error()).toBeNull();
994
+ });
995
+
996
+ it("rejects a request made while a confirmed save runs instead of leaving it hanging", async () => {
997
+ const held = await start(() => surface.request("org"));
998
+ await tick();
999
+ await start(() => surface.confirm());
1000
+ await tick();
1001
+
1002
+ const refused = await start(() => surface.request("private"));
1003
+ await tick();
1004
+
1005
+ expect(refused.status).toBe("rejected");
1006
+ expect(refused.reason).toBeInstanceOf(ShareVisibilityNotSavedError);
1007
+ expect(transport.writes).toHaveLength(1);
1008
+
1009
+ await drive(() => transport.writes[0].resolve());
1010
+ await tick();
1011
+ expect(held.status).toBe("resolved");
1012
+ });
1013
+ });
1014
+
1015
+ // The surfaces live below the suite so the table above reads first. A hook
1016
+ // result only exists during render, so each harness keeps the latest one.
1017
+ async function until(condition: () => boolean, label: string) {
1018
+ for (let attempt = 0; attempt < 25; attempt += 1) {
1019
+ if (condition()) return;
1020
+ await tick(10);
1021
+ }
1022
+ throw new Error(`Timed out waiting for ${label}`);
1023
+ }
1024
+
1025
+ function buttonSurface(env: Env): Surface {
1026
+ let controller: ShareButtonController | undefined;
1027
+ let setResource!: (id: string) => void;
1028
+ const live = () => controller as ShareButtonController;
1029
+ function Harness() {
1030
+ const [resourceId, updateResource] = useState("doc-1");
1031
+ setResource = updateResource;
1032
+ controller = useShareButtonController({
1033
+ resourceType: "document",
1034
+ resourceId,
1035
+ onShareSuccess: transport.shared,
1036
+ });
1037
+ return null;
1038
+ }
1039
+ return {
1040
+ async mount() {
1041
+ await act(async () => {
1042
+ env.root().render(
1043
+ <QueryClientProvider client={env.queryClient()}>
1044
+ <Harness />
1045
+ </QueryClientProvider>,
1046
+ );
1047
+ });
1048
+ act(() => live().handleOpenChange(true));
1049
+ await until(() => live().canManage, "the share state to load");
1050
+ },
1051
+ unmount: () => env.root().unmount(),
1052
+ request: (next) => live().handleVisibilityChange(next),
1053
+ requestFromUi: (next) => live().handleVisibility(next),
1054
+ setOpen: (open) => live().handleOpenChange(open),
1055
+ setResource: (id) => setResource(id),
1056
+ confirm: () => live().visibilityTransition.confirm(),
1057
+ cancel: () => live().visibilityTransition.cancel(),
1058
+ value: () => live().visibility,
1059
+ error: () => live().shareError,
1060
+ confirmationError: () => live().visibilityTransition.error,
1061
+ busy: () => live().visibilityTransition.submitting,
1062
+ };
1063
+ }
1064
+
1065
+ function dialogSurface(env: Env): Surface {
1066
+ let controller: ShareDialogController | undefined;
1067
+ let setOpen!: (open: boolean) => void;
1068
+ let setResource!: (id: string) => void;
1069
+ const live = () => controller as ShareDialogController;
1070
+ function Harness() {
1071
+ const [resourceId, updateResource] = useState("doc-1");
1072
+ setResource = updateResource;
1073
+ const [open, updateOpen] = useState(true);
1074
+ setOpen = updateOpen;
1075
+ controller = useShareDialogController({
1076
+ open,
1077
+ onClose: () => undefined,
1078
+ resourceType: "document",
1079
+ resourceId,
1080
+ resourceTitle: "Quarterly plan",
1081
+ });
1082
+ return null;
1083
+ }
1084
+ return {
1085
+ async mount() {
1086
+ await act(async () => {
1087
+ env.root().render(
1088
+ <QueryClientProvider client={env.queryClient()}>
1089
+ <Harness />
1090
+ </QueryClientProvider>,
1091
+ );
1092
+ });
1093
+ await until(() => !live().visibility.disabled, "the share state to load");
1094
+ },
1095
+ unmount: () => env.root().unmount(),
1096
+ request: (next) => live().visibility.transition.request(next),
1097
+ requestFromUi: (next) => live().visibility.set(next),
1098
+ setOpen: (open) => setOpen(open),
1099
+ setResource: (id) => setResource(id),
1100
+ confirm: () => live().visibility.transition.confirm(),
1101
+ cancel: () => live().visibility.transition.cancel(),
1102
+ value: () => live().visibility.value,
1103
+ error: () => live().error,
1104
+ confirmationError: () => live().visibility.transition.error,
1105
+ busy: () => live().visibility.disabled,
1106
+ };
1107
+ }