@marktiderman/genesis-chat 0.1.1 → 0.2.1

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 (175) hide show
  1. package/README.md +379 -13
  2. package/dist/chunk-A4YJX7KT.js +544 -0
  3. package/dist/chunk-A4YJX7KT.js.map +1 -0
  4. package/dist/chunk-AZS3HNLM.js +30 -0
  5. package/dist/chunk-AZS3HNLM.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-GBJ3YI4E.js +966 -0
  9. package/dist/chunk-GBJ3YI4E.js.map +1 -0
  10. package/dist/chunk-JU2FAPBI.js +609 -0
  11. package/dist/chunk-JU2FAPBI.js.map +1 -0
  12. package/dist/chunk-VEPZROQB.js +187 -0
  13. package/dist/chunk-VEPZROQB.js.map +1 -0
  14. package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
  15. package/dist/index.d.ts +78 -163
  16. package/dist/index.js +3 -2
  17. package/dist/native.d.ts +1939 -1
  18. package/dist/native.js +5023 -0
  19. package/dist/native.js.map +1 -1
  20. package/dist/outbox-B9NAWQ58.d.ts +219 -0
  21. package/dist/push.d.ts +206 -0
  22. package/dist/push.js +3 -0
  23. package/dist/push.js.map +1 -0
  24. package/dist/react.d.ts +176 -7
  25. package/dist/react.js +3 -3
  26. package/dist/server.d.ts +551 -21
  27. package/dist/server.js +976 -34
  28. package/dist/server.js.map +1 -1
  29. package/dist/thread-oPra7pkW.d.ts +57 -0
  30. package/dist/web.d.ts +335 -12
  31. package/dist/web.js +1784 -64
  32. package/dist/web.js.map +1 -1
  33. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  34. package/migrations/0003_genesis_chat_search_threads.sql +85 -0
  35. package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
  36. package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
  37. package/package.json +78 -6
  38. package/src/__tests__/coaching-core.test.ts +334 -0
  39. package/src/__tests__/coaching-react.test.tsx +319 -0
  40. package/src/__tests__/coaching-web.test.tsx +521 -0
  41. package/src/__tests__/core.test.ts +91 -0
  42. package/src/__tests__/fake-xhr.ts +54 -0
  43. package/src/__tests__/outbox-hosted.test.ts +269 -0
  44. package/src/__tests__/outbox.test.ts +122 -0
  45. package/src/__tests__/realtime.test.ts +70 -0
  46. package/src/__tests__/review-279.test.ts +41 -0
  47. package/src/__tests__/review-fixes-core.test.ts +193 -0
  48. package/src/__tests__/review-fixes-web.test.tsx +405 -0
  49. package/src/__tests__/server-effects.test.ts +158 -0
  50. package/src/__tests__/server-fake-video.test.ts +81 -0
  51. package/src/__tests__/server-fakes.ts +96 -0
  52. package/src/__tests__/server-integrations.test.ts +247 -0
  53. package/src/__tests__/server-providers.test.ts +246 -0
  54. package/src/__tests__/server-runner.test.ts +277 -0
  55. package/src/__tests__/server-safety.test.ts +303 -0
  56. package/src/__tests__/web-markdown.test.tsx +88 -0
  57. package/src/__tests__/web.test.tsx +349 -0
  58. package/src/core/availability.ts +223 -0
  59. package/src/core/client.ts +783 -0
  60. package/src/core/extras.ts +106 -0
  61. package/src/core/media.ts +144 -0
  62. package/src/core/outbox.ts +635 -0
  63. package/src/core/thread.ts +171 -0
  64. package/src/core/transfer.ts +71 -0
  65. package/src/core/types.ts +328 -0
  66. package/src/index.ts +14 -0
  67. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  68. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  69. package/src/native/__tests__/composer-state.test.ts +188 -0
  70. package/src/native/__tests__/composer.test.tsx +133 -0
  71. package/src/native/__tests__/emoji.test.tsx +254 -0
  72. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  73. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  74. package/src/native/__tests__/organise-logic.test.ts +164 -0
  75. package/src/native/__tests__/organise-screens.test.tsx +424 -0
  76. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  77. package/src/native/__tests__/push-payload.test.ts +66 -0
  78. package/src/native/__tests__/push.test.ts +209 -0
  79. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  80. package/src/native/__tests__/rows-actions.test.ts +76 -0
  81. package/src/native/__tests__/thread-layout.test.ts +41 -0
  82. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  83. package/src/native/__tests__/tus.test.ts +154 -0
  84. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  85. package/src/native/__tests__/video-media.test.tsx +498 -0
  86. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  87. package/src/native/__tests__/video-upload.test.ts +227 -0
  88. package/src/native/access-error.tsx +31 -0
  89. package/src/native/actions.ts +42 -0
  90. package/src/native/attachment-cards.tsx +132 -0
  91. package/src/native/auto-retry.ts +69 -0
  92. package/src/native/availability.tsx +376 -0
  93. package/src/native/broadcast-sheet.tsx +193 -0
  94. package/src/native/composer-state.ts +302 -0
  95. package/src/native/composer.tsx +453 -0
  96. package/src/native/context.tsx +70 -0
  97. package/src/native/copy.ts +296 -0
  98. package/src/native/emoji-data.ts +21 -0
  99. package/src/native/emoji-picker.tsx +229 -0
  100. package/src/native/emoji.ts +118 -0
  101. package/src/native/icon-button.tsx +40 -0
  102. package/src/native/inbox-screen.tsx +122 -0
  103. package/src/native/index.ts +184 -0
  104. package/src/native/link-preview.tsx +104 -0
  105. package/src/native/media-colors.ts +15 -0
  106. package/src/native/media.tsx +187 -0
  107. package/src/native/message-actions-sheet.tsx +126 -0
  108. package/src/native/message-bubble.tsx +366 -0
  109. package/src/native/moderation.tsx +42 -0
  110. package/src/native/modules.ts +277 -0
  111. package/src/native/organise-copy.ts +195 -0
  112. package/src/native/photo-viewer.tsx +531 -0
  113. package/src/native/push.ts +375 -0
  114. package/src/native/recorder-machine.ts +176 -0
  115. package/src/native/rows.ts +81 -0
  116. package/src/native/schedule-sheet.tsx +328 -0
  117. package/src/native/search-screen.tsx +189 -0
  118. package/src/native/stack-options.ts +36 -0
  119. package/src/native/start-sheet.tsx +87 -0
  120. package/src/native/thread-screen.tsx +647 -0
  121. package/src/native/topics.tsx +234 -0
  122. package/src/native/tus.ts +282 -0
  123. package/src/native/use-composer.ts +71 -0
  124. package/src/native/use-jump.ts +91 -0
  125. package/src/native/use-voice-recorder.ts +234 -0
  126. package/src/native/video-player.tsx +410 -0
  127. package/src/native/video-recorder-state.ts +140 -0
  128. package/src/native/video-recorder.tsx +274 -0
  129. package/src/native/video-upload-tray.tsx +77 -0
  130. package/src/native/video-upload.ts +432 -0
  131. package/src/native/voice-player.tsx +301 -0
  132. package/src/react/index.tsx +894 -0
  133. package/src/react/previews.ts +51 -0
  134. package/src/server/assistant.ts +235 -0
  135. package/src/server/crypto.ts +86 -0
  136. package/src/server/expo.ts +119 -0
  137. package/src/server/fake-video.ts +171 -0
  138. package/src/server/handler.ts +32 -0
  139. package/src/server/index.ts +58 -0
  140. package/src/server/moderation.ts +155 -0
  141. package/src/server/mux.ts +290 -0
  142. package/src/server/notifications.ts +130 -0
  143. package/src/server/runner.ts +468 -0
  144. package/src/server/transcribers.ts +111 -0
  145. package/src/server/types.ts +171 -0
  146. package/src/server/unfurl.ts +480 -0
  147. package/src/server/upload.ts +158 -0
  148. package/src/web/attachments.tsx +379 -0
  149. package/src/web/availability.tsx +299 -0
  150. package/src/web/broadcast.tsx +166 -0
  151. package/src/web/bubble.tsx +585 -0
  152. package/src/web/cards.tsx +136 -0
  153. package/src/web/composer.tsx +571 -0
  154. package/src/web/emoji-picker.tsx +84 -0
  155. package/src/web/error-boundary.tsx +33 -0
  156. package/src/web/format.ts +55 -0
  157. package/src/web/inbox.tsx +196 -0
  158. package/src/web/index.ts +75 -0
  159. package/src/web/markdown.tsx +245 -0
  160. package/src/web/memory-client.ts +714 -0
  161. package/src/web/panel.tsx +135 -0
  162. package/src/web/schedule.tsx +204 -0
  163. package/src/web/search.tsx +129 -0
  164. package/src/web/thread-context.ts +64 -0
  165. package/src/web/thread.tsx +830 -0
  166. package/src/web/topics.tsx +160 -0
  167. package/src/web/use-voice-recorder.ts +264 -0
  168. package/src/web/video.tsx +516 -0
  169. package/src/web/voice-note.tsx +197 -0
  170. package/dist/chunk-27UTR5FW.js +0 -322
  171. package/dist/chunk-27UTR5FW.js.map +0 -1
  172. package/dist/chunk-JQXZUEEL.js +0 -525
  173. package/dist/chunk-JQXZUEEL.js.map +0 -1
  174. package/dist/chunk-SAY24E6V.js +0 -309
  175. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,54 @@
