@rebasepro/plugin-ai 0.17.3 → 0.18.1
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/LICENSE +0 -1
- package/README.md +4 -0
- package/dist/api.d.ts +8 -0
- package/dist/index.es.js.map +1 -1
- package/package.json +35 -21
- package/src/api.ts +0 -320
- package/src/components/AutofillReviewDialog.tsx +0 -209
- package/src/components/DataEnhancementControllerProvider.tsx +0 -320
- package/src/components/FormEnhanceAction.tsx +0 -307
- package/src/editor/useEditorAIController.tsx +0 -24
- package/src/index.ts +0 -9
- package/src/tests/AutofillReviewDialog.test.tsx +0 -340
- package/src/tests/api.test.ts +0 -382
- package/src/tests/properties.test.ts +0 -420
- package/src/tests/request_agreement.test.ts +0 -240
- package/src/tests/review.test.tsx +0 -596
- package/src/tests/useDataEnhancementPlugin.test.tsx +0 -72
- package/src/tests/useEditorAIController.test.ts +0 -98
- package/src/tests/values.test.ts +0 -87
- package/src/types/data_enhancement_controller.tsx +0 -142
- package/src/useDataEnhancementPlugin.tsx +0 -68
- package/src/utils/properties.ts +0 -168
- package/src/utils/values.ts +0 -72
- package/src/vite-env.d.ts +0 -1
|
@@ -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
|
-
});
|