@rebasepro/plugin-ai 0.12.1-canary.gf5f1d39 → 0.13.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.
Files changed (33) hide show
  1. package/README.md +62 -18
  2. package/dist/api.d.ts +58 -30
  3. package/dist/components/AutofillReviewDialog.d.ts +16 -0
  4. package/dist/components/DataEnhancementControllerProvider.d.ts +2 -3
  5. package/dist/components/FormEnhanceAction.d.ts +1 -1
  6. package/dist/editor/useEditorAIController.d.ts +11 -2
  7. package/dist/index.es.js +601 -382
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/types/data_enhancement_controller.d.ts +84 -28
  10. package/dist/useDataEnhancementPlugin.d.ts +10 -5
  11. package/package.json +24 -19
  12. package/src/api.ts +241 -174
  13. package/src/components/AutofillReviewDialog.tsx +209 -0
  14. package/src/components/DataEnhancementControllerProvider.tsx +203 -262
  15. package/src/components/FormEnhanceAction.tsx +154 -128
  16. package/src/editor/useEditorAIController.tsx +20 -33
  17. package/src/tests/AutofillReviewDialog.test.tsx +340 -0
  18. package/src/tests/api.test.ts +283 -0
  19. package/src/tests/properties.test.ts +420 -0
  20. package/src/tests/review.test.tsx +393 -0
  21. package/src/tests/useDataEnhancementPlugin.test.tsx +36 -15
  22. package/src/tests/useEditorAIController.test.ts +98 -0
  23. package/src/types/data_enhancement_controller.tsx +98 -31
  24. package/src/useDataEnhancementPlugin.tsx +13 -12
  25. package/dist/utils/diffStrings.d.ts +0 -7
  26. package/dist/utils/strings_counter.d.ts +0 -2
  27. package/dist/utils/suggestions.d.ts +0 -1
  28. package/src/tests/diffStrings.test.ts +0 -128
  29. package/src/tests/strings_counter.test.ts +0 -117
  30. package/src/tests/suggestions.test.ts +0 -53
  31. package/src/utils/diffStrings.ts +0 -70
  32. package/src/utils/strings_counter.ts +0 -22
  33. package/src/utils/suggestions.ts +0 -6