1
+ /** @feat:chat A scriptable XMLHttpRequest: the test answers each request and reports its progress. */
2
+ import { vi } from "vitest";
3
+
4
+ export class FakeXHR {
5
+ static all: FakeXHR[] = [];
6
+ method = "";
7
+ url = "";
8
+ headers: Record<string, string> = {};
9
+ body: unknown = undefined;
10
+ sent = false;
11
+ aborted = false;
12
+ status = 0;
13
+ responseText = "";
14
+ upload: { onprogress: ((e: ProgressEvent) => void) | null } = { onprogress: null };
15
+ onload: (() => void) | null = null;
16
+ onerror: (() => void) | null = null;
17
+ onabort: (() => void) | null = null;
18
+ constructor() {
19
+ FakeXHR.all.push(this);
20
+ }
21
+ open(method: string, url: string) {
22
+ this.method = method;
23
+ this.url = url;
24
+ }
25
+ setRequestHeader(k: string, v: string) {
26
+ this.headers[k] = v;
27
+ }
28
+ send(body: unknown) {
29
+ this.body = body;
30
+ this.sent = true;
31
+ }
32
+ abort() {
33
+ this.aborted = true;
34
+ this.onabort?.();
35
+ }
36
+ progress(loaded: number, total: number) {
37
+ this.upload.onprogress?.({ loaded, total, lengthComputable: true } as ProgressEvent);
38
+ }
39
+ respond(status = 200, text = "") {
40
+ this.status = status;
41
+ this.responseText = text;
42
+ this.onload?.();
43
+ }
44
+ fail() {
45
+ this.onerror?.();
46
+ }
47
+ }
48
+
49
+ /** Installs FakeXHR as XMLHttpRequest (undone by vi.unstubAllGlobals). */
50
+ export function installFakeXHR(): typeof FakeXHR {
51
+ FakeXHR.all = [];
52
+ vi.stubGlobal("XMLHttpRequest", FakeXHR);
53
+ return FakeXHR;
54
+ }
@@ -0,0 +1,269 @@
1
+ /** @feat:chat Hosted uploads joining the outbox at start, and upload progress. */
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { createChatClient, type ChatClient, type ChatSupabase } from "../core/client";
4
+ import { createOutbox, type HostedUpload, type HostedUploadStart, type OutboxStore, type QueuedMessage } from "../core/outbox";
5
+ import { sendBytes } from "../core/transfer";
6
+ import { installFakeXHR } from "./fake-xhr";
7
+
8
+ afterEach(() => {
9
+ vi.unstubAllGlobals();
10
+ });
11
+
12
+ const tick = () => new Promise((r) => setTimeout(r, 0));
13
+
14
+ /** Saves through JSON, as localStorage or MMKV would: nothing is shared with the live queue. */
15
+ function jsonStore(): OutboxStore & { raw: () => QueuedMessage[] } {
16
+ let saved = "[]";
17
+ return {
18
+ load: async () => JSON.parse(saved) as QueuedMessage[],
19
+ save: async (q) => void (saved = JSON.stringify(q)),
20
+ raw: () => JSON.parse(saved) as QueuedMessage[],
21
+ };
22
+ }
23
+
24
+ /** A client whose send is idempotent by clientId, like send_message. */
25
+ function fakeClient() {
26
+ const posted = new Map<string, { id: string; input: Record<string, unknown> }>();
27
+ const sends: Record<string, unknown>[] = [];
28
+ const client = {
29
+ upload: vi.fn(async (_c: string, _u: string, _d: unknown, o: { name: string; onProgress?: (s: number, t: number) => void }) => {
30
+ o.onProgress?.(5, 10);
31
+ return `c1/me/${o.name}`;
32
+ }),
33
+ send: vi.fn(async (input: Record<string, unknown>) => {
34
+ sends.push(input);
35
+ const clientId = String(input.clientId);
36
+ if (!posted.has(clientId)) posted.set(clientId, { id: `m-${posted.size + 1}`, input });
37
+ const p = posted.get(clientId)!;
38
+ return { id: p.id, conversationId: input.conversationId, clientId, seq: 1 } as never;
39
+ }),
40
+ } as unknown as ChatClient;
41
+ return { client, sends, posted };
42
+ }
43
+
44
+ /** A hosted upload whose bytes finish when the test says. */
45
+ function hostedHook() {
46
+ const calls: { file: string; resume?: unknown }[] = [];
47
+ const finishers: { resolve: () => void; reject: (e: unknown) => void }[] = [];
48
+ let n = 0;
49
+ const hook: HostedUpload = vi.fn(async (_conversationId, file, opts) => {
50
+ calls.push({ file: file.name, resume: opts.resume });
51
+ const done = new Promise<void>((resolve, reject) => finishers.push({ resolve, reject }));
52
+ const start: HostedUploadStart = {
53
+ provider: "mux",
54
+ providerId: opts.resume?.providerId ?? `upl_${++n}`,
55
+ status: "pending",
56
+ url: opts.resume?.url ?? `https://up.test/${n}`,
57
+ done,
58
+ };
59
+ return start;
60
+ });
61
+ return { hook, calls, finishers };
62
+ }
63
+
64
+ const video = (name = "clip.mp4") => ({ data: `file:///cache/${name}`, name, contentType: "video/mp4", hosted: true, meta: { kind: "video" as const, durationMs: 3000 } });
65
+
66
+ describe("a hosted video joins the outbox at start", () => {
67
+ it("is sent in its place, pending, before its bytes are up; text written after it lands after it", async () => {
68
+ const { client, sends } = fakeClient();
69
+ const h = hostedHook();
70
+ const outbox = createOutbox({ client, userId: "me", hostedUpload: h.hook, readFile: async () => new Blob(["v"]) });
71
+ const videoId = await outbox.send({ conversationId: "c1", body: "" }, [video()]);
72
+ const textId = await outbox.send({ conversationId: "c1", body: "Watch this" });
73
+ await tick();
74
+ await tick();
75
+ expect(sends.map((s) => s.clientId)).toEqual([videoId, textId]);
76
+ expect(sends[0]!.attachments).toEqual([
77
+ expect.objectContaining({ kind: "video", provider: "mux", providerId: "upl_1", status: "pending", mime: "video/mp4", durationMs: 3000 }),
78
+ ]);
79
+ // Nothing went to storage.
80
+ expect(client.upload).not.toHaveBeenCalled();
81
+ // Posted, it waits only for its bytes.
82
+ expect(outbox.pending("c1")).toEqual([expect.objectContaining({ clientId: videoId, sentMessageId: "m-1", status: "sending" })]);
83
+ h.finishers[0]!.resolve();
84
+ await tick();
85
+ expect(outbox.pending("c1")).toEqual([]);
86
+ });
87
+
88
+ it("carries the upload on after a relaunch and sends the message again with the same clientId and upload id", async () => {
89
+ const store = jsonStore();
90
+ const first = fakeClient();
91
+ const h1 = hostedHook();
92
+ const before = createOutbox({ client: first.client, userId: "me", store, hostedUpload: h1.hook });
93
+ const id = await before.send({ conversationId: "c1", body: "" }, [video()]);
94
+ await tick();
95
+ await tick();
96
+ expect(first.sends).toHaveLength(1);
97
+ expect(store.raw()[0]).toMatchObject({ clientId: id, hosted: { 0: { provider: "mux", providerId: "upl_1", url: "https://up.test/1" } } });
98
+
99
+ // The app closes with the bytes half up; it opens again.
100
+ const h2 = hostedHook();
101
+ const after = createOutbox({ client: first.client, userId: "me", store, hostedUpload: h2.hook });
102
+ await after.load();
103
+ await after.retryAll();
104
+ await tick();
105
+ await tick();
106
+ expect(h2.calls).toEqual([{ file: "clip.mp4", resume: expect.objectContaining({ provider: "mux", providerId: "upl_1", url: "https://up.test/1" }) }]);
107
+ expect(first.sends).toHaveLength(2);
108
+ expect(first.sends[1]).toMatchObject({ clientId: id, attachments: [expect.objectContaining({ providerId: "upl_1", status: "pending" })] });
109
+ // Posted once.
110
+ expect(first.posted.size).toBe(1);
111
+ h2.finishers[0]!.resolve();
112
+ await tick();
113
+ expect(after.pending()).toEqual([]);
114
+ expect(store.raw()).toEqual([]);
115
+ });
116
+
117
+ it("a failed transfer marks the posted message failed without holding later ones; Retry carries the same upload on", async () => {
118
+ const { client, sends } = fakeClient();
119
+ const h = hostedHook();
120
+ const outbox = createOutbox({ client, userId: "me", hostedUpload: h.hook });
121
+ const videoId = await outbox.send({ conversationId: "c1", body: "" }, [video()]);
122
+ await tick();
123
+ h.finishers[0]!.reject(new Error("network"));
124
+ await tick();
125
+ await tick();
126
+ expect(outbox.pending("c1")[0]).toMatchObject({ clientId: videoId, status: "failed", error: "The video didn't finish uploading" });
127
+ await outbox.send({ conversationId: "c1", body: "Still here" });
128
+ await tick();
129
+ expect(sends.at(-1)).toMatchObject({ body: "Still here" });
130
+
131
+ await outbox.retry(videoId);
132
+ await tick();
133
+ await tick();
134
+ expect(h.calls.at(-1)).toEqual({ file: "clip.mp4", resume: expect.objectContaining({ providerId: "upl_1" }) });
135
+ h.finishers.at(-1)!.resolve();
136
+ await tick();
137
+ expect(outbox.pending("c1")).toEqual([]);
138
+ });
139
+
140
+ it("goes to storage, as before, without a hostedUpload", async () => {
141
+ const { client, sends } = fakeClient();
142
+ const outbox = createOutbox({ client, userId: "me", readFile: async () => new Blob(["v"]) });
143
+ await outbox.send({ conversationId: "c1", body: "" }, [video()]);
144
+ await tick();
145
+ await tick();
146
+ expect(client.upload).toHaveBeenCalledTimes(1);
147
+ expect(sends[0]!.attachments).toEqual([expect.objectContaining({ kind: "video", path: "c1/me/clip.mp4" })]);
148
+ });
149
+
150
+ it("discarding the message stops its bytes", async () => {
151
+ const { client } = fakeClient();
152
+ let signal: AbortSignal | undefined;
153
+ const hook: HostedUpload = async (_c, _f, opts) => {
154
+ signal = opts.signal;
155
+ return { provider: "mux", providerId: "upl_x", status: "pending", done: new Promise<void>(() => undefined) };
156
+ };
157
+ const outbox = createOutbox({ client, userId: "me", hostedUpload: hook });
158
+ const id = await outbox.send({ conversationId: "c1", body: "" }, [video()]);
159
+ await tick();
160
+ await outbox.discard(id);
161
+ expect(signal?.aborted).toBe(true);
162
+ expect(outbox.pending()).toEqual([]);
163
+ });
164
+ });
165
+
166
+ describe("upload progress", () => {
167
+ it("shows each storage file's progress on the pending message", async () => {
168
+ const { client } = fakeClient();
169
+ let finish!: () => void;
170
+ let report!: (s: number, t: number) => void;
171
+ (client.upload as ReturnType<typeof vi.fn>).mockImplementationOnce(
172
+ (_c: string, _u: string, _d: unknown, o: { name: string; onProgress?: (s: number, t: number) => void }) =>
173
+ new Promise((r) => {
174
+ report = o.onProgress!;
175
+ finish = () => r(`c1/me/${o.name}`);
176
+ }),
177
+ );
178
+ const changes: QueuedMessage[][] = [];
179
+ const outbox = createOutbox({ client, userId: "me", onChange: (q) => changes.push(q) });
180
+ await outbox.send({ conversationId: "c1" }, [{ data: new Blob(["0123456789"]), name: "a.png", contentType: "image/png" }]);
181
+ await tick();
182
+ report(4, 10);
183
+ expect(outbox.pending("c1")[0]!.files?.[0]?.progress).toBe(0.4);
184
+ report(10, 10);
185
+ expect(outbox.pending("c1")[0]!.files?.[0]?.progress).toBe(1);
186
+ finish();
187
+ await tick();
188
+ expect(outbox.pending("c1")).toEqual([]);
189
+ });
190
+
191
+ it("passes onProgress to the app's upload hook too", async () => {
192
+ const { client } = fakeClient();
193
+ let seen = 0;
194
+ const outbox = createOutbox({
195
+ client,
196
+ userId: "me",
197
+ upload: async (_c, file, o) => {
198
+ o?.onProgress?.(1, 2);
199
+ seen = outbox.pending("c1")[0]!.files![0]!.progress!;
200
+ return `c1/me/${file.name}`;
201
+ },
202
+ });
203
+ await outbox.send({ conversationId: "c1" }, [{ data: "file:///a.jpg", name: "a.jpg", contentType: "image/jpeg" }]);
204
+ await tick();
205
+ expect(seen).toBe(0.5);
206
+ });
207
+ });
208
+
209
+ describe("client.upload with onProgress", () => {
210
+ function storageFake() {
211
+ const signedFor: string[] = [];
212
+ const plain: string[] = [];
213
+ const supabase = {
214
+ schema: () => ({}),
215
+ storage: {
216
+ from: () => ({
217
+ createSignedUploadUrl: async (path: string) => {
218
+ signedFor.push(path);
219
+ return { data: { signedUrl: `https://sb.test/storage/v1/object/upload/sign/genesis-chat/${path}?token=t`, path, token: "t" }, error: null };
220
+ },
221
+ upload: async (path: string) => {
222
+ plain.push(path);
223
+ return { data: { path }, error: null };
224
+ },
225
+ }),
226
+ },
227
+ } as unknown as ChatSupabase;
228
+ return { supabase, signedFor, plain };
229
+ }
230
+
231
+ it("sends the bytes to a signed upload URL through XMLHttpRequest and reports progress", async () => {
232
+ const XHR = installFakeXHR();
233
+ const s = storageFake();
234
+ const client = createChatClient(s.supabase);
235
+ const progress: number[][] = [];
236
+ const going = client.upload("c1", "me", new Blob(["0123456789"]), { name: "a.png", contentType: "image/png", id: "f1", onProgress: (a, b) => progress.push([a, b]) });
237
+ await tick();
238
+ const xhr = XHR.all[0]!;
239
+ expect(xhr).toMatchObject({ method: "PUT", url: "https://sb.test/storage/v1/object/upload/sign/genesis-chat/c1/me/f1.png?token=t", sent: true });
240
+ expect(xhr.headers).toMatchObject({ "content-type": "image/png", "x-upsert": "false" });
241
+ xhr.progress(3, 10);
242
+ xhr.respond(200, "{}");
243
+ expect(await going).toBe("c1/me/f1.png");
244
+ expect(progress).toEqual([
245
+ [3, 10],
246
+ [10, 10],
247
+ ]);
248
+ expect(s.plain).toEqual([]);
249
+ });
250
+
251
+ it("uses the plain upload without onProgress", async () => {
252
+ installFakeXHR();
253
+ const s = storageFake();
254
+ const client = createChatClient(s.supabase);
255
+ expect(await client.upload("c1", "me", new Blob(["x"]), { name: "a.png", contentType: "image/png", id: "f2" })).toBe("c1/me/f2.png");
256
+ expect(s.plain).toEqual(["c1/me/f2.png"]);
257
+ expect(s.signedFor).toEqual([]);
258
+ });
259
+
260
+ it("a refused PUT is a permanent ChatError; a dropped connection is transient", async () => {
261
+ const XHR = installFakeXHR();
262
+ const refused = sendBytes("https://x.test/a", new Blob(["x"]));
263
+ XHR.all[0]!.respond(403);
264
+ await expect(refused).rejects.toMatchObject({ name: "ChatError", permanent: true });
265
+ const dropped = sendBytes("https://x.test/b", new Blob(["x"]));
266
+ XHR.all[1]!.fail();
267
+ await expect(dropped).rejects.toMatchObject({ name: "ChatError", transient: true });
268
+ });
269
+ });
@@ -0,0 +1,122 @@
1
+ /** @feat:chat */
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import type { ChatClient } from "../core/client";
4
+ import { randomId } from "../core/media";
5
+ import { createOutbox, memoryOutboxStore, type QueuedMessage } from "../core/outbox";
6
+
7
+ const tick = () => new Promise((r) => setTimeout(r, 0));
8
+
9
+ function fakeClient() {
10
+ const sent: unknown[] = [];
11
+ const client = {
12
+ upload: vi.fn(async (_c: string, _u: string, _d: unknown, o: { name: string }) => `c1/me/${o.name}`),
13
+ send: vi.fn(async (input: { clientId: string; conversationId: string }) => {
14
+ sent.push(input);
15
+ return { id: `m-${input.clientId}`, conversationId: input.conversationId, clientId: input.clientId, seq: sent.length } as never;
16
+ }),
17
+ } as unknown as ChatClient;
18
+ return { client, sent };
19
+ }
20
+
21
+ describe("ids", () => {
22
+ const original = globalThis.crypto;
23
+ afterEach(() => Object.defineProperty(globalThis, "crypto", { value: original, configurable: true }));
24
+
25
+ it("makes UUIDs where crypto.randomUUID is missing (some React Native runtimes)", () => {
26
+ Object.defineProperty(globalThis, "crypto", { value: { getRandomValues: original.getRandomValues.bind(original) }, configurable: true });
27
+ const id = randomId();
28
+ expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
29
+ Object.defineProperty(globalThis, "crypto", { value: undefined, configurable: true });
30
+ expect(randomId()).not.toBe(randomId());
31
+ });
32
+
33
+ it("uses the app's id maker when given one", async () => {
34
+ const { client } = fakeClient();
35
+ const outbox = createOutbox({ client, userId: "me", newId: () => "fixed-id" });
36
+ expect(await outbox.send({ conversationId: "c1", body: "hi" })).toBe("fixed-id");
37
+ });
38
+ });
39
+
40
+ describe("files across a relaunch", () => {
41
+ it("says what each pending file is, so the bubble can draw it", async () => {
42
+ const { client } = fakeClient();
43
+ let release!: () => void;
44
+ (client.upload as ReturnType<typeof vi.fn>).mockImplementationOnce(() => new Promise((r) => (release = () => r("c1/me/v.m4a"))));
45
+ const outbox = createOutbox({ client, userId: "me" });
46
+ await outbox.send({ conversationId: "c1" }, [{ data: new Blob(["x"]), name: "v.m4a", contentType: "audio/mp4", meta: { kind: "voice", durationMs: 4200 } }]);
47
+ expect(outbox.pending("c1")[0]!.files).toEqual([{ name: "v.m4a", contentType: "audio/mp4", kind: "voice", meta: { kind: "voice", durationMs: 4200 } }]);
48
+ release();
49
+ });
50
+
51
+ it("never sends a message without the pasted image it lost on a relaunch; it asks for it again", async () => {
52
+ const store = memoryOutboxStore();
53
+ const first = fakeClient();
54
+ (first.client.upload as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("offline"));
55
+ const before = createOutbox({ client: first.client, userId: "me", store });
56
+ const id = await before.send({ conversationId: "c1", body: "look" }, [{ data: new Blob(["png"]), name: "paste.png", contentType: "image/png" }]);
57
+ await tick();
58
+
59
+ const again = fakeClient();
60
+ const after = createOutbox({ client: again.client, userId: "me", store });
61
+ await after.load();
62
+ await after.retryAll();
63
+ await tick();
64
+ expect(again.client.send).not.toHaveBeenCalled();
65
+ expect(after.pending("c1")[0]).toMatchObject({ status: "failed", error: "Attach the file again to send" });
66
+
67
+ await after.reattach(id, [{ data: new Blob(["png"]), name: "paste.png", contentType: "image/png" }]);
68
+ await tick();
69
+ expect(again.sent).toHaveLength(1);
70
+ expect(after.pending("c1")).toHaveLength(0);
71
+ });
72
+
73
+ it("keeps a pasted image across a relaunch when the app can save it", async () => {
74
+ const store = memoryOutboxStore();
75
+ const first = fakeClient();
76
+ (first.client.upload as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("offline"));
77
+ const persistFile = vi.fn(async () => "file:///cache/paste.png");
78
+ const before = createOutbox({ client: first.client, userId: "me", store, persistFile });
79
+ await before.send({ conversationId: "c1" }, [{ data: new Blob(["png"]), name: "paste.png", contentType: "image/png" }]);
80
+ await tick();
81
+ const saved = (await store.load()) as QueuedMessage[];
82
+ expect(saved[0]!.files[0]!.data).toBe("file:///cache/paste.png");
83
+
84
+ const again = fakeClient();
85
+ const readFile = vi.fn(async () => new Blob(["png"]));
86
+ const after = createOutbox({ client: again.client, userId: "me", store, readFile });
87
+ await after.load();
88
+ await after.retryAll();
89
+ await tick();
90
+ expect(readFile).toHaveBeenCalledWith("file:///cache/paste.png");
91
+ expect(again.sent).toHaveLength(1);
92
+ });
93
+ });
94
+
95
+ describe("reattaching only what was lost", () => {
96
+ it("fills the lost slots and keeps the files that survived", async () => {
97
+ const store = memoryOutboxStore();
98
+ const first = fakeClient();
99
+ (first.client.upload as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("offline"));
100
+ const before = createOutbox({ client: first.client, userId: "me", store });
101
+ const id = await before.send({ conversationId: "c1" }, [
102
+ { data: "file:///docs/plan.pdf", name: "plan.pdf", contentType: "application/pdf" },
103
+ { data: new Blob(["png"]), name: "paste.png", contentType: "image/png" },
104
+ ]);
105
+ await tick();
106
+
107
+ const again = fakeClient();
108
+ const after = createOutbox({ client: again.client, userId: "me", store, readFile: async () => new Blob(["pdf"]) });
109
+ await after.load();
110
+ expect(after.pending("c1")[0]!.files!.map((f) => [f.name, Boolean(f.lost)])).toEqual([["plan.pdf", false], ["paste.png", true]]);
111
+
112
+ // Saving again (any change) must not forget which slot was lost.
113
+ await after.retryAll();
114
+ expect(((await store.load()) as QueuedMessage[])[0]!.pendingFiles!.map((f) => Boolean(f.lost))).toEqual([false, true]);
115
+
116
+ await after.reattach(id, [{ data: new Blob(["png2"]), name: "paste-again.png", contentType: "image/png" }]);
117
+ await tick();
118
+ expect(again.client.upload).toHaveBeenCalledTimes(2);
119
+ expect((again.client.upload as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[3].name)).toEqual(["plan.pdf", "paste-again.png"]);
120
+ expect(again.sent).toHaveLength(1);
121
+ });
122
+ });
@@ -0,0 +1,70 @@
1
+ /** @feat:chat */
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { createChatClient, type ChatSupabase } from "../core/client";
4
+
5
+ function fakeRealtime() {
6
+ const order: string[] = [];
7
+ let release!: () => void;
8
+ const authed = new Promise<void>((r) => (release = r));
9
+ const channel = {
10
+ on: vi.fn(() => channel),
11
+ status: undefined as undefined | ((s: string) => void),
12
+ subscribe: vi.fn((cb?: (s: string) => void) => {
13
+ order.push("subscribe");
14
+ channel.status = cb;
15
+ return channel;
16
+ }),
17
+ send: vi.fn(),
18
+ };
19
+ const supabase = {
20
+ realtime: {
21
+ setAuth: vi.fn(async () => {
22
+ await authed;
23
+ order.push("setAuth");
24
+ }),
25
+ },
26
+ channel: vi.fn(() => channel),
27
+ removeChannel: vi.fn(),
28
+ schema: vi.fn(),
29
+ } as unknown as ChatSupabase;
30
+ return { supabase, channel, order, release };
31
+ }
32
+
33
+ const tick = () => new Promise((r) => setTimeout(r, 0));
34
+
35
+ describe("joining private topics", () => {
36
+ it("sets the user's token before joining, so a private join isn't refused", async () => {
37
+ const f = fakeRealtime();
38
+ createChatClient(f.supabase).subscribeConversation("c1", "me", { onSignal: () => {} });
39
+ await tick();
40
+ expect(f.channel.subscribe).not.toHaveBeenCalled();
41
+ f.release();
42
+ await tick();
43
+ expect(f.order).toEqual(["setAuth", "subscribe"]);
44
+ });
45
+
46
+ it("never joins a channel left before its token was set", async () => {
47
+ const f = fakeRealtime();
48
+ const sub = createChatClient(f.supabase).subscribeInbox("me", () => {});
49
+ sub.unsubscribe();
50
+ f.release();
51
+ await tick();
52
+ expect(f.channel.subscribe).not.toHaveBeenCalled();
53
+ expect(f.supabase.removeChannel).toHaveBeenCalled();
54
+ });
55
+
56
+ it("drops typing signals until the channel has joined (no REST fallback)", async () => {
57
+ const f = fakeRealtime();
58
+ const ch = createChatClient(f.supabase).subscribeConversation("c1", "me", { onSignal: () => {} });
59
+ f.release();
60
+ await tick();
61
+ ch.signal("typing");
62
+ expect(f.channel.send).not.toHaveBeenCalled();
63
+ f.channel.status?.("SUBSCRIBED");
64
+ ch.signal("typing");
65
+ expect(f.channel.send).toHaveBeenCalledTimes(1);
66
+ f.channel.status?.("CHANNEL_ERROR");
67
+ ch.signal("typing");
68
+ expect(f.channel.send).toHaveBeenCalledTimes(1);
69
+ });
70
+ });
@@ -0,0 +1,41 @@
1
+ /** @feat:chat Fixes from the review of release 279: upload refusals, and what app cards are told. */
2
+ import { describe, expect, it } from "vitest";
3
+ import { ChatError } from "../core/client";
4
+ import { sendBytes } from "../core/transfer";
5
+ import type { ChatAttachment } from "../core/types";
6
+ import type { ChatAttachmentKind, ChatCardContext } from "../react/index";
7
+ import { attachmentCardOf } from "../web/attachments";
8
+
9
+ const answering = (status: number) => (async () => new Response("", { status })) as unknown as typeof fetch;
10
+ const failure = (status: number) => sendBytes("https://x.test/a", new Blob(["x"]), { fetch: answering(status) }).then(
11
+ () => { throw new Error("expected a failure"); },
12
+ (e: ChatError) => e,
13
+ );
14
+
15
+ describe("an upload's failed answer", () => {
16
+ it("is retried on its own after a timeout or a server error", async () => {
17
+ expect((await failure(408)).transient).toBe(true);
18
+ expect((await failure(503)).transient).toBe(true);
19
+ });
20
+ it("waits when rate limited, without blocking the conversation as a refusal would", async () => {
21
+ const e = await failure(429);
22
+ expect(e.rateLimited).toBe(true);
23
+ expect(e.permanent).toBe(false);
24
+ });
25
+ it("is a refusal for any other 4xx", async () => {
26
+ expect((await failure(403)).permanent).toBe(true);
27
+ });
28
+ });
29
+
30
+ describe("an app card on the web", () => {
31
+ const checkIn: ChatAttachmentKind = {
32
+ title: () => "Check-in",
33
+ action: (_a, ctx?: ChatCardContext) => (ctx?.mine ? null : { label: "Answer", href: "https://app.test/check-in" }),
34
+ };
35
+ const a = { kind: "check_in", ref: {} } as unknown as ChatAttachment;
36
+
37
+ it("is told whether it is on your own message", () => {
38
+ expect(attachmentCardOf(checkIn, a, { mine: false })?.action?.label).toBe("Answer");
39
+ expect(attachmentCardOf(checkIn, a, { mine: true })?.action).toBeNull();
40
+ });
41
+ });