@marktiderman/genesis-chat 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +304 -13
- package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
- package/dist/chunk-5D5A7I4Q.js.map +1 -0
- package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
- package/dist/chunk-7S6HYNWW.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-PZTOY2LY.js +576 -0
- package/dist/chunk-PZTOY2LY.js.map +1 -0
- package/dist/chunk-VEPZROQB.js +187 -0
- package/dist/chunk-VEPZROQB.js.map +1 -0
- package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1910 -1
- package/dist/native.js +4900 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B0y3phE0.d.ts +151 -0
- package/dist/push.d.ts +206 -0
- package/dist/push.js +3 -0
- package/dist/push.js.map +1 -0
- package/dist/react.d.ts +149 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +510 -21
- package/dist/server.js +863 -21
- package/dist/server.js.map +1 -1
- package/dist/thread-DWXHKPdw.d.ts +57 -0
- package/dist/web.d.ts +313 -10
- package/dist/web.js +1731 -60
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
- package/package.json +74 -6
- package/src/__tests__/coaching-core.test.ts +332 -0
- package/src/__tests__/coaching-react.test.tsx +248 -0
- package/src/__tests__/coaching-web.test.tsx +449 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +402 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fakes.ts +90 -0
- package/src/__tests__/server-integrations.test.ts +247 -0
- package/src/__tests__/server-providers.test.ts +246 -0
- package/src/__tests__/server-runner.test.ts +277 -0
- package/src/__tests__/server-safety.test.ts +290 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +333 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +757 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +429 -0
- package/src/core/thread.ts +171 -0
- package/src/core/types.ts +311 -0
- package/src/index.ts +13 -0
- package/src/native/__tests__/auto-retry.test.tsx +128 -0
- package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
- package/src/native/__tests__/composer-state.test.ts +188 -0
- package/src/native/__tests__/composer.test.tsx +133 -0
- package/src/native/__tests__/emoji.test.tsx +238 -0
- package/src/native/__tests__/inbox-screen.test.tsx +48 -0
- package/src/native/__tests__/message-bubble.test.tsx +127 -0
- package/src/native/__tests__/organise-logic.test.ts +164 -0
- package/src/native/__tests__/organise-screens.test.tsx +344 -0
- package/src/native/__tests__/photo-viewer.test.tsx +226 -0
- package/src/native/__tests__/push-payload.test.ts +66 -0
- package/src/native/__tests__/push.test.ts +209 -0
- package/src/native/__tests__/recorder-machine.test.ts +96 -0
- package/src/native/__tests__/rows-actions.test.ts +76 -0
- package/src/native/__tests__/thread-layout.test.ts +41 -0
- package/src/native/__tests__/thread-screen.test.tsx +116 -0
- package/src/native/__tests__/tus.test.ts +154 -0
- package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
- package/src/native/__tests__/video-media.test.tsx +421 -0
- package/src/native/__tests__/video-recorder-state.test.ts +87 -0
- package/src/native/__tests__/video-upload.test.ts +227 -0
- package/src/native/access-error.tsx +31 -0
- package/src/native/actions.ts +42 -0
- package/src/native/attachment-cards.tsx +116 -0
- package/src/native/auto-retry.ts +69 -0
- package/src/native/availability.tsx +376 -0
- package/src/native/broadcast-sheet.tsx +193 -0
- package/src/native/composer-state.ts +302 -0
- package/src/native/composer.tsx +453 -0
- package/src/native/context.tsx +70 -0
- package/src/native/copy.ts +296 -0
- package/src/native/emoji-data.ts +21 -0
- package/src/native/emoji-picker.tsx +229 -0
- package/src/native/emoji.ts +118 -0
- package/src/native/icon-button.tsx +40 -0
- package/src/native/inbox-screen.tsx +116 -0
- package/src/native/index.ts +184 -0
- package/src/native/link-preview.tsx +104 -0
- package/src/native/media-colors.ts +15 -0
- package/src/native/media.tsx +187 -0
- package/src/native/message-actions-sheet.tsx +121 -0
- package/src/native/message-bubble.tsx +374 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +275 -0
- package/src/native/organise-copy.ts +195 -0
- package/src/native/photo-viewer.tsx +531 -0
- package/src/native/push.ts +375 -0
- package/src/native/recorder-machine.ts +176 -0
- package/src/native/rows.ts +81 -0
- package/src/native/schedule-sheet.tsx +314 -0
- package/src/native/search-screen.tsx +168 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +620 -0
- package/src/native/topics.tsx +179 -0
- package/src/native/tus.ts +282 -0
- package/src/native/use-composer.ts +71 -0
- package/src/native/use-jump.ts +91 -0
- package/src/native/use-voice-recorder.ts +234 -0
- package/src/native/video-player.tsx +370 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +271 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +283 -0
- package/src/react/index.tsx +810 -0
- package/src/server/assistant.ts +235 -0
- package/src/server/crypto.ts +86 -0
- package/src/server/expo.ts +119 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +57 -0
- package/src/server/moderation.ts +155 -0
- package/src/server/mux.ts +290 -0
- package/src/server/notifications.ts +130 -0
- package/src/server/runner.ts +468 -0
- package/src/server/transcribers.ts +111 -0
- package/src/server/types.ts +171 -0
- package/src/server/unfurl.ts +480 -0
- package/src/server/upload.ts +158 -0
- package/src/web/attachments.tsx +379 -0
- package/src/web/availability.tsx +299 -0
- package/src/web/broadcast.tsx +166 -0
- package/src/web/bubble.tsx +563 -0
- package/src/web/cards.tsx +136 -0
- package/src/web/composer.tsx +571 -0
- package/src/web/emoji-picker.tsx +84 -0
- package/src/web/error-boundary.tsx +33 -0
- package/src/web/format.ts +55 -0
- package/src/web/inbox.tsx +195 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +706 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +192 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +802 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +522 -0
- package/src/web/voice-note.tsx +197 -0
- package/dist/chunk-27UTR5FW.js +0 -322
- package/dist/chunk-27UTR5FW.js.map +0 -1
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// @feat:chat
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ChatError } from "../../core/client";
|
|
4
|
+
import { base64Utf8, createTusUpload } from "../tus";
|
|
5
|
+
|
|
6
|
+
const URL_ = "http://127.0.0.1:54321";
|
|
7
|
+
const CONV = "c0000000-0000-4000-8000-000000000001";
|
|
8
|
+
const USER = "u0000000-0000-4000-8000-000000000002";
|
|
9
|
+
|
|
10
|
+
function server(opts: { failPatchOnce?: boolean; createStatus?: number | (() => number); objectThere?: boolean } = {}) {
|
|
11
|
+
let received = 0;
|
|
12
|
+
let failed = false;
|
|
13
|
+
const calls: { method: string; url: string; headers: Record<string, string>; size?: number }[] = [];
|
|
14
|
+
const fetch = vi.fn(async (url: string, init: RequestInit = {}) => {
|
|
15
|
+
const headers = Object.fromEntries(Object.entries((init.headers ?? {}) as Record<string, string>));
|
|
16
|
+
const method = init.method ?? "GET";
|
|
17
|
+
const size = init.body instanceof ArrayBuffer ? init.body.byteLength : undefined;
|
|
18
|
+
calls.push({ method, url, headers, size });
|
|
19
|
+
if (method === "POST") {
|
|
20
|
+
const status = (typeof opts.createStatus === "function" ? opts.createStatus() : opts.createStatus) ?? 201;
|
|
21
|
+
return new Response(null, { status, headers: status === 201 ? { location: `${URL_}/storage/v1/upload/resumable/abc` } : {} });
|
|
22
|
+
}
|
|
23
|
+
// Storage answers a missing object with 400 (its body says 404).
|
|
24
|
+
if (method === "HEAD" && url.includes("/storage/v1/object/authenticated/")) return new Response(null, { status: opts.objectThere ? 200 : 400 });
|
|
25
|
+
if (method === "HEAD") return new Response(null, { status: 200, headers: { "upload-offset": String(received) } });
|
|
26
|
+
if (method === "PATCH") {
|
|
27
|
+
if (opts.failPatchOnce && !failed && received > 0) {
|
|
28
|
+
failed = true;
|
|
29
|
+
throw new TypeError("Network request failed");
|
|
30
|
+
}
|
|
31
|
+
expect(Number(headers["upload-offset"])).toBe(received);
|
|
32
|
+
received += size ?? 0;
|
|
33
|
+
return new Response(null, { status: 204, headers: { "upload-offset": String(received) } });
|
|
34
|
+
}
|
|
35
|
+
return new Response(new Uint8Array(10).buffer, { status: 200 });
|
|
36
|
+
});
|
|
37
|
+
return { fetch: fetch as unknown as typeof globalThis.fetch, calls, received: () => received };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const file = (bytes: number) => ({ data: new Uint8Array(bytes).buffer, name: "photo.jpg", contentType: "image/jpeg" });
|
|
41
|
+
|
|
42
|
+
describe("createTusUpload", () => {
|
|
43
|
+
it("creates the upload at the conversation's path with the person's token, then sends it in chunks", async () => {
|
|
44
|
+
const s = server();
|
|
45
|
+
const upload = createTusUpload({ supabaseUrl: `${URL_}/`, userId: USER, getAccessToken: async () => "jwt", apikey: "pk", fetch: s.fetch, chunkBytes: 4, newId: () => "id1" });
|
|
46
|
+
const path = await upload(CONV, file(10));
|
|
47
|
+
expect(path).toBe(`${CONV}/${USER}/id1.jpg`);
|
|
48
|
+
const create = s.calls[0]!;
|
|
49
|
+
expect(create).toMatchObject({ method: "POST", url: `${URL_}/storage/v1/upload/resumable` });
|
|
50
|
+
expect(create.headers).toMatchObject({ authorization: "Bearer jwt", apikey: "pk", "tus-resumable": "1.0.0", "upload-length": "10", "x-upsert": "false" });
|
|
51
|
+
expect(create.headers["upload-metadata"]).toContain(`bucketName ${base64Utf8("genesis-chat")}`);
|
|
52
|
+
expect(create.headers["upload-metadata"]).toContain(`objectName ${base64Utf8(path)}`);
|
|
53
|
+
expect(s.calls.filter((c) => c.method === "PATCH").map((c) => c.size)).toEqual([4, 4, 2]);
|
|
54
|
+
expect(s.received()).toBe(10);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("resumes after a dropped chunk from where the server is, and a retry reuses the path", async () => {
|
|
58
|
+
const s = server({ failPatchOnce: true });
|
|
59
|
+
const upload = createTusUpload({ supabaseUrl: URL_, userId: USER, getAccessToken: async () => "jwt", fetch: s.fetch, chunkBytes: 4, newId: () => "id2", retries: 0 });
|
|
60
|
+
const f = file(10);
|
|
61
|
+
await expect(upload(CONV, f)).rejects.toBeInstanceOf(ChatError);
|
|
62
|
+
const path = await upload(CONV, f);
|
|
63
|
+
expect(path).toBe(`${CONV}/${USER}/id2.jpg`);
|
|
64
|
+
expect(s.calls.filter((c) => c.method === "POST")).toHaveLength(1);
|
|
65
|
+
expect(s.calls.some((c) => c.method === "HEAD")).toBe(true);
|
|
66
|
+
expect(s.received()).toBe(10);
|
|
67
|
+
// Done: a third call doesn't upload again.
|
|
68
|
+
const before = s.calls.length;
|
|
69
|
+
expect(await upload(CONV, f)).toBe(path);
|
|
70
|
+
expect(s.calls.length).toBe(before);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("an upload the server already has (409) counts as done once the object is checked to be there", async () => {
|
|
74
|
+
const s = server({ createStatus: 409, objectThere: true });
|
|
75
|
+
const upload = createTusUpload({ supabaseUrl: URL_, userId: USER, getAccessToken: async () => "jwt", fetch: s.fetch, newId: () => "id3" });
|
|
76
|
+
expect(await upload(CONV, file(3))).toBe(`${CONV}/${USER}/id3.jpg`);
|
|
77
|
+
expect(s.calls.filter((c) => c.method === "PATCH")).toHaveLength(0);
|
|
78
|
+
expect(s.calls.find((c) => c.method === "HEAD")).toMatchObject({ url: `${URL_}/storage/v1/object/authenticated/genesis-chat/${CONV}/${USER}/id3.jpg`, headers: { authorization: "Bearer jwt" } });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("a 409 whose object isn't there is never taken as done: the file goes up whole at a new path", async () => {
|
|
82
|
+
let creates = 0;
|
|
83
|
+
const s = server({ createStatus: () => (++creates === 1 ? 409 : 201), objectThere: false });
|
|
84
|
+
const ids = ["old", "new"];
|
|
85
|
+
const upload = createTusUpload({ supabaseUrl: URL_, userId: USER, getAccessToken: async () => "jwt", fetch: s.fetch, newId: () => ids.shift()!, chunkBytes: 4 });
|
|
86
|
+
expect(await upload(CONV, file(10))).toBe(`${CONV}/${USER}/new.jpg`);
|
|
87
|
+
expect(s.received()).toBe(10);
|
|
88
|
+
const creates2 = s.calls.filter((c) => c.method === "POST");
|
|
89
|
+
expect(creates2).toHaveLength(2);
|
|
90
|
+
expect(creates2[1]!.headers["upload-metadata"]).toContain(`objectName ${base64Utf8(`${CONV}/${USER}/new.jpg`)}`);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("a refusal is a ChatError that isn't retried; signed out or a bad type never reaches the server", async () => {
|
|
94
|
+
const s = server({ createStatus: 403 });
|
|
95
|
+
const upload = createTusUpload({ supabaseUrl: URL_, userId: USER, getAccessToken: async () => "jwt", fetch: s.fetch });
|
|
96
|
+
const err = await upload(CONV, file(3)).catch((e) => e);
|
|
97
|
+
expect(err).toBeInstanceOf(ChatError);
|
|
98
|
+
expect(err.forbidden).toBe(true);
|
|
99
|
+
expect(s.calls.filter((c) => c.method === "POST")).toHaveLength(1);
|
|
100
|
+
|
|
101
|
+
const s2 = server();
|
|
102
|
+
const signedOut = createTusUpload({ supabaseUrl: URL_, userId: USER, getAccessToken: async () => null, fetch: s2.fetch });
|
|
103
|
+
await expect(signedOut(CONV, file(3))).rejects.toMatchObject({ code: "42501" });
|
|
104
|
+
await expect(signedOut(CONV, { ...file(3), name: "x.svg", contentType: "image/svg+xml" })).rejects.toMatchObject({ code: "22023" });
|
|
105
|
+
expect(s2.calls.filter((c) => c.method !== "GET")).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("reads a file URI one chunk at a time through openFile, never whole, and closes it", async () => {
|
|
109
|
+
const s = server();
|
|
110
|
+
const reads: [number, number][] = [];
|
|
111
|
+
const close = vi.fn();
|
|
112
|
+
const openFile = vi.fn(async () => ({
|
|
113
|
+
size: 10,
|
|
114
|
+
read: async (offset: number, length: number) => {
|
|
115
|
+
reads.push([offset, length]);
|
|
116
|
+
return new Uint8Array(Math.min(length, 10 - offset));
|
|
117
|
+
},
|
|
118
|
+
close,
|
|
119
|
+
}));
|
|
120
|
+
const readFile = vi.fn(async () => new Uint8Array(10).buffer);
|
|
121
|
+
const upload = createTusUpload({ supabaseUrl: URL_, userId: USER, getAccessToken: async () => "jwt", fetch: s.fetch, openFile, readFile, chunkBytes: 4, newId: () => "id5" });
|
|
122
|
+
await upload(CONV, { data: "file:///video.mp4", name: "video.mp4", contentType: "video/mp4" });
|
|
123
|
+
expect(openFile).toHaveBeenCalledWith("file:///video.mp4");
|
|
124
|
+
expect(readFile).not.toHaveBeenCalled();
|
|
125
|
+
expect(reads).toEqual([[0, 4], [4, 4], [8, 2]]);
|
|
126
|
+
expect(s.calls.find((c) => c.method === "POST")!.headers["upload-length"]).toBe("10");
|
|
127
|
+
expect(s.calls.filter((c) => c.method === "PATCH").map((c) => c.size)).toEqual([4, 4, 2]);
|
|
128
|
+
expect(close).toHaveBeenCalledTimes(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("closes the file when the upload fails", async () => {
|
|
132
|
+
const s = server({ createStatus: 403 });
|
|
133
|
+
const close = vi.fn();
|
|
134
|
+
const upload = createTusUpload({ supabaseUrl: URL_, userId: USER, getAccessToken: async () => "jwt", fetch: s.fetch, openFile: () => ({ size: 3, read: async () => new Uint8Array(3), close }) });
|
|
135
|
+
await expect(upload(CONV, { data: "file:///a.jpg", name: "a.jpg", contentType: "image/jpeg" })).rejects.toBeInstanceOf(ChatError);
|
|
136
|
+
expect(close).toHaveBeenCalledTimes(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("reads a file URI through readFile", async () => {
|
|
140
|
+
const s = server();
|
|
141
|
+
const readFile = vi.fn(async () => new Uint8Array(5).buffer);
|
|
142
|
+
const upload = createTusUpload({ supabaseUrl: URL_, userId: () => USER, getAccessToken: async () => "jwt", fetch: s.fetch, readFile, newId: () => "id4" });
|
|
143
|
+
await upload(CONV, { data: "file:///voice.m4a", name: "voice.m4a", contentType: "audio/mp4" });
|
|
144
|
+
expect(readFile).toHaveBeenCalledWith("file:///voice.m4a");
|
|
145
|
+
expect(s.received()).toBe(5);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("base64Utf8", () => {
|
|
150
|
+
it("encodes UTF-8", () => {
|
|
151
|
+
expect(base64Utf8("genesis-chat")).toBe(Buffer.from("genesis-chat").toString("base64"));
|
|
152
|
+
expect(base64Utf8("é/ü")).toBe(Buffer.from("é/ü").toString("base64"));
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// @feat:chat
|
|
3
|
+
import { act, renderHook } from "@testing-library/react";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { LocalFile } from "../../core/outbox";
|
|
6
|
+
import type { ExpoAudioLike, RecorderStateLike } from "../modules";
|
|
7
|
+
import { RECORDER_START_READS, RECORDER_STALL_READS, useVoiceRecorder } from "../use-voice-recorder";
|
|
8
|
+
|
|
9
|
+
// AppState, which the shared React Native shim leaves out: the test moves the app to the background.
|
|
10
|
+
const appState = vi.hoisted(() => {
|
|
11
|
+
const listeners = new Set<(s: string) => void>();
|
|
12
|
+
return { listeners, emit: (s: string) => listeners.forEach((l) => l(s)) };
|
|
13
|
+
});
|
|
14
|
+
vi.mock("react-native", async (original) => ({
|
|
15
|
+
...(await original<object>()),
|
|
16
|
+
AppState: {
|
|
17
|
+
addEventListener: (_: string, l: (s: string) => void) => {
|
|
18
|
+
appState.listeners.add(l);
|
|
19
|
+
return { remove: () => appState.listeners.delete(l) };
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
/** expo-audio, mocked: a recorder whose status the test moves forward. */
|
|
25
|
+
function fakeAudio(opts: { granted?: boolean } = {}) {
|
|
26
|
+
const status: RecorderStateLike = { isRecording: false, durationMillis: 0, metering: -160, url: null };
|
|
27
|
+
const recorder = {
|
|
28
|
+
uri: null as string | null,
|
|
29
|
+
prepareToRecordAsync: vi.fn(async () => undefined),
|
|
30
|
+
record: vi.fn(() => {
|
|
31
|
+
status.isRecording = true;
|
|
32
|
+
}),
|
|
33
|
+
stop: vi.fn(async () => {
|
|
34
|
+
status.isRecording = false;
|
|
35
|
+
recorder.uri = "file:///rec/voice.m4a";
|
|
36
|
+
}),
|
|
37
|
+
getStatus: vi.fn(() => ({ ...status })),
|
|
38
|
+
};
|
|
39
|
+
const audio: ExpoAudioLike = {
|
|
40
|
+
useAudioRecorder: vi.fn(() => recorder),
|
|
41
|
+
useAudioPlayer: vi.fn(),
|
|
42
|
+
useAudioPlayerStatus: vi.fn(),
|
|
43
|
+
RecordingPresets: { HIGH_QUALITY: { extension: ".m4a" } },
|
|
44
|
+
requestRecordingPermissionsAsync: vi.fn(async () => ({ granted: opts.granted ?? true })),
|
|
45
|
+
setAudioModeAsync: vi.fn(async () => undefined),
|
|
46
|
+
} as unknown as ExpoAudioLike;
|
|
47
|
+
return { audio, recorder, status };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("useVoiceRecorder (expo-audio mocked)", () => {
|
|
51
|
+
beforeEach(() => vi.useFakeTimers());
|
|
52
|
+
afterEach(() => vi.useRealTimers());
|
|
53
|
+
|
|
54
|
+
async function start(audio: ExpoAudioLike, onRecorded: (f: LocalFile) => void, signalRecording = vi.fn()) {
|
|
55
|
+
const hook = renderHook(() => useVoiceRecorder(audio, { limitMs: 60_000, onRecorded, signalRecording }));
|
|
56
|
+
await act(async () => {
|
|
57
|
+
hook.result.current.dispatch({ type: "press" });
|
|
58
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
59
|
+
});
|
|
60
|
+
return hook;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
it("records with metering on, reads the level as it goes, and sends a voice note with its duration and waveform", async () => {
|
|
64
|
+
const { audio, recorder, status } = fakeAudio();
|
|
65
|
+
const onRecorded = vi.fn();
|
|
66
|
+
const signalRecording = vi.fn();
|
|
67
|
+
const hook = await start(audio, onRecorded, signalRecording);
|
|
68
|
+
expect(audio.useAudioRecorder).toHaveBeenCalledWith({ extension: ".m4a", isMeteringEnabled: true });
|
|
69
|
+
expect(audio.setAudioModeAsync).toHaveBeenCalledWith({ allowsRecording: true, playsInSilentMode: true });
|
|
70
|
+
expect(recorder.prepareToRecordAsync).toHaveBeenCalled();
|
|
71
|
+
expect(recorder.record).toHaveBeenCalled();
|
|
72
|
+
expect(hook.result.current.state.phase).toBe("recording");
|
|
73
|
+
|
|
74
|
+
for (const [ms, db] of [[100, -40], [200, -10], [300, -25], [1200, -5]] as const) {
|
|
75
|
+
status.durationMillis = ms;
|
|
76
|
+
status.metering = db;
|
|
77
|
+
await act(async () => void (await vi.advanceTimersByTimeAsync(100)));
|
|
78
|
+
}
|
|
79
|
+
expect(hook.result.current.state.durationMs).toBe(1200);
|
|
80
|
+
expect(signalRecording).toHaveBeenCalled();
|
|
81
|
+
|
|
82
|
+
await act(async () => {
|
|
83
|
+
hook.result.current.dispatch({ type: "release" });
|
|
84
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
85
|
+
});
|
|
86
|
+
expect(recorder.stop).toHaveBeenCalled();
|
|
87
|
+
expect(onRecorded).toHaveBeenCalledTimes(1);
|
|
88
|
+
const file = onRecorded.mock.calls[0]![0] as LocalFile;
|
|
89
|
+
expect(file).toMatchObject({ data: "file:///rec/voice.m4a", contentType: "audio/mp4", meta: { kind: "voice", durationMs: 1200 } });
|
|
90
|
+
expect(file.meta!.waveform).toHaveLength(48);
|
|
91
|
+
expect(Math.max(...file.meta!.waveform!)).toBe(100);
|
|
92
|
+
expect(audio.setAudioModeAsync).toHaveBeenLastCalledWith({ allowsRecording: false });
|
|
93
|
+
expect(hook.result.current.state.phase).toBe("idle");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("slide to cancel stops and throws the recording away", async () => {
|
|
97
|
+
const { audio, recorder, status } = fakeAudio();
|
|
98
|
+
const onRecorded = vi.fn();
|
|
99
|
+
const hook = await start(audio, onRecorded);
|
|
100
|
+
status.durationMillis = 2000;
|
|
101
|
+
await act(async () => void (await vi.advanceTimersByTimeAsync(100)));
|
|
102
|
+
act(() => hook.result.current.dispatch({ type: "move", dx: -200, dy: 0 }));
|
|
103
|
+
await act(async () => void (await vi.advanceTimersByTimeAsync(0)));
|
|
104
|
+
expect(recorder.stop).toHaveBeenCalled();
|
|
105
|
+
expect(onRecorded).not.toHaveBeenCalled();
|
|
106
|
+
expect(hook.result.current.state.phase).toBe("idle");
|
|
107
|
+
// The level timer stopped with it.
|
|
108
|
+
const reads = recorder.getStatus.mock.calls.length;
|
|
109
|
+
await act(async () => void (await vi.advanceTimersByTimeAsync(500)));
|
|
110
|
+
expect(recorder.getStatus.mock.calls.length).toBe(reads);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("locked, it keeps recording after letting go, until Send", async () => {
|
|
114
|
+
const { audio, status } = fakeAudio();
|
|
115
|
+
const onRecorded = vi.fn();
|
|
116
|
+
const hook = await start(audio, onRecorded);
|
|
117
|
+
act(() => hook.result.current.dispatch({ type: "move", dx: 0, dy: -100 }));
|
|
118
|
+
act(() => hook.result.current.dispatch({ type: "release" }));
|
|
119
|
+
expect(hook.result.current.state).toMatchObject({ phase: "recording", locked: true });
|
|
120
|
+
status.durationMillis = 3000;
|
|
121
|
+
await act(async () => void (await vi.advanceTimersByTimeAsync(100)));
|
|
122
|
+
await act(async () => {
|
|
123
|
+
hook.result.current.dispatch({ type: "send" });
|
|
124
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
125
|
+
});
|
|
126
|
+
expect(onRecorded).toHaveBeenCalledTimes(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("without microphone permission it never records, and says so", async () => {
|
|
130
|
+
const { audio, recorder } = fakeAudio({ granted: false });
|
|
131
|
+
const hook = await start(audio, vi.fn());
|
|
132
|
+
expect(recorder.record).not.toHaveBeenCalled();
|
|
133
|
+
expect(hook.result.current.state).toMatchObject({ phase: "idle", notice: "denied" });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("leaving the screen mid-recording stops the recorder", async () => {
|
|
137
|
+
const { audio, recorder } = fakeAudio();
|
|
138
|
+
const hook = await start(audio, vi.fn());
|
|
139
|
+
hook.unmount();
|
|
140
|
+
expect(recorder.stop).toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("a press cancelled while the microphone starts never records, and lets the microphone go", async () => {
|
|
144
|
+
const { audio, recorder } = fakeAudio();
|
|
145
|
+
let grant!: (v: { granted: boolean }) => void;
|
|
146
|
+
(audio.requestRecordingPermissionsAsync as ReturnType<typeof vi.fn>).mockImplementationOnce(() => new Promise((r) => (grant = r)));
|
|
147
|
+
const hook = renderHook(() => useVoiceRecorder(audio, { limitMs: 60_000, onRecorded: vi.fn() }));
|
|
148
|
+
act(() => hook.result.current.dispatch({ type: "press" }));
|
|
149
|
+
act(() => hook.result.current.dispatch({ type: "cancel" }));
|
|
150
|
+
await act(async () => {
|
|
151
|
+
grant({ granted: true });
|
|
152
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
153
|
+
});
|
|
154
|
+
expect(recorder.prepareToRecordAsync).not.toHaveBeenCalled();
|
|
155
|
+
expect(recorder.record).not.toHaveBeenCalled();
|
|
156
|
+
expect(audio.setAudioModeAsync).not.toHaveBeenCalledWith({ allowsRecording: true, playsInSilentMode: true });
|
|
157
|
+
expect(hook.result.current.state.phase).toBe("idle");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("a second press after a cancelled one isn't started by the first one's late answer", async () => {
|
|
161
|
+
const { audio, recorder } = fakeAudio();
|
|
162
|
+
const grants: ((v: { granted: boolean }) => void)[] = [];
|
|
163
|
+
(audio.requestRecordingPermissionsAsync as ReturnType<typeof vi.fn>).mockImplementation(() => new Promise((r) => grants.push(r)));
|
|
164
|
+
const hook = renderHook(() => useVoiceRecorder(audio, { limitMs: 60_000, onRecorded: vi.fn() }));
|
|
165
|
+
act(() => hook.result.current.dispatch({ type: "press" }));
|
|
166
|
+
act(() => hook.result.current.dispatch({ type: "cancel" }));
|
|
167
|
+
act(() => hook.result.current.dispatch({ type: "press" }));
|
|
168
|
+
await act(async () => {
|
|
169
|
+
grants[0]!({ granted: true });
|
|
170
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
171
|
+
});
|
|
172
|
+
expect(recorder.record).not.toHaveBeenCalled();
|
|
173
|
+
expect(hook.result.current.state.phase).toBe("starting");
|
|
174
|
+
await act(async () => {
|
|
175
|
+
grants[1]!({ granted: true });
|
|
176
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
177
|
+
});
|
|
178
|
+
expect(recorder.record).toHaveBeenCalledTimes(1);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("useVoiceRecorder when something goes wrong", () => {
|
|
183
|
+
beforeEach(() => vi.useFakeTimers());
|
|
184
|
+
afterEach(() => vi.useRealTimers());
|
|
185
|
+
|
|
186
|
+
async function recordFor(audio: ExpoAudioLike, status: RecorderStateLike, onRecorded: (f: LocalFile) => void, ms = 1500) {
|
|
187
|
+
const hook = renderHook(() => useVoiceRecorder(audio, { limitMs: 60_000, onRecorded }));
|
|
188
|
+
await act(async () => {
|
|
189
|
+
hook.result.current.dispatch({ type: "press" });
|
|
190
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
191
|
+
});
|
|
192
|
+
status.durationMillis = ms;
|
|
193
|
+
await act(async () => void (await vi.advanceTimersByTimeAsync(100)));
|
|
194
|
+
return hook;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
it("a stop that throws sends nothing and says the voice message wasn't saved", async () => {
|
|
198
|
+
const { audio, recorder, status } = fakeAudio();
|
|
199
|
+
recorder.stop.mockImplementationOnce(async () => {
|
|
200
|
+
throw new Error("stop failed");
|
|
201
|
+
});
|
|
202
|
+
const onRecorded = vi.fn();
|
|
203
|
+
const hook = await recordFor(audio, status, onRecorded);
|
|
204
|
+
await act(async () => {
|
|
205
|
+
hook.result.current.dispatch({ type: "release" });
|
|
206
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
207
|
+
});
|
|
208
|
+
expect(onRecorded).not.toHaveBeenCalled();
|
|
209
|
+
expect(hook.result.current.state).toMatchObject({ phase: "idle", notice: "error" });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("a stop that leaves no file says so too", async () => {
|
|
213
|
+
const { audio, recorder, status } = fakeAudio();
|
|
214
|
+
recorder.stop.mockImplementationOnce(async () => {
|
|
215
|
+
status.isRecording = false;
|
|
216
|
+
});
|
|
217
|
+
const onRecorded = vi.fn();
|
|
218
|
+
const hook = await recordFor(audio, status, onRecorded);
|
|
219
|
+
await act(async () => {
|
|
220
|
+
hook.result.current.dispatch({ type: "release" });
|
|
221
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
222
|
+
});
|
|
223
|
+
expect(onRecorded).not.toHaveBeenCalled();
|
|
224
|
+
expect(hook.result.current.state.notice).toBe("error");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("the recorder stopping by itself (a phone call) ends the attempt with a notice instead of freezing the timer", async () => {
|
|
228
|
+
const { audio, recorder, status } = fakeAudio();
|
|
229
|
+
const onRecorded = vi.fn();
|
|
230
|
+
const hook = await recordFor(audio, status, onRecorded);
|
|
231
|
+
status.isRecording = false;
|
|
232
|
+
await act(async () => void (await vi.advanceTimersByTimeAsync(100 * RECORDER_STALL_READS)));
|
|
233
|
+
expect(hook.result.current.state).toMatchObject({ phase: "idle", notice: "interrupted" });
|
|
234
|
+
expect(onRecorded).not.toHaveBeenCalled();
|
|
235
|
+
expect(recorder.stop).toHaveBeenCalled();
|
|
236
|
+
const reads = recorder.getStatus.mock.calls.length;
|
|
237
|
+
await act(async () => void (await vi.advanceTimersByTimeAsync(500)));
|
|
238
|
+
expect(recorder.getStatus.mock.calls.length).toBe(reads);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("a status that stops reading ends the attempt too", async () => {
|
|
242
|
+
const { audio, recorder, status } = fakeAudio();
|
|
243
|
+
const hook = await recordFor(audio, status, vi.fn());
|
|
244
|
+
recorder.getStatus.mockImplementation(() => {
|
|
245
|
+
throw new Error("gone");
|
|
246
|
+
});
|
|
247
|
+
await act(async () => void (await vi.advanceTimersByTimeAsync(100 * RECORDER_STALL_READS)));
|
|
248
|
+
expect(hook.result.current.state).toMatchObject({ phase: "idle", notice: "interrupted" });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("the app going to the background ends the recording and lets the microphone go", async () => {
|
|
252
|
+
const { audio, recorder, status } = fakeAudio();
|
|
253
|
+
const hook = await recordFor(audio, status, vi.fn());
|
|
254
|
+
act(() => appState.emit("background"));
|
|
255
|
+
await act(async () => void (await vi.advanceTimersByTimeAsync(0)));
|
|
256
|
+
expect(hook.result.current.state).toMatchObject({ phase: "idle", notice: "interrupted" });
|
|
257
|
+
expect(recorder.stop).toHaveBeenCalled();
|
|
258
|
+
expect(audio.setAudioModeAsync).toHaveBeenLastCalledWith({ allowsRecording: false });
|
|
259
|
+
const before = appState.listeners.size;
|
|
260
|
+
hook.unmount();
|
|
261
|
+
expect(appState.listeners.size).toBe(before - 1);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("a record() that never starts ends the attempt with an error notice, not a frozen 0:00", async () => {
|
|
265
|
+
const { audio, recorder } = fakeAudio();
|
|
266
|
+
recorder.record.mockImplementation(() => undefined);
|
|
267
|
+
const hook = renderHook(() => useVoiceRecorder(audio, { limitMs: 60_000, onRecorded: vi.fn() }));
|
|
268
|
+
await act(async () => {
|
|
269
|
+
hook.result.current.dispatch({ type: "press" });
|
|
270
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
271
|
+
});
|
|
272
|
+
expect(hook.result.current.state.phase).toBe("recording");
|
|
273
|
+
await act(async () => void (await vi.advanceTimersByTimeAsync(100 * RECORDER_START_READS)));
|
|
274
|
+
expect(hook.result.current.state).toMatchObject({ phase: "idle", notice: "error" });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("the background while the microphone is still starting (Android's permission dialog) doesn't end the attempt", async () => {
|
|
278
|
+
const { audio, recorder } = fakeAudio();
|
|
279
|
+
let grant!: (v: { granted: boolean }) => void;
|
|
280
|
+
(audio.requestRecordingPermissionsAsync as ReturnType<typeof vi.fn>).mockImplementationOnce(() => new Promise((r) => (grant = r)));
|
|
281
|
+
const hook = renderHook(() => useVoiceRecorder(audio, { limitMs: 60_000, onRecorded: vi.fn() }));
|
|
282
|
+
act(() => hook.result.current.dispatch({ type: "press" }));
|
|
283
|
+
act(() => appState.emit("background"));
|
|
284
|
+
await act(async () => {
|
|
285
|
+
grant({ granted: true });
|
|
286
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
287
|
+
});
|
|
288
|
+
expect(recorder.record).toHaveBeenCalled();
|
|
289
|
+
expect(hook.result.current.state).toMatchObject({ phase: "recording", notice: null });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("leaving the screen while the stop is under way lets it finish, stops once, and still sends the note", async () => {
|
|
293
|
+
const { audio, recorder, status } = fakeAudio();
|
|
294
|
+
let finishStop!: () => void;
|
|
295
|
+
recorder.stop.mockImplementationOnce(
|
|
296
|
+
() =>
|
|
297
|
+
new Promise<void>((resolve) => {
|
|
298
|
+
finishStop = () => {
|
|
299
|
+
recorder.uri = "file:///rec/voice.m4a";
|
|
300
|
+
resolve();
|
|
301
|
+
};
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
const onRecorded = vi.fn();
|
|
305
|
+
const hook = await recordFor(audio, status, onRecorded);
|
|
306
|
+
act(() => hook.result.current.dispatch({ type: "release" }));
|
|
307
|
+
hook.unmount();
|
|
308
|
+
await act(async () => {
|
|
309
|
+
finishStop();
|
|
310
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
311
|
+
});
|
|
312
|
+
expect(recorder.stop).toHaveBeenCalledTimes(1);
|
|
313
|
+
expect(onRecorded).toHaveBeenCalledTimes(1);
|
|
314
|
+
expect(audio.setAudioModeAsync).toHaveBeenLastCalledWith({ allowsRecording: false });
|
|
315
|
+
});
|
|
316
|
+
});
|