@rebasepro/plugin-ai 0.17.3 → 0.18.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.
@@ -1,596 +0,0 @@
1
- import { TextEncoder, TextDecoder } from "util";
2
- Object.assign(global, { TextEncoder,
3
- TextDecoder });
4
-
5
- // The provider reads the auth controller, so it imports `@rebasepro/app`, whose
6
- // module graph probes the viewport on load. Same stub as the other suites here.
7
- if (typeof window !== "undefined") {
8
- Object.defineProperty(window, "matchMedia", {
9
- writable: true,
10
- value: jest.fn().mockImplementation(query => ({
11
- matches: false,
12
- media: query,
13
- onchange: null,
14
- addEventListener: jest.fn(),
15
- removeEventListener: jest.fn(),
16
- dispatchEvent: jest.fn()
17
- }))
18
- });
19
- }
20
-
21
- import React from "react";
22
- import { act, renderHook, waitFor } from "@testing-library/react";
23
-
24
- import { AuthControllerContext } from "@rebasepro/app";
25
-
26
- import {
27
- DataEnhancementControllerProvider,
28
- useDataEnhancementController
29
- } from "../components/DataEnhancementControllerProvider";
30
- import { clearAiStatusCache } from "../api";
31
-
32
- /**
33
- * The propose → review → apply contract.
34
- *
35
- * The design this replaced streamed generated text straight into the live form
36
- * fields. Its failure modes were all the same failure: the record changed
37
- * before anyone agreed to it. Half-written sentences appeared mid-generation,
38
- * heuristics guessed whether each token should append to or replace what the
39
- * operator had typed, and getting the old value back meant retyping it.
40
- *
41
- * So the load-bearing assertion in this file is a negative one — `setFieldValue`
42
- * is not called — and it is checked during streaming, after streaming, after a
43
- * failure, and after a discard. Everything else here is detail; that is the
44
- * promise.
45
- */
46
-
47
- jest.mock("@rebasepro/cms", () => ({
48
- getFieldId: (property: { type?: string }) => (property?.type === "string" ? "text_field" : "number_field")
49
- }));
50
-
51
- const COLLECTION = {
52
- name: "Products",
53
- singularName: "Product",
54
- properties: {
55
- title: { type: "string",
56
- name: "Title" },
57
- subtitle: { type: "string",
58
- name: "Subtitle" },
59
- stock: { type: "number",
60
- name: "Stock" }
61
- }
62
- } as any;
63
-
64
- /** A `Response` whose body yields the given chunks, in order. */
65
- function streamingResponse(chunks: string[]): any {
66
- const encoder = new TextEncoder();
67
- let i = 0;
68
- return {
69
- ok: true,
70
- status: 200,
71
- body: {
72
- getReader: () => ({
73
- read: async () =>
74
- i < chunks.length
75
- ? { done: false,
76
- value: encoder.encode(chunks[i++]) }
77
- : { done: true,
78
- value: undefined }
79
- })
80
- }
81
- };
82
- }
83
-
84
- const AUTOFILL_BODY = [
85
- 'event: suggestion_delta\ndata: {"key":"title","text":"Blue "}',
86
- 'event: suggestion_delta\ndata: {"key":"title","text":"widget"}',
87
- 'event: suggestion\ndata: {"key":"title","value":"Blue widget"}',
88
- 'event: suggestion\ndata: {"key":"stock","value":42}',
89
- 'event: done\ndata: {"suggestions":{"title":"Blue widget","stock":42}}',
90
- ""
91
- ].join("\n\n");
92
-
93
- /** Route `/status` to an availability answer and `/autofill` to a stream. */
94
- function mockService(autofill: () => any, available = true) {
95
- (global as any).fetch = jest.fn((url: string) => {
96
- if (String(url).endsWith("/status")) {
97
- return Promise.resolve({ ok: true,
98
- status: 200,
99
- json: async () => ({ available,
100
- model: "gemini-3.7-flash" }) });
101
- }
102
- if (String(url).endsWith("/autofill")) return Promise.resolve(autofill());
103
- return Promise.resolve({ ok: true,
104
- status: 200,
105
- json: async () => ({ prompts: [] }) });
106
- });
107
- }
108
-
109
- async function mountController(formValues: Record<string, unknown> = {}, options: {
110
- collection?: any,
111
- getConfigForPath?: (props: any) => boolean,
112
- user?: any
113
- } = {}) {
114
- const setFieldValue = jest.fn();
115
- const formContext = { values: formValues,
116
- setFieldValue } as any;
117
-
118
- const wrapper = ({ children }: { children: React.ReactNode }) => (
119
- <AuthControllerContext.Provider value={{ user: options.user ?? null } as any}>
120
- <DataEnhancementControllerProvider
121
- path={"products"}
122
- collection={options.collection ?? COLLECTION}
123
- getConfigForPath={options.getConfigForPath}
124
- formContext={formContext}
125
- {...({} as any)}>
126
- {children}
127
- </DataEnhancementControllerProvider>
128
- </AuthControllerContext.Provider>
129
- );
130
-
131
- const rendered = renderHook(() => useDataEnhancementController(), { wrapper });
132
- return { ...rendered,
133
- setFieldValue };
134
- }
135
-
136
- /** The body of the `/autofill` request the last run made. */
137
- function autofillRequestBody() {
138
- const call = ((global as any).fetch as jest.Mock).mock.calls
139
- .find(([url]: [string]) => String(url).endsWith("/autofill"));
140
- return JSON.parse(call[1].body);
141
- }
142
-
143
- beforeEach(() => {
144
- // The availability probe is cached for the life of the page, so each test
145
- // has to start from an unasked question.
146
- clearAiStatusCache();
147
- });
148
-
149
- afterEach(() => {
150
- jest.restoreAllMocks();
151
- clearAiStatusCache();
152
- });
153
-
154
- describe("autofill review", () => {
155
-
156
- it("never writes to the form while generating or after it finishes", async () => {
157
- mockService(() => streamingResponse([AUTOFILL_BODY]));
158
- const { result, setFieldValue } = await mountController({ title: "" });
159
- await waitFor(() => expect(result.current.enabled).toBe(true));
160
-
161
- await act(async () => {
162
- await result.current.generate({ values: { title: "" } });
163
- });
164
-
165
- expect(result.current.review?.status).toBe("ready");
166
- expect(result.current.review?.fields.length).toBe(2);
167
- expect(setFieldValue).not.toHaveBeenCalled();
168
- });
169
-
170
- it("accumulates streamed text into the proposal, not the field", async () => {
171
- mockService(() => streamingResponse([AUTOFILL_BODY]));
172
- const { result, setFieldValue } = await mountController();
173
- await waitFor(() => expect(result.current.enabled).toBe(true));
174
-
175
- await act(async () => {
176
- await result.current.generate({ values: {} });
177
- });
178
-
179
- const title = result.current.review?.fields.find(f => f.key === "title");
180
- expect(title?.proposed).toBe("Blue widget");
181
- expect(title?.pending).toBe(false);
182
- expect(title?.label).toBe("Title");
183
- expect(setFieldValue).not.toHaveBeenCalled();
184
- });
185
-
186
- it("writes only the selected fields, and only on apply", async () => {
187
- mockService(() => streamingResponse([AUTOFILL_BODY]));
188
- const { result, setFieldValue } = await mountController();
189
- await waitFor(() => expect(result.current.enabled).toBe(true));
190
-
191
- await act(async () => {
192
- await result.current.generate({ values: {} });
193
- });
194
-
195
- act(() => result.current.toggleField("stock"));
196
- expect(setFieldValue).not.toHaveBeenCalled();
197
-
198
- act(() => result.current.applyReview());
199
-
200
- expect(setFieldValue).toHaveBeenCalledTimes(1);
201
- expect(setFieldValue).toHaveBeenCalledWith("title", "Blue widget");
202
- // The review closes on apply — there is nothing left to decide.
203
- expect(result.current.review).toBeNull();
204
- });
205
-
206
- it("writes nothing when the review is discarded", async () => {
207
- mockService(() => streamingResponse([AUTOFILL_BODY]));
208
- const { result, setFieldValue } = await mountController({ title: "Existing" });
209
- await waitFor(() => expect(result.current.enabled).toBe(true));
210
-
211
- await act(async () => {
212
- await result.current.generate({ values: { title: "Existing" } });
213
- });
214
- act(() => result.current.dismissReview());
215
-
216
- expect(setFieldValue).not.toHaveBeenCalled();
217
- expect(result.current.review).toBeNull();
218
- });
219
-
220
- it("records what each proposal would overwrite", async () => {
221
- // The row shows the current value struck through, which is only possible
222
- // because the form was never touched — it still holds the original.
223
- mockService(() => streamingResponse([AUTOFILL_BODY]));
224
- const { result } = await mountController({ title: "Old title" });
225
- await waitFor(() => expect(result.current.enabled).toBe(true));
226
-
227
- await act(async () => {
228
- await result.current.generate({ values: { title: "Old title" } });
229
- });
230
-
231
- const title = result.current.review?.fields.find(f => f.key === "title");
232
- expect(title?.currentValue).toBe("Old title");
233
- expect(title?.proposed).toBe("Blue widget");
234
- });
235
-
236
- it("toggles every field at once", async () => {
237
- mockService(() => streamingResponse([AUTOFILL_BODY]));
238
- const { result, setFieldValue } = await mountController();
239
- await waitFor(() => expect(result.current.enabled).toBe(true));
240
-
241
- await act(async () => {
242
- await result.current.generate({ values: {} });
243
- });
244
-
245
- act(() => result.current.toggleAll(false));
246
- act(() => result.current.applyReview());
247
- expect(setFieldValue).not.toHaveBeenCalled();
248
- });
249
-
250
- it("keeps the fields that arrived before a mid-stream failure", async () => {
251
- // A run that produced two good fields and then broke should still let
252
- // the operator take the two, rather than discarding the work.
253
- const truncated = [
254
- 'event: suggestion\ndata: {"key":"title","value":"Blue widget"}',
255
- 'event: error\ndata: {"message":"quota exhausted"}',
256
- ""
257
- ].join("\n\n");
258
- mockService(() => streamingResponse([truncated]));
259
- const { result, setFieldValue } = await mountController();
260
- await waitFor(() => expect(result.current.enabled).toBe(true));
261
-
262
- await act(async () => {
263
- await result.current.generate({ values: {} });
264
- });
265
-
266
- expect(result.current.review?.status).toBe("failed");
267
- expect(result.current.review?.error).toMatch(/quota exhausted/);
268
- expect(result.current.review?.fields.map(f => f.key)).toEqual(["title"]);
269
- expect(setFieldValue).not.toHaveBeenCalled();
270
-
271
- act(() => result.current.applyReview());
272
- expect(setFieldValue).toHaveBeenCalledWith("title", "Blue widget");
273
- });
274
-
275
- it("sends values flattened onto the same dotted paths as the properties", async () => {
276
- // The service is told about `seo.title`, so it has to be told the value
277
- // of `seo.title` — not of `seo`. Sending the nested object instead makes
278
- // every existing value invisible to the model, which shows up as
279
- // autofill cheerfully overwriting things the operator already wrote.
280
- mockService(() => streamingResponse([AUTOFILL_BODY]));
281
- const { result } = await mountController();
282
- await waitFor(() => expect(result.current.enabled).toBe(true));
283
-
284
- await act(async () => {
285
- await result.current.generate({ values: { seo: { title: "Nested" } } });
286
- });
287
-
288
- const call = ((global as any).fetch as jest.Mock).mock.calls
289
- .find(([url]: [string]) => String(url).endsWith("/autofill"));
290
- expect(JSON.parse(call[1].body).values).toEqual({ "seo.title": "Nested" });
291
- });
292
-
293
- it("stays disabled when the service reports unavailable", async () => {
294
- mockService(() => streamingResponse([AUTOFILL_BODY]), false);
295
- const { result } = await mountController();
296
- await waitFor(() => expect((global as any).fetch).toHaveBeenCalled());
297
- expect(result.current.enabled).toBe(false);
298
- expect(result.current.review).toBeNull();
299
- });
300
- });
301
-
302
- describe("autofill review — runs that overlap or are abandoned", () => {
303
-
304
- it("does not write into a review the operator already dismissed", async () => {
305
- // Closing the dialog mid-generation must not leave late frames landing
306
- // in a review that is no longer on screen.
307
- mockService(() => streamingResponse([AUTOFILL_BODY]));
308
- const { result, setFieldValue } = await mountController();
309
- await waitFor(() => expect(result.current.enabled).toBe(true));
310
-
311
- let pending: Promise<void>;
312
- act(() => {
313
- pending = result.current.generate({ values: {} });
314
- });
315
- act(() => result.current.dismissReview());
316
- await act(async () => {
317
- await pending!;
318
- });
319
-
320
- expect(result.current.review).toBeNull();
321
- expect(setFieldValue).not.toHaveBeenCalled();
322
- });
323
-
324
- it("does not let an earlier run's fields leak into a later one", async () => {
325
- // Two clicks in quick succession. The first run's frames must not
326
- // appear in the second run's review — the operator would be reviewing
327
- // proposals for an instruction they replaced.
328
- const first = [
329
- 'event: suggestion\ndata: {"key":"title","value":"FIRST RUN"}',
330
- 'event: done\ndata: {"suggestions":{"title":"FIRST RUN"}}',
331
- ""
332
- ].join("\n\n");
333
- const second = [
334
- 'event: suggestion\ndata: {"key":"stock","value":7}',
335
- 'event: done\ndata: {"suggestions":{"stock":7}}',
336
- ""
337
- ].join("\n\n");
338
-
339
- let call = 0;
340
- (global as any).fetch = jest.fn((url: string) => {
341
- if (String(url).endsWith("/status")) {
342
- return Promise.resolve({ ok: true,
343
- status: 200,
344
- json: async () => ({ available: true }) });
345
- }
346
- if (String(url).endsWith("/autofill")) {
347
- return Promise.resolve(streamingResponse([call++ === 0 ? first : second]));
348
- }
349
- return Promise.resolve({ ok: true,
350
- status: 200,
351
- json: async () => ({ prompts: [] }) });
352
- });
353
-
354
- const { result } = await mountController();
355
- await waitFor(() => expect(result.current.enabled).toBe(true));
356
-
357
- await act(async () => {
358
- await result.current.generate({ values: {} });
359
- });
360
- await act(async () => {
361
- await result.current.generate({ values: {} });
362
- });
363
-
364
- const keys = result.current.review?.fields.map(f => f.key);
365
- expect(keys).toEqual(["stock"]);
366
- expect(JSON.stringify(result.current.review)).not.toMatch(/FIRST RUN/);
367
- });
368
-
369
- it("applies only the fields that finished, if applied mid-stream", async () => {
370
- // The Apply button counts non-pending rows; applying must honour the
371
- // same rule, or a half-written sentence lands in the record.
372
- mockService(() => streamingResponse([
373
- 'event: suggestion_delta\ndata: {"key":"title","text":"half a sen"}\n\n' +
374
- 'event: suggestion\ndata: {"key":"stock","value":9}\n\n'
375
- ]));
376
- const { result, setFieldValue } = await mountController();
377
- await waitFor(() => expect(result.current.enabled).toBe(true));
378
-
379
- await act(async () => {
380
- await result.current.generate({ values: {} });
381
- });
382
-
383
- // The stream ended without completing `title`, so it is dropped rather
384
- // than offered — the only thing held for it is a half-written sentence.
385
- expect(result.current.review?.fields.map(f => f.key)).toEqual(["stock"]);
386
-
387
- act(() => result.current.applyReview());
388
-
389
- expect(setFieldValue).toHaveBeenCalledTimes(1);
390
- expect(setFieldValue).toHaveBeenCalledWith("stock", 9);
391
- });
392
-
393
- it("reports a truncated stream as a failure, not as an empty answer", async () => {
394
- // The body simply stops — a rolled pod, a proxy timeout. The review used
395
- // to close with "Nothing to fill in — every field either already has a
396
- // value the model would not improve on, or is not one it can write",
397
- // which is a confident, wrong answer about the operator's empty fields.
398
- mockService(() => streamingResponse([
399
- 'event: suggestion\ndata: {"key":"title","value":"Blue widget"}\n\n'
400
- ]));
401
- const { result, setFieldValue } = await mountController();
402
- await waitFor(() => expect(result.current.enabled).toBe(true));
403
-
404
- await act(async () => {
405
- await result.current.generate({ values: {} });
406
- });
407
-
408
- expect(result.current.review?.status).toBe("failed");
409
- expect(result.current.review?.error).toMatch(/ended before it finished/);
410
- // What did arrive is still on offer — the dialog's failure branch says so.
411
- expect(result.current.review?.fields.map(f => f.key)).toEqual(["title"]);
412
- expect(setFieldValue).not.toHaveBeenCalled();
413
- });
414
-
415
- it("drops a date the model returned as unparseable rather than storing NaN", async () => {
416
- const collection = {
417
- name: "Posts",
418
- singularName: "Post",
419
- properties: { publishedAt: { type: "date",
420
- name: "Published at" } }
421
- } as any;
422
-
423
- (global as any).fetch = jest.fn((url: string) => {
424
- if (String(url).endsWith("/status")) {
425
- return Promise.resolve({ ok: true,
426
- status: 200,
427
- json: async () => ({ available: true }) });
428
- }
429
- return Promise.resolve(streamingResponse([
430
- 'event: suggestion\ndata: {"key":"publishedAt","value":"not a date"}\n\nevent: done\ndata: {"suggestions":{}}\n\n'
431
- ]));
432
- });
433
-
434
- const setFieldValue = jest.fn();
435
- const wrapper = ({ children }: { children: React.ReactNode }) => (
436
- <DataEnhancementControllerProvider
437
- path={"posts"}
438
- collection={collection}
439
- formContext={{ values: {},
440
- setFieldValue } as any}
441
- {...({} as any)}>
442
- {children}
443
- </DataEnhancementControllerProvider>
444
- );
445
- const { result } = renderHook(() => useDataEnhancementController(), { wrapper });
446
- await waitFor(() => expect(result.current.enabled).toBe(true));
447
-
448
- await act(async () => {
449
- await result.current.generate({ values: {} });
450
- });
451
- act(() => result.current.applyReview());
452
-
453
- expect(setFieldValue).not.toHaveBeenCalled();
454
- });
455
- });
456
-
457
- /**
458
- * What actually goes on the wire.
459
- *
460
- * `request_agreement.test.ts` checks the two maps against each other directly;
461
- * these go through the provider, because the provider is what builds the body —
462
- * and a guard that is correct over a lossily-transformed copy of its subject is
463
- * exactly the failure this is here to catch.
464
- */
465
- describe("the autofill request body", () => {
466
-
467
- const POSTS = {
468
- name: "Posts",
469
- singularName: "Post",
470
- properties: {
471
- title: { type: "string",
472
- name: "Title" },
473
- summary: { type: "string",
474
- name: "Summary" },
475
- tags: {
476
- type: "array",
477
- name: "Tags",
478
- of: { type: "string",
479
- name: "Tag" }
480
- },
481
- publishedAt: { type: "date",
482
- name: "Published at" },
483
- internalNotes: {
484
- type: "string",
485
- name: "Internal notes",
486
- admin: { readOnly: true }
487
- }
488
- }
489
- } as any;
490
-
491
- const PUBLISHED = {
492
- title: "Hello",
493
- tags: ["news", "launch"],
494
- publishedAt: new Date("2026-01-01T00:00:00.000Z"),
495
- internalNotes: "written by a backend hook"
496
- };
497
-
498
- it("sends an array and a date under the keys the property map names", async () => {
499
- // The service asks `values["tags"]` because `properties` says `tags`.
500
- // Sent as `tags.0`/`tags.1` — and a `Date` not sent at all — that lookup
501
- // is `undefined`, the field reads as empty, and a populated tag list and
502
- // a set publication date come back pre-ticked for replacement.
503
- mockService(() => streamingResponse([AUTOFILL_BODY]));
504
- const { result } = await mountController(PUBLISHED, { collection: POSTS });
505
- await waitFor(() => expect(result.current.enabled).toBe(true));
506
-
507
- await act(async () => {
508
- await result.current.generate({ values: PUBLISHED });
509
- });
510
-
511
- const { values, properties } = autofillRequestBody();
512
- expect(values.tags).toEqual(["news", "launch"]);
513
- expect(values.publishedAt).toBe("2026-01-01T00:00:00.000Z");
514
- expect(Object.keys(values).sort()).toEqual(["publishedAt", "tags", "title"]);
515
- // Every key of `values` is a key the service will look up.
516
- for (const key of Object.keys(values)) {
517
- expect(Object.keys(properties)).toContain(key);
518
- }
519
- });
520
-
521
- it("does not send the value of a read-only property", async () => {
522
- // The service will not *fill* a disabled field — but it puts every value
523
- // it is given into the prompt as context, so a field a backend hook owns
524
- // was travelling to the provider verbatim.
525
- mockService(() => streamingResponse([AUTOFILL_BODY]));
526
- const { result } = await mountController(PUBLISHED, { collection: POSTS });
527
- await waitFor(() => expect(result.current.enabled).toBe(true));
528
-
529
- await act(async () => {
530
- await result.current.generate({ values: PUBLISHED });
531
- });
532
-
533
- const body = autofillRequestBody();
534
- expect(body.values).not.toHaveProperty("internalNotes");
535
- expect(JSON.stringify(body.values)).not.toContain("backend hook");
536
- // Still declared as a property, so the service keeps refusing to fill it.
537
- expect(body.properties.internalNotes.disabled).toBe(true);
538
- });
539
- });
540
-
541
- describe("who is allowed to autofill", () => {
542
-
543
- it("passes the signed-in user to getConfigForPath", async () => {
544
- // The public prop type has always documented `user`, and the provider
545
- // called `getConfigForPath({ path, collection })`. So
546
- // `({ user }) => user?.roles?.includes("editor")` was `Boolean(undefined)`
547
- // — an access rule that decided nothing, in whichever direction it was
548
- // written.
549
- mockService(() => streamingResponse([AUTOFILL_BODY]));
550
- const seen: any[] = [];
551
- const { result } = await mountController({}, {
552
- user: { uid: "u1",
553
- roles: ["editor"] },
554
- getConfigForPath: (props) => {
555
- seen.push(props);
556
- return props.user?.roles?.includes("editor");
557
- }
558
- });
559
-
560
- await waitFor(() => expect(result.current.enabled).toBe(true));
561
- expect(seen[0]).toMatchObject({ path: "products",
562
- user: { uid: "u1" } });
563
- });
564
-
565
- it("passes null rather than undefined when nobody is signed in", async () => {
566
- mockService(() => streamingResponse([AUTOFILL_BODY]));
567
- const seen: any[] = [];
568
- await mountController({}, {
569
- getConfigForPath: (props) => {
570
- seen.push(props);
571
- return true;
572
- }
573
- });
574
- await waitFor(() => expect(seen.length).toBeGreaterThan(0));
575
- expect(seen[0].user).toBeNull();
576
- });
577
- });
578
-
579
- describe("the availability probe", () => {
580
-
581
- it("contacts the host once, however many forms are opened", async () => {
582
- // The provider is form-scoped. Uncached, this is one request to the
583
- // host — by default one Rebase runs — every time any record is opened,
584
- // whether or not anyone ever clicks Autofill.
585
- mockService(() => streamingResponse([AUTOFILL_BODY]));
586
-
587
- const first = await mountController();
588
- await waitFor(() => expect(first.result.current.enabled).toBe(true));
589
- const second = await mountController();
590
- await waitFor(() => expect(second.result.current.enabled).toBe(true));
591
-
592
- const statusCalls = ((global as any).fetch as jest.Mock).mock.calls
593
- .filter(([url]: [string]) => String(url).endsWith("/status"));
594
- expect(statusCalls.length).toBe(1);
595
- });
596
- });
@@ -1,72 +0,0 @@
1
- import { TextEncoder, TextDecoder } from "util";
2
- Object.assign(global, { TextEncoder,
3
- TextDecoder });
4
-
5
- // Mock window.matchMedia
6
- if (typeof window !== "undefined") {
7
- Object.defineProperty(window, "matchMedia", {
8
- writable: true,
9
- value: jest.fn().mockImplementation(query => ({
10
- matches: false,
11
- media: query,
12
- onchange: null,
13
- addEventListener: jest.fn(),
14
- removeEventListener: jest.fn(),
15
- dispatchEvent: jest.fn()
16
- }))
17
- });
18
- }
19
-
20
- import { renderHook } from "@testing-library/react";
21
- import { useDataEnhancementPlugin } from "../useDataEnhancementPlugin";
22
- import { DEFAULT_AI_ENDPOINT } from "../api";
23
-
24
- jest.mock("@rebasepro/cms", () => ({
25
- useUrlController: () => ({})
26
- }));
27
-
28
- describe("useDataEnhancementPlugin hook", () => {
29
- it("returns data enhancement plugin with correct metadata", () => {
30
- const { result } = renderHook(() => useDataEnhancementPlugin());
31
- const plugin = result.current;
32
-
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
- });
44
- });
45
-
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" }));
59
-
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:\/\//);
71
- });
72
- });