@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.
- package/README.md +62 -18
- package/dist/api.d.ts +58 -30
- package/dist/components/AutofillReviewDialog.d.ts +16 -0
- package/dist/components/DataEnhancementControllerProvider.d.ts +2 -3
- package/dist/components/FormEnhanceAction.d.ts +1 -1
- package/dist/editor/useEditorAIController.d.ts +11 -2
- package/dist/index.es.js +601 -382
- package/dist/index.es.js.map +1 -1
- package/dist/types/data_enhancement_controller.d.ts +84 -28
- package/dist/useDataEnhancementPlugin.d.ts +10 -5
- package/package.json +24 -19
- package/src/api.ts +241 -174
- package/src/components/AutofillReviewDialog.tsx +209 -0
- package/src/components/DataEnhancementControllerProvider.tsx +203 -262
- package/src/components/FormEnhanceAction.tsx +154 -128
- package/src/editor/useEditorAIController.tsx +20 -33
- package/src/tests/AutofillReviewDialog.test.tsx +340 -0
- package/src/tests/api.test.ts +283 -0
- package/src/tests/properties.test.ts +420 -0
- package/src/tests/review.test.tsx +393 -0
- package/src/tests/useDataEnhancementPlugin.test.tsx +36 -15
- package/src/tests/useEditorAIController.test.ts +98 -0
- package/src/types/data_enhancement_controller.tsx +98 -31
- package/src/useDataEnhancementPlugin.tsx +13 -12
- package/dist/utils/diffStrings.d.ts +0 -7
- package/dist/utils/strings_counter.d.ts +0 -2
- package/dist/utils/suggestions.d.ts +0 -1
- package/src/tests/diffStrings.test.ts +0 -128
- package/src/tests/strings_counter.test.ts +0 -117
- package/src/tests/suggestions.test.ts +0 -53
- package/src/utils/diffStrings.ts +0 -70
- package/src/utils/strings_counter.ts +0 -22
- 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
|
-
|
|
33
|
-
|
|
34
|
-
expect(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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("
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const { result } = renderHook(() => useDataEnhancementPlugin(
|
|
46
|
-
const
|
|
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(
|
|
49
|
-
|
|
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
|
+
});
|