@@ -0,0 +1,393 @@
1
+ import { TextEncoder, TextDecoder } from "util";
2
+ Object.assign(global, { TextEncoder,
3
+ TextDecoder });
4
+
5
+ import React from "react";
6
+ import { act, renderHook, waitFor } from "@testing-library/react";
7
+
8
+ import {
9
+ DataEnhancementControllerProvider,
10
+ useDataEnhancementController
11
+ } from "../components/DataEnhancementControllerProvider";
12
+
13
+ /**
14
+ * The propose → review → apply contract.
15
+ *
16
+ * The design this replaced streamed generated text straight into the live form
17
+ * fields. Its failure modes were all the same failure: the record changed
18
+ * before anyone agreed to it. Half-written sentences appeared mid-generation,
19
+ * heuristics guessed whether each token should append to or replace what the
20
+ * operator had typed, and getting the old value back meant retyping it.
21
+ *
22
+ * So the load-bearing assertion in this file is a negative one — `setFieldValue`
23
+ * is not called — and it is checked during streaming, after streaming, after a
24
+ * failure, and after a discard. Everything else here is detail; that is the
25
+ * promise.
26
+ */
27
+
28
+ jest.mock("@rebasepro/admin", () => ({
29
+ getFieldId: (property: { type?: string }) => (property?.type === "string" ? "text_field" : "number_field")
30
+ }));
31
+
32
+ const COLLECTION = {
33
+ name: "Products",
34
+ singularName: "Product",
35
+ properties: {
36
+ title: { type: "string",
37
+ name: "Title" },
38
+ subtitle: { type: "string",
39
+ name: "Subtitle" },
40
+ stock: { type: "number",
41
+ name: "Stock" }
42
+ }
43
+ } as any;
44
+
45
+ /** A `Response` whose body yields the given chunks, in order. */
46
+ function streamingResponse(chunks: string[]): any {
47
+ const encoder = new TextEncoder();
48
+ let i = 0;
49
+ return {
50
+ ok: true,
51
+ status: 200,
52
+ body: {
53
+ getReader: () => ({
54
+ read: async () =>
55
+ i < chunks.length
56
+ ? { done: false,
57
+ value: encoder.encode(chunks[i++]) }
58
+ : { done: true,
59
+ value: undefined }
60
+ })
61
+ }
62
+ };
63
+ }
64
+
65
+ const AUTOFILL_BODY = [
66
+ 'event: suggestion_delta\ndata: {"key":"title","text":"Blue "}',
67
+ 'event: suggestion_delta\ndata: {"key":"title","text":"widget"}',
68
+ 'event: suggestion\ndata: {"key":"title","value":"Blue widget"}',
69
+ 'event: suggestion\ndata: {"key":"stock","value":42}',
70
+ 'event: done\ndata: {"suggestions":{"title":"Blue widget","stock":42}}',
71
+ ""
72
+ ].join("\n\n");
73
+
74
+ /** Route `/status` to an availability answer and `/autofill` to a stream. */
75
+ function mockService(autofill: () => any, available = true) {
76
+ (global as any).fetch = jest.fn((url: string) => {
77
+ if (String(url).endsWith("/status")) {
78
+ return Promise.resolve({ ok: true,
79
+ status: 200,
80
+ json: async () => ({ available,
81
+ model: "gemini-3.6-flash" }) });
82
+ }
83
+ if (String(url).endsWith("/autofill")) return Promise.resolve(autofill());
84
+ return Promise.resolve({ ok: true,
85
+ status: 200,
86
+ json: async () => ({ prompts: [] }) });
87
+ });
88
+ }
89
+
90
+ async function mountController(formValues: Record<string, unknown> = {}) {
91
+ const setFieldValue = jest.fn();
92
+ const formContext = { values: formValues,
93
+ setFieldValue } as any;
94
+
95
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
96
+ <DataEnhancementControllerProvider
97
+ path={"products"}
98
+ collection={COLLECTION}
99
+ formContext={formContext}
100
+ {...({} as any)}>
101
+ {children}
102
+ </DataEnhancementControllerProvider>
103
+ );
104
+
105
+ const rendered = renderHook(() => useDataEnhancementController(), { wrapper });
106
+ return { ...rendered,
107
+ setFieldValue };
108
+ }
109
+
110
+ afterEach(() => {
111
+ jest.restoreAllMocks();
112
+ });
113
+
114
+ describe("autofill review", () => {
115
+
116
+ it("never writes to the form while generating or after it finishes", async () => {
117
+ mockService(() => streamingResponse([AUTOFILL_BODY]));
118
+ const { result, setFieldValue } = await mountController({ title: "" });
119
+ await waitFor(() => expect(result.current.enabled).toBe(true));
120
+
121
+ await act(async () => {
122
+ await result.current.generate({ values: { title: "" } });
123
+ });
124
+
125
+ expect(result.current.review?.status).toBe("ready");
126
+ expect(result.current.review?.fields.length).toBe(2);
127
+ expect(setFieldValue).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it("accumulates streamed text into the proposal, not the field", async () => {
131
+ mockService(() => streamingResponse([AUTOFILL_BODY]));
132
+ const { result, setFieldValue } = await mountController();
133
+ await waitFor(() => expect(result.current.enabled).toBe(true));
134
+
135
+ await act(async () => {
136
+ await result.current.generate({ values: {} });
137
+ });
138
+
139
+ const title = result.current.review?.fields.find(f => f.key === "title");
140
+ expect(title?.proposed).toBe("Blue widget");
141
+ expect(title?.pending).toBe(false);
142
+ expect(title?.label).toBe("Title");
143
+ expect(setFieldValue).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it("writes only the selected fields, and only on apply", async () => {
147
+ mockService(() => streamingResponse([AUTOFILL_BODY]));
148
+ const { result, setFieldValue } = await mountController();
149
+ await waitFor(() => expect(result.current.enabled).toBe(true));
150
+
151
+ await act(async () => {
152
+ await result.current.generate({ values: {} });
153
+ });
154
+
155
+ act(() => result.current.toggleField("stock"));
156
+ expect(setFieldValue).not.toHaveBeenCalled();
157
+
158
+ act(() => result.current.applyReview());
159
+
160
+ expect(setFieldValue).toHaveBeenCalledTimes(1);
161
+ expect(setFieldValue).toHaveBeenCalledWith("title", "Blue widget");
162
+ // The review closes on apply — there is nothing left to decide.
163
+ expect(result.current.review).toBeNull();
164
+ });
165
+
166
+ it("writes nothing when the review is discarded", async () => {
167
+ mockService(() => streamingResponse([AUTOFILL_BODY]));
168
+ const { result, setFieldValue } = await mountController({ title: "Existing" });
169
+ await waitFor(() => expect(result.current.enabled).toBe(true));
170
+
171
+ await act(async () => {
172
+ await result.current.generate({ values: { title: "Existing" } });
173
+ });
174
+ act(() => result.current.dismissReview());
175
+
176
+ expect(setFieldValue).not.toHaveBeenCalled();
177
+ expect(result.current.review).toBeNull();
178
+ });
179
+
180
+ it("records what each proposal would overwrite", async () => {
181
+ // The row shows the current value struck through, which is only possible
182
+ // because the form was never touched — it still holds the original.
183
+ mockService(() => streamingResponse([AUTOFILL_BODY]));
184
+ const { result } = await mountController({ title: "Old title" });
185
+ await waitFor(() => expect(result.current.enabled).toBe(true));
186
+
187
+ await act(async () => {
188
+ await result.current.generate({ values: { title: "Old title" } });
189
+ });
190
+
191
+ const title = result.current.review?.fields.find(f => f.key === "title");
192
+ expect(title?.currentValue).toBe("Old title");
193
+ expect(title?.proposed).toBe("Blue widget");
194
+ });
195
+
196
+ it("toggles every field at once", async () => {
197
+ mockService(() => streamingResponse([AUTOFILL_BODY]));
198
+ const { result, setFieldValue } = await mountController();
199
+ await waitFor(() => expect(result.current.enabled).toBe(true));
200
+
201
+ await act(async () => {
202
+ await result.current.generate({ values: {} });
203
+ });
204
+
205
+ act(() => result.current.toggleAll(false));
206
+ act(() => result.current.applyReview());
207
+ expect(setFieldValue).not.toHaveBeenCalled();
208
+ });
209
+
210
+ it("keeps the fields that arrived before a mid-stream failure", async () => {
211
+ // A run that produced two good fields and then broke should still let
212
+ // the operator take the two, rather than discarding the work.
213
+ const truncated = [
214
+ 'event: suggestion\ndata: {"key":"title","value":"Blue widget"}',
215
+ 'event: error\ndata: {"message":"quota exhausted"}',
216
+ ""
217
+ ].join("\n\n");
218
+ mockService(() => streamingResponse([truncated]));
219
+ const { result, setFieldValue } = await mountController();
220
+ await waitFor(() => expect(result.current.enabled).toBe(true));
221
+
222
+ await act(async () => {
223
+ await result.current.generate({ values: {} });
224
+ });
225
+
226
+ expect(result.current.review?.status).toBe("failed");
227
+ expect(result.current.review?.error).toMatch(/quota exhausted/);
228
+ expect(result.current.review?.fields.map(f => f.key)).toEqual(["title"]);
229
+ expect(setFieldValue).not.toHaveBeenCalled();
230
+
231
+ act(() => result.current.applyReview());
232
+ expect(setFieldValue).toHaveBeenCalledWith("title", "Blue widget");
233
+ });
234
+
235
+ it("sends values flattened onto the same dotted paths as the properties", async () => {
236
+ // The service is told about `seo.title`, so it has to be told the value
237
+ // of `seo.title` — not of `seo`. Sending the nested object instead makes
238
+ // every existing value invisible to the model, which shows up as
239
+ // autofill cheerfully overwriting things the operator already wrote.
240
+ mockService(() => streamingResponse([AUTOFILL_BODY]));
241
+ const { result } = await mountController();
242
+ await waitFor(() => expect(result.current.enabled).toBe(true));
243
+
244
+ await act(async () => {
245
+ await result.current.generate({ values: { seo: { title: "Nested" } } });
246
+ });
247
+
248
+ const call = ((global as any).fetch as jest.Mock).mock.calls
249
+ .find(([url]: [string]) => String(url).endsWith("/autofill"));
250
+ expect(JSON.parse(call[1].body).values).toEqual({ "seo.title": "Nested" });
251
+ });
252
+
253
+ it("stays disabled when the service reports unavailable", async () => {
254
+ mockService(() => streamingResponse([AUTOFILL_BODY]), false);
255
+ const { result } = await mountController();
256
+ await waitFor(() => expect((global as any).fetch).toHaveBeenCalled());
257
+ expect(result.current.enabled).toBe(false);
258
+ expect(result.current.review).toBeNull();
259
+ });
260
+ });
261
+
262
+ describe("autofill review — runs that overlap or are abandoned", () => {
263
+
264
+ it("does not write into a review the operator already dismissed", async () => {
265
+ // Closing the dialog mid-generation must not leave late frames landing
266
+ // in a review that is no longer on screen.
267
+ mockService(() => streamingResponse([AUTOFILL_BODY]));
268
+ const { result, setFieldValue } = await mountController();
269
+ await waitFor(() => expect(result.current.enabled).toBe(true));
270
+
271
+ let pending: Promise<void>;
272
+ act(() => {
273
+ pending = result.current.generate({ values: {} });
274
+ });
275
+ act(() => result.current.dismissReview());
276
+ await act(async () => {
277
+ await pending!;
278
+ });
279
+
280
+ expect(result.current.review).toBeNull();
281
+ expect(setFieldValue).not.toHaveBeenCalled();
282
+ });
283
+
284
+ it("does not let an earlier run's fields leak into a later one", async () => {
285
+ // Two clicks in quick succession. The first run's frames must not
286
+ // appear in the second run's review — the operator would be reviewing
287
+ // proposals for an instruction they replaced.
288
+ const first = [
289
+ 'event: suggestion\ndata: {"key":"title","value":"FIRST RUN"}',
290
+ 'event: done\ndata: {"suggestions":{"title":"FIRST RUN"}}',
291
+ ""
292
+ ].join("\n\n");
293
+ const second = [
294
+ 'event: suggestion\ndata: {"key":"stock","value":7}',
295
+ 'event: done\ndata: {"suggestions":{"stock":7}}',
296
+ ""
297
+ ].join("\n\n");
298
+
299
+ let call = 0;
300
+ (global as any).fetch = jest.fn((url: string) => {
301
+ if (String(url).endsWith("/status")) {
302
+ return Promise.resolve({ ok: true,
303
+ status: 200,
304
+ json: async () => ({ available: true }) });
305
+ }
306
+ if (String(url).endsWith("/autofill")) {
307
+ return Promise.resolve(streamingResponse([call++ === 0 ? first : second]));
308
+ }
309
+ return Promise.resolve({ ok: true,
310
+ status: 200,
311
+ json: async () => ({ prompts: [] }) });
312
+ });
313
+
314
+ const { result } = await mountController();
315
+ await waitFor(() => expect(result.current.enabled).toBe(true));
316
+
317
+ await act(async () => {
318
+ await result.current.generate({ values: {} });
319
+ });
320
+ await act(async () => {
321
+ await result.current.generate({ values: {} });
322
+ });
323
+
324
+ const keys = result.current.review?.fields.map(f => f.key);
325
+ expect(keys).toEqual(["stock"]);
326
+ expect(JSON.stringify(result.current.review)).not.toMatch(/FIRST RUN/);
327
+ });
328
+
329
+ it("applies only the fields that finished, if applied mid-stream", async () => {
330
+ // The Apply button counts non-pending rows; applying must honour the
331
+ // same rule, or a half-written sentence lands in the record.
332
+ mockService(() => streamingResponse([
333
+ 'event: suggestion_delta\ndata: {"key":"title","text":"half a sen"}\n\n' +
334
+ 'event: suggestion\ndata: {"key":"stock","value":9}\n\n'
335
+ ]));
336
+ const { result, setFieldValue } = await mountController();
337
+ await waitFor(() => expect(result.current.enabled).toBe(true));
338
+
339
+ await act(async () => {
340
+ await result.current.generate({ values: {} });
341
+ });
342
+
343
+ // The stream ended without completing `title`, so it is dropped rather
344
+ // than offered — the only thing held for it is a half-written sentence.
345
+ expect(result.current.review?.fields.map(f => f.key)).toEqual(["stock"]);
346
+
347
+ act(() => result.current.applyReview());
348
+
349
+ expect(setFieldValue).toHaveBeenCalledTimes(1);
350
+ expect(setFieldValue).toHaveBeenCalledWith("stock", 9);
351
+ });
352
+
353
+ it("drops a date the model returned as unparseable rather than storing NaN", async () => {
354
+ const collection = {
355
+ name: "Posts",
356
+ singularName: "Post",
357
+ properties: { publishedAt: { type: "date",
358
+ name: "Published at" } }
359
+ } as any;
360
+
361
+ (global as any).fetch = jest.fn((url: string) => {
362
+ if (String(url).endsWith("/status")) {
363
+ return Promise.resolve({ ok: true,
364
+ status: 200,
365
+ json: async () => ({ available: true }) });
366
+ }
367
+ return Promise.resolve(streamingResponse([
368
+ 'event: suggestion\ndata: {"key":"publishedAt","value":"not a date"}\n\nevent: done\ndata: {"suggestions":{}}\n\n'
369
+ ]));
370
+ });
371
+
372
+ const setFieldValue = jest.fn();
373
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
374
+ <DataEnhancementControllerProvider
375
+ path={"posts"}
376
+ collection={collection}
377
+ formContext={{ values: {},
378
+ setFieldValue } as any}
379
+ {...({} as any)}>
380
+ {children}
381
+ </DataEnhancementControllerProvider>
382
+ );
383
+ const { result } = renderHook(() => useDataEnhancementController(), { wrapper });
384
+ await waitFor(() => expect(result.current.enabled).toBe(true));
385
+
386
+ await act(async () => {
387
+ await result.current.generate({ values: {} });
388
+ });
389
+ act(() => result.current.applyReview());
390
+
391
+ expect(setFieldValue).not.toHaveBeenCalled();
392
+ });
393
+ });
@@ -19,6 +19,7 @@ if (typeof window !== "undefined") {
19
19
 
20
20
  import { renderHook } from "@testing-library/react";
21
21
  import { useDataEnhancementPlugin } from "../useDataEnhancementPlugin";
22
+ import { DEFAULT_AI_ENDPOINT } from "../api";
22
23
 
23
24
  jest.mock("@rebasepro/admin", () => ({
24
25
  useUrlController: () => ({})
@@ -29,23 +30,43 @@ describe("useDataEnhancementPlugin hook", () => {
29
30
  const { result } = renderHook(() => useDataEnhancementPlugin());
30
31
  const plugin = result.current;
31
32
 
32
- expect(plugin.key).toBe("data_enhancement");
33
- expect(plugin.slots).toBeDefined();
34
- expect(plugin.slots[0].slot).toBe("form.actions");
35
- expect(plugin.providers).toBeDefined();
36
- expect(plugin.providers[0].scope).toBe("form");
37
- expect(plugin.providers[0].props.apiKey).toBe("fcms-U9jdDii0xXWSDC34asfrf54lbkFJBfKfRWcEDEwdc4V5wDWEDF");
33
+ // Matched as one shape rather than indexed field by field. `slots` and
34
+ // `providers` are optional on `Plugin`, and a preceding
35
+ // `expect(...).toBeDefined()` does not narrow them for TypeScript — so
36
+ // the indexed form only compiled because nothing type-checked this file.
37
+ // `toMatchObject` needs no narrowing and pins the fields together, which
38
+ // is what "correct metadata" actually means.
39
+ expect(plugin).toMatchObject({
40
+ key: "data_enhancement",
41
+ slots: [{ slot: "form.actions" }],
42
+ providers: [{ scope: "form" }]
43
+ });
38
44
  });
39
45
 
40
- it("accepts and forwards custom apiKey and host props", () => {
41
- const customProps = {
42
- apiKey: "custom-key",
43
- host: "https://custom-host.com"
44
- };
45
- const { result } = renderHook(() => useDataEnhancementPlugin(customProps));
46
- const plugin = result.current;
46
+ it("ships no credentials", () => {
47
+ // The FireCMS-era plugin exported a hardcoded `fcms-…` key as
48
+ // `DEFAULT_API_KEY` and handed it to the provider. Anyone who installed
49
+ // the package got a copy. Nothing key-shaped may reach the provider
50
+ // props again — the hosted service authenticates nobody by design.
51
+ const { result } = renderHook(() => useDataEnhancementPlugin());
52
+ const props = result.current.providers?.[0]?.props ?? {};
53
+ expect(Object.keys(props)).toEqual(expect.not.arrayContaining(["apiKey", "firebaseToken", "token"]));
54
+ expect(JSON.stringify(props)).not.toMatch(/fcms-/);
55
+ });
56
+
57
+ it("forwards a custom endpoint so a self-hoster can proxy the service", () => {
58
+ const { result } = renderHook(() => useDataEnhancementPlugin({ endpoint: "https://ai.example.com" }));
47
59
 
48
- expect(plugin.providers[0].props.apiKey).toBe("custom-key");
49
- expect(plugin.providers[0].props.host).toBe("https://custom-host.com");
60
+ expect(result.current).toMatchObject({
61
+ providers: [{ props: { endpoint: "https://ai.example.com" } }]
62
+ });
63
+ });
64
+
65
+ it("leaves the endpoint unset so the client falls back to the hosted default", () => {
66
+ // Compared against the exported constant rather than a copy of the URL:
67
+ // a copy pins the string, this pins the wiring.
68
+ const { result } = renderHook(() => useDataEnhancementPlugin());
69
+ expect(result.current.providers?.[0]?.props?.endpoint).toBeUndefined();
70
+ expect(DEFAULT_AI_ENDPOINT).toMatch(/^https:\/\//);
50
71
  });
51
72
  });
@@ -0,0 +1,98 @@
1
+ import { TextEncoder, TextDecoder } from "util";
2
+ Object.assign(global, { TextEncoder,
3
+ TextDecoder });
4
+
5
+ import { renderHook } from "@testing-library/react";
6
+ import { useEditorAIController } from "../editor/useEditorAIController";
7
+
8
+ /**
9
+ * The editor's inline continuation.
10
+ *
11
+ * Small, but it was the FireCMS-era code that demanded a Firebase ID token and
12
+ * threw `"Firebase token is required"` without one — in a Rebase app there is
13
+ * no such thing. The property worth pinning is that it now needs no token at
14
+ * all, and that its streamed deltas reach the editor in order.
15
+ */
16
+ jest.mock("@rebasepro/admin", () => ({}));
17
+
18
+ function streamingResponse(chunks: string[]): any {
19
+ const encoder = new TextEncoder();
20
+ let i = 0;
21
+ return {
22
+ ok: true,
23
+ status: 200,
24
+ body: {
25
+ getReader: () => ({
26
+ read: async () => i < chunks.length
27
+ ? { done: false,
28
+ value: encoder.encode(chunks[i++]) }
29
+ : { done: true,
30
+ value: undefined }
31
+ })
32
+ }
33
+ };
34
+ }
35
+
36
+ afterEach(() => jest.restoreAllMocks());
37
+
38
+ describe("useEditorAIController", () => {
39
+
40
+ it("streams deltas in order and resolves with the whole continuation", async () => {
41
+ (global as any).fetch = jest.fn().mockResolvedValue(streamingResponse([
42
+ 'event: delta\ndata: {"text":"made of "}\n\n',
43
+ 'event: delta\ndata: {"text":"stainless steel"}\n\n',
44
+ "event: done\ndata: {}\n\n"
45
+ ]));
46
+
47
+ const { result } = renderHook(() => useEditorAIController());
48
+ const seen: string[] = [];
49
+ const text = await result.current.autocomplete("The burrs are ", " Ships worldwide.", d => seen.push(d));
50
+
51
+ expect(seen).toEqual(["made of ", "stainless steel"]);
52
+ expect(text).toBe("made of stainless steel");
53
+ });
54
+
55
+ it("needs no auth token — it does not send one, and does not demand one", async () => {
56
+ const fetchMock = jest.fn().mockResolvedValue(streamingResponse(["event: done\ndata: {}\n\n"]));
57
+ (global as any).fetch = fetchMock;
58
+
59
+ const { result } = renderHook(() => useEditorAIController());
60
+ await expect(result.current.autocomplete("a", "b", () => undefined)).resolves.toBe("");
61
+
62
+ const [, init] = fetchMock.mock.calls[0];
63
+ expect(init.headers).toEqual({ "Content-Type": "application/json" });
64
+ expect(JSON.stringify(init)).not.toMatch(/Bearer|Basic|token/i);
65
+ });
66
+
67
+ it("sends the caret context the editor gave it", async () => {
68
+ const fetchMock = jest.fn().mockResolvedValue(streamingResponse(["event: done\ndata: {}\n\n"]));
69
+ (global as any).fetch = fetchMock;
70
+
71
+ const { result } = renderHook(() => useEditorAIController());
72
+ await result.current.autocomplete("before", "after", () => undefined);
73
+
74
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ textBefore: "before",
75
+ textAfter: "after" });
76
+ });
77
+
78
+ it("honours a custom endpoint, so a self-hoster can proxy it", async () => {
79
+ const fetchMock = jest.fn().mockResolvedValue(streamingResponse(["event: done\ndata: {}\n\n"]));
80
+ (global as any).fetch = fetchMock;
81
+
82
+ const { result } = renderHook(() => useEditorAIController({ endpoint: "https://ai.example.com" }));
83
+ await result.current.autocomplete("a", "b", () => undefined);
84
+
85
+ expect(fetchMock.mock.calls[0][0]).toBe("https://ai.example.com/autocomplete");
86
+ });
87
+
88
+ it("surfaces the service's message when it refuses", async () => {
89
+ (global as any).fetch = jest.fn().mockResolvedValue({
90
+ ok: false,
91
+ status: 429,
92
+ json: async () => ({ error: { message: "The free AI quota for today has been used up." } })
93
+ });
94
+
95
+ const { result } = renderHook(() => useEditorAIController());
96
+ await expect(result.current.autocomplete("a", "b", () => undefined)).rejects.toThrow(/quota for today/);
97
+ });
98
+ });