@rebasepro/plugin-ai 0.12.1-canary.gf4240e3 → 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.
@@ -0,0 +1,420 @@
1
+ import { getSimplifiedProperties } from "../utils/properties";
2
+ import { Properties } from "@rebasepro/types";
3
+
4
+ /**
5
+ * The schema serializer.
6
+ *
7
+ * This is the file that decides *what the model is asked to fill*. The service
8
+ * builds its JSON schema from whatever comes out of here, so a key this
9
+ * function drops is a field autofill will never write, silently — no error, no
10
+ * warning, just a form that stays half empty on exactly the collections that
11
+ * are most tedious to fill by hand.
12
+ *
13
+ * It also has to agree with `flatMapEntityValues` about paths. Properties are
14
+ * keyed on dotted paths (`seo.title`) and values are flattened onto the same
15
+ * ones; if the two disagree, the model is told a field exists and shown no
16
+ * value for it, and cheerfully overwrites what the operator already wrote.
17
+ *
18
+ * `getFieldId` is mocked to something deterministic — the real one resolves
19
+ * against the admin field registry, which is not what is under test here.
20
+ */
21
+ jest.mock("@rebasepro/admin", () => ({
22
+ getFieldId: (property: { type?: string; admin?: { markdown?: boolean; multiline?: boolean } }) => {
23
+ if (!property?.type) return undefined;
24
+ if (property.type === "string" && property.admin?.markdown) return "markdown";
25
+ if (property.type === "string" && property.admin?.multiline) return "multiline";
26
+ if (property.type === "string") return "text_field";
27
+ if (property.type === "number") return "number_field";
28
+ if (property.type === "boolean") return "switch";
29
+ if (property.type === "date") return "date_time";
30
+ if (property.type === "reference") return "reference";
31
+ return undefined;
32
+ }
33
+ }));
34
+
35
+ describe("getSimplifiedProperties — flat properties", () => {
36
+
37
+ it("keys each scalar on its own name", () => {
38
+ const properties = {
39
+ title: { name: "Title",
40
+ type: "string" },
41
+ stock: { name: "Stock",
42
+ type: "number" }
43
+ } as unknown as Properties;
44
+
45
+ const out = getSimplifiedProperties(properties, {});
46
+
47
+ expect(Object.keys(out).sort()).toEqual(["stock", "title"]);
48
+ expect(out.title).toMatchObject({ name: "Title",
49
+ type: "string",
50
+ fieldConfigId: "text_field" });
51
+ });
52
+
53
+ it("carries the description through, because it is what the model is steered by", () => {
54
+ const properties = {
55
+ summary: { name: "Summary",
56
+ type: "string",
57
+ description: "One sentence, no marketing" }
58
+ } as unknown as Properties;
59
+
60
+ expect(getSimplifiedProperties(properties, {}).summary.description).toBe("One sentence, no marketing");
61
+ });
62
+
63
+ it("marks disabled and read-only fields, so the service can refuse to fill them", () => {
64
+ const properties = {
65
+ slug: { name: "Slug",
66
+ type: "string",
67
+ admin: { disabled: true } },
68
+ computed: { name: "Computed",
69
+ type: "string",
70
+ admin: { readOnly: true } },
71
+ normal: { name: "Normal",
72
+ type: "string" }
73
+ } as unknown as Properties;
74
+
75
+ const out = getSimplifiedProperties(properties, {});
76
+ expect(out.slug.disabled).toBe(true);
77
+ expect(out.computed.disabled).toBe(true);
78
+ expect(out.normal.disabled).toBe(false);
79
+ });
80
+
81
+ it("flattens enum values to the ids the model must choose between", () => {
82
+ const asArray = {
83
+ status: { name: "Status",
84
+ type: "string",
85
+ enum: [{ id: "draft",
86
+ label: "Draft" }, { id: "live",
87
+ label: "Live" }] }
88
+ } as unknown as Properties;
89
+ expect(getSimplifiedProperties(asArray, {}).status.enum).toEqual(["draft", "live"]);
90
+
91
+ const asObject = {
92
+ status: { name: "Status",
93
+ type: "string",
94
+ enum: { draft: "Draft",
95
+ live: "Live" } }
96
+ } as unknown as Properties;
97
+ expect(getSimplifiedProperties(asObject, {}).status.enum).toEqual(["draft", "live"]);
98
+ });
99
+
100
+ it("distinguishes markdown and multiline, which change how a suggestion is joined", () => {
101
+ const properties = {
102
+ body: { name: "Body",
103
+ type: "string",
104
+ admin: { markdown: true } },
105
+ notes: { name: "Notes",
106
+ type: "string",
107
+ admin: { multiline: true } }
108
+ } as unknown as Properties;
109
+
110
+ const out = getSimplifiedProperties(properties, {});
111
+ expect(out.body.fieldConfigId).toBe("markdown");
112
+ expect(out.notes.fieldConfigId).toBe("multiline");
113
+ });
114
+
115
+ it("skips a property builder, which is a function with no schema to read", () => {
116
+ const properties = {
117
+ title: { name: "Title",
118
+ type: "string" },
119
+ derived: () => ({ name: "Derived",
120
+ type: "string" })
121
+ } as unknown as Properties;
122
+
123
+ expect(Object.keys(getSimplifiedProperties(properties, {}))).toEqual(["title"]);
124
+ });
125
+
126
+ it("drops a property whose type has no field, rather than throwing", () => {
127
+ const properties = {
128
+ title: { name: "Title",
129
+ type: "string" },
130
+ weird: { name: "Weird",
131
+ type: "geopoint" }
132
+ } as unknown as Properties;
133
+
134
+ expect(Object.keys(getSimplifiedProperties(properties, {}))).toEqual(["title"]);
135
+ });
136
+
137
+ it("returns nothing for an absent property map", () => {
138
+ expect(getSimplifiedProperties(undefined as unknown as Properties, {})).toEqual({});
139
+ });
140
+ });
141
+
142
+ describe("getSimplifiedProperties — nested maps", () => {
143
+
144
+ it("keys children on the dotted path the values are flattened onto", () => {
145
+ // The contract with `flatMapEntityValues`. If these disagree the model
146
+ // is told about `seo.title` and shown a value for `seo`, so it treats
147
+ // every existing value as absent.
148
+ const properties = {
149
+ seo: {
150
+ name: "SEO",
151
+ type: "map",
152
+ properties: {
153
+ title: { name: "SEO title",
154
+ type: "string" },
155
+ description: { name: "SEO description",
156
+ type: "string" }
157
+ }
158
+ }
159
+ } as unknown as Properties;
160
+
161
+ const out = getSimplifiedProperties(properties, {});
162
+
163
+ expect(Object.keys(out).sort()).toEqual(["seo", "seo.description", "seo.title"]);
164
+ expect(out["seo.title"]).toMatchObject({ name: "SEO title",
165
+ fieldConfigId: "text_field" });
166
+ // The container itself is described too, so the model knows the grouping.
167
+ expect(out.seo).toMatchObject({ type: "map",
168
+ fieldConfigId: "group" });
169
+ });
170
+
171
+ it("nests two levels deep", () => {
172
+ const properties = {
173
+ meta: {
174
+ name: "Meta",
175
+ type: "map",
176
+ properties: {
177
+ seo: {
178
+ name: "SEO",
179
+ type: "map",
180
+ properties: { title: { name: "Title",
181
+ type: "string" } }
182
+ }
183
+ }
184
+ }
185
+ } as unknown as Properties;
186
+
187
+ const out = getSimplifiedProperties(properties, {});
188
+ expect(out["meta.seo.title"]).toMatchObject({ name: "Title" });
189
+ });
190
+
191
+ it("omits a map that declares no properties", () => {
192
+ const properties = {
193
+ title: { name: "Title",
194
+ type: "string" },
195
+ blob: { name: "Blob",
196
+ type: "map" }
197
+ } as unknown as Properties;
198
+
199
+ expect(Object.keys(getSimplifiedProperties(properties, {}))).toEqual(["title"]);
200
+ });
201
+
202
+ it("omits a map whose children are all unfillable", () => {
203
+ const properties = {
204
+ refs: {
205
+ name: "Refs",
206
+ type: "map",
207
+ properties: { point: { name: "Point",
208
+ type: "geopoint" } }
209
+ }
210
+ } as unknown as Properties;
211
+
212
+ expect(getSimplifiedProperties(properties, {})).toEqual({});
213
+ });
214
+ });
215
+
216
+ describe("getSimplifiedProperties — arrays", () => {
217
+
218
+ it("describes an array of scalars by its element type", () => {
219
+ // What lets the service offer `{type: "array", items: {type: "string"}}`
220
+ // for a tags field rather than skipping it.
221
+ const properties = {
222
+ tags: { name: "Tags",
223
+ type: "array",
224
+ of: { name: "Tag",
225
+ type: "string" } }
226
+ } as unknown as Properties;
227
+
228
+ const out = getSimplifiedProperties(properties, {});
229
+ expect(out.tags).toMatchObject({ type: "array",
230
+ fieldConfigId: "repeat" });
231
+ expect(out.tags.of).toMatchObject({ type: "string",
232
+ fieldConfigId: "text_field" });
233
+ });
234
+
235
+ it("carries the element's enum, so an array of choices stays constrained", () => {
236
+ const properties = {
237
+ categories: {
238
+ name: "Categories",
239
+ type: "array",
240
+ of: { name: "Category",
241
+ type: "string",
242
+ enum: [{ id: "a",
243
+ label: "A" }, { id: "b",
244
+ label: "B" }] }
245
+ }
246
+ } as unknown as Properties;
247
+
248
+ expect(getSimplifiedProperties(properties, {}).categories.of?.enum).toEqual(["a", "b"]);
249
+ });
250
+
251
+ it("marks a disabled array so the service leaves it alone", () => {
252
+ const properties = {
253
+ tags: { name: "Tags",
254
+ type: "array",
255
+ admin: { readOnly: true },
256
+ of: { name: "Tag",
257
+ type: "string" } }
258
+ } as unknown as Properties;
259
+
260
+ expect(getSimplifiedProperties(properties, {}).tags.disabled).toBe(true);
261
+ });
262
+
263
+ it("omits an array with neither `of` nor `oneOf`", () => {
264
+ const properties = {
265
+ title: { name: "Title",
266
+ type: "string" },
267
+ mystery: { name: "Mystery",
268
+ type: "array" }
269
+ } as unknown as Properties;
270
+
271
+ expect(Object.keys(getSimplifiedProperties(properties, {}))).toEqual(["title"]);
272
+ });
273
+ });
274
+
275
+ describe("getSimplifiedProperties — oneOf blocks", () => {
276
+
277
+ const blockProperties = {
278
+ content: {
279
+ name: "Content",
280
+ type: "array",
281
+ oneOf: {
282
+ typeField: "type",
283
+ valueField: "value",
284
+ properties: {
285
+ text: { name: "Text",
286
+ type: "string",
287
+ admin: { markdown: true } },
288
+ count: { name: "Count",
289
+ type: "number" }
290
+ }
291
+ }
292
+ }
293
+ } as unknown as Properties;
294
+
295
+ it("describes the available block types when the field is empty", () => {
296
+ const out = getSimplifiedProperties(blockProperties, {});
297
+ expect(out.content).toMatchObject({ type: "array",
298
+ fieldConfigId: "block" });
299
+ expect(Object.keys(out.content.oneOf!.properties).sort()).toEqual(["count", "text"]);
300
+ expect(out.content.oneOf!.typeField).toBe("type");
301
+ expect(out.content.oneOf!.valueField).toBe("value");
302
+ });
303
+
304
+ it("expands the blocks that already exist, keyed by index", () => {
305
+ const values = {
306
+ content: [
307
+ { type: "text",
308
+ value: "Hello" },
309
+ { type: "count",
310
+ value: 3 }
311
+ ]
312
+ };
313
+
314
+ const out = getSimplifiedProperties(blockProperties, values);
315
+
316
+ expect(out["content.0.value"]).toMatchObject({ name: "Text",
317
+ fieldConfigId: "markdown" });
318
+ expect(out["content.1.value"]).toMatchObject({ name: "Count",
319
+ fieldConfigId: "number_field" });
320
+ });
321
+
322
+ it("skips a block whose declared type is not in the schema", () => {
323
+ // Data can outlive a schema change. An unknown block must not take the
324
+ // whole collection's serialization down with it.
325
+ const values = { content: [{ type: "removed_block",
326
+ value: "x" }] };
327
+ const out = getSimplifiedProperties(blockProperties, values);
328
+ expect(out.content).toBeDefined();
329
+ expect(Object.keys(out).filter((k) => k.startsWith("content."))).toEqual([]);
330
+ });
331
+
332
+ it("skips a null entry in the block list", () => {
333
+ const values = { content: [null, { type: "text",
334
+ value: "Hi" }] };
335
+ const out = getSimplifiedProperties(blockProperties, values);
336
+ expect(out["content.1.value"]).toBeDefined();
337
+ });
338
+
339
+ it("defaults the type and value field names when the schema omits them", () => {
340
+ const properties = {
341
+ content: {
342
+ name: "Content",
343
+ type: "array",
344
+ oneOf: { properties: { text: { name: "Text",
345
+ type: "string" } } }
346
+ }
347
+ } as unknown as Properties;
348
+
349
+ const out = getSimplifiedProperties(properties, { content: [{ type: "text",
350
+ value: "Hi" }] });
351
+ expect(out["content.0.value"]).toMatchObject({ name: "Text" });
352
+ });
353
+ });
354
+
355
+ describe("getSimplifiedProperties — the paths it produces", () => {
356
+
357
+ it("agrees with the flattened value paths for every shape at once", () => {
358
+ // The single assertion that catches a drift between this function and
359
+ // `flatMapEntityValues`: every leaf key the model is told about must be
360
+ // one the values can actually be keyed on.
361
+ const properties = {
362
+ title: { name: "Title",
363
+ type: "string" },
364
+ seo: {
365
+ name: "SEO",
366
+ type: "map",
367
+ properties: { title: { name: "SEO title",
368
+ type: "string" } }
369
+ },
370
+ tags: { name: "Tags",
371
+ type: "array",
372
+ of: { name: "Tag",
373
+ type: "string" } }
374
+ } as unknown as Properties;
375
+
376
+ const out = getSimplifiedProperties(properties, { title: "T",
377
+ seo: { title: "S" } });
378
+
379
+ expect(Object.keys(out).sort()).toEqual(["seo", "seo.title", "tags", "title"]);
380
+ for (const key of Object.keys(out)) {
381
+ expect(key).toMatch(/^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)*$/);
382
+ }
383
+ });
384
+ });
385
+
386
+ describe("getSimplifiedProperties — a known wart", () => {
387
+
388
+ it("emits the block's type discriminator as a bare string, not a property", () => {
389
+ // Pinned rather than fixed, because it is pre-existing and harmless in
390
+ // effect — but it *is* malformed: the return type says
391
+ // `Record<string, InputProperty>` and this entry is the raw string
392
+ // `"text"`. It reaches the service, which skips it via an explicit
393
+ // `typeof property !== "object"` guard in `planFill` (see the matching
394
+ // test in saas/backend/functions/ai.test.ts).
395
+ //
396
+ // If that guard is ever removed, `property.disabled` and `property.type`
397
+ // read `undefined` off a string and the key is dropped anyway — so the
398
+ // worst case is noise on the wire rather than a bad schema. Fixing it
399
+ // means deciding whether the model benefits from knowing a block's type,
400
+ // which is a behaviour change, not a cleanup.
401
+ const properties = {
402
+ content: {
403
+ name: "Content",
404
+ type: "array",
405
+ oneOf: { properties: { text: { name: "Text",
406
+ type: "string" } } }
407
+ }
408
+ } as unknown as Properties;
409
+
410
+ const out = getSimplifiedProperties(properties, { content: [{ type: "text",
411
+ value: "Hi" }] });
412
+
413
+ expect(Object.keys(out)).toContain("content.0.type");
414
+ expect(typeof out["content.0.type"]).toBe("string");
415
+ expect(out["content.0.type"] as unknown).toBe("text");
416
+ // The entry that carries real schema is the value one, and it is correct.
417
+ expect(out["content.0.value"]).toMatchObject({ name: "Text",
418
+ type: "string" });
419
+ });
420
+ });
@@ -258,3 +258,136 @@ describe("autofill review", () => {
258
258
  expect(result.current.review).toBeNull();
259
259
  });
260
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
+ });
@@ -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
+ });