@rebasepro/plugin-ai 0.12.1-canary.gf5f1d39 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +62 -18
  2. package/dist/api.d.ts +58 -30
  3. package/dist/components/AutofillReviewDialog.d.ts +16 -0
  4. package/dist/components/DataEnhancementControllerProvider.d.ts +2 -3
  5. package/dist/components/FormEnhanceAction.d.ts +1 -1
  6. package/dist/editor/useEditorAIController.d.ts +11 -2
  7. package/dist/index.es.js +601 -382
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/types/data_enhancement_controller.d.ts +84 -28
  10. package/dist/useDataEnhancementPlugin.d.ts +10 -5
  11. package/package.json +24 -19
  12. package/src/api.ts +241 -174
  13. package/src/components/AutofillReviewDialog.tsx +209 -0
  14. package/src/components/DataEnhancementControllerProvider.tsx +203 -262
  15. package/src/components/FormEnhanceAction.tsx +154 -128
  16. package/src/editor/useEditorAIController.tsx +20 -33
  17. package/src/tests/AutofillReviewDialog.test.tsx +340 -0
  18. package/src/tests/api.test.ts +283 -0
  19. package/src/tests/properties.test.ts +420 -0
  20. package/src/tests/review.test.tsx +393 -0
  21. package/src/tests/useDataEnhancementPlugin.test.tsx +36 -15
  22. package/src/tests/useEditorAIController.test.ts +98 -0
  23. package/src/types/data_enhancement_controller.tsx +98 -31
  24. package/src/useDataEnhancementPlugin.tsx +13 -12
  25. package/dist/utils/diffStrings.d.ts +0 -7
  26. package/dist/utils/strings_counter.d.ts +0 -2
  27. package/dist/utils/suggestions.d.ts +0 -1
  28. package/src/tests/diffStrings.test.ts +0 -128
  29. package/src/tests/strings_counter.test.ts +0 -117
  30. package/src/tests/suggestions.test.ts +0 -53
  31. package/src/utils/diffStrings.ts +0 -70
  32. package/src/utils/strings_counter.ts +0 -22
  33. package/src/utils/suggestions.ts +0 -6
@@ -0,0 +1,340 @@
1
+ import { TextEncoder, TextDecoder } from "util";
2
+ Object.assign(global, { TextEncoder,
3
+ TextDecoder });
4
+
5
+ if (typeof window !== "undefined") {
6
+ Object.defineProperty(window, "matchMedia", {
7
+ writable: true,
8
+ value: jest.fn().mockImplementation(query => ({
9
+ matches: false,
10
+ media: query,
11
+ onchange: null,
12
+ addEventListener: jest.fn(),
13
+ removeEventListener: jest.fn(),
14
+ dispatchEvent: jest.fn()
15
+ }))
16
+ });
17
+ }
18
+
19
+ import React from "react";
20
+ import { act, render, screen, within } from "@testing-library/react";
21
+ import userEvent from "@testing-library/user-event";
22
+
23
+ import {
24
+ DataEnhancementControllerProvider,
25
+ useDataEnhancementController
26
+ } from "../components/DataEnhancementControllerProvider";
27
+ import { AutofillReviewDialog } from "../components/AutofillReviewDialog";
28
+ import { DataEnhancementController } from "../types/data_enhancement_controller";
29
+
30
+ /**
31
+ * The review surface.
32
+ *
33
+ * The controller's behaviour is covered in `review.test.tsx`; this is about
34
+ * what the operator can actually see and press. The two things worth proving
35
+ * here are that a proposal which would overwrite existing content *says so* —
36
+ * a review that hides what it is about to destroy is worse than no review — and
37
+ * that the Apply button's count matches what Apply will really write.
38
+ */
39
+
40
+ jest.mock("@rebasepro/admin", () => ({
41
+ getFieldId: (property: { type?: string }) => (property?.type === "string" ? "text_field" : "number_field")
42
+ }));
43
+
44
+ const COLLECTION = {
45
+ name: "Products",
46
+ singularName: "Product",
47
+ properties: {
48
+ title: { type: "string",
49
+ name: "Title" },
50
+ stock: { type: "number",
51
+ name: "Stock" }
52
+ }
53
+ } as any;
54
+
55
+ function streamingResponse(body: string): any {
56
+ const encoder = new TextEncoder();
57
+ let sent = false;
58
+ return {
59
+ ok: true,
60
+ status: 200,
61
+ body: {
62
+ getReader: () => ({
63
+ read: async () => {
64
+ if (sent) return { done: true,
65
+ value: undefined };
66
+ sent = true;
67
+ return { done: false,
68
+ value: encoder.encode(body) };
69
+ }
70
+ })
71
+ }
72
+ };
73
+ }
74
+
75
+ const FULL_RUN = [
76
+ 'event: suggestion\ndata: {"key":"title","value":"Blue widget"}',
77
+ 'event: suggestion\ndata: {"key":"stock","value":42}',
78
+ 'event: done\ndata: {"suggestions":{"title":"Blue widget","stock":42}}',
79
+ ""
80
+ ].join("\n\n");
81
+
82
+ function mockService(autofillBody: string | (() => any)) {
83
+ (global as any).fetch = jest.fn((url: string) => {
84
+ if (String(url).endsWith("/status")) {
85
+ return Promise.resolve({ ok: true,
86
+ status: 200,
87
+ json: async () => ({ available: true }) });
88
+ }
89
+ if (String(url).endsWith("/autofill")) {
90
+ return Promise.resolve(typeof autofillBody === "string" ? streamingResponse(autofillBody) : autofillBody());
91
+ }
92
+ return Promise.resolve({ ok: true,
93
+ status: 200,
94
+ json: async () => ({ prompts: [] }) });
95
+ });
96
+ }
97
+
98
+ let controller: DataEnhancementController;
99
+
100
+ function Capture() {
101
+ controller = useDataEnhancementController();
102
+ return null;
103
+ }
104
+
105
+ async function mount(formValues: Record<string, unknown> = {}) {
106
+ const setFieldValue = jest.fn();
107
+ const formContext = { values: formValues,
108
+ setFieldValue } as any;
109
+
110
+ render(
111
+ <DataEnhancementControllerProvider
112
+ path={"products"}
113
+ collection={COLLECTION}
114
+ formContext={formContext}
115
+ {...({} as any)}>
116
+ <Capture/>
117
+ <AutofillReviewDialog/>
118
+ </DataEnhancementControllerProvider>
119
+ );
120
+
121
+ // Let the /status probe resolve so the controller reports enabled.
122
+ await act(async () => {
123
+ await Promise.resolve();
124
+ });
125
+
126
+ return { setFieldValue };
127
+ }
128
+
129
+ async function runAutofill(values: Record<string, unknown> = {}) {
130
+ await act(async () => {
131
+ await controller.generate({ values });
132
+ });
133
+ }
134
+
135
+ afterEach(() => {
136
+ jest.restoreAllMocks();
137
+ });
138
+
139
+ describe("AutofillReviewDialog", () => {
140
+
141
+ it("renders nothing until a run has produced something to review", async () => {
142
+ mockService(FULL_RUN);
143
+ await mount();
144
+ expect(screen.queryByText(/Review autofill/i)).toBeNull();
145
+ });
146
+
147
+ it("lists each proposed field with its label and value", async () => {
148
+ mockService(FULL_RUN);
149
+ await mount();
150
+ await runAutofill();
151
+
152
+ expect(screen.getByText("Review autofill")).toBeTruthy();
153
+ expect(screen.getByText("Title")).toBeTruthy();
154
+ expect(screen.getByText("Blue widget")).toBeTruthy();
155
+ expect(screen.getByText("Stock")).toBeTruthy();
156
+ expect(screen.getByText("42")).toBeTruthy();
157
+ });
158
+
159
+ it("says so when a proposal would overwrite something already written", async () => {
160
+ // The property that makes this a review rather than a preview. Hiding
161
+ // what is about to be destroyed is worse than not offering a review.
162
+ mockService(FULL_RUN);
163
+ await mount({ title: "Hand grinder" });
164
+ await runAutofill({ title: "Hand grinder" });
165
+
166
+ expect(screen.getAllByText(/replaces the current value/i).length).toBe(1);
167
+ expect(screen.getByText("Hand grinder")).toBeTruthy();
168
+ });
169
+
170
+ it("does not claim a replacement for a field that was empty", async () => {
171
+ mockService(FULL_RUN);
172
+ await mount({ title: "" });
173
+ await runAutofill({ title: "" });
174
+
175
+ expect(screen.queryByText(/replaces the current value/i)).toBeNull();
176
+ });
177
+
178
+ it("counts only the fields Apply will actually write", async () => {
179
+ mockService(FULL_RUN);
180
+ await mount();
181
+ await runAutofill();
182
+
183
+ expect(screen.getByRole("button", { name: /Apply 2 fields/i })).toBeTruthy();
184
+
185
+ const user = userEvent.setup();
186
+ const rows = screen.getAllByRole("checkbox");
187
+ // The first checkbox is "select all"; the next two are the fields.
188
+ await act(async () => {
189
+ await user.click(rows[rows.length - 1]);
190
+ });
191
+
192
+ expect(screen.getByRole("button", { name: /Apply 1 field/i })).toBeTruthy();
193
+ });
194
+
195
+ it("writes only the ticked fields when Apply is pressed", async () => {
196
+ mockService(FULL_RUN);
197
+ const { setFieldValue } = await mount();
198
+ await runAutofill();
199
+
200
+ const user = userEvent.setup();
201
+ const boxes = screen.getAllByRole("checkbox");
202
+ await act(async () => {
203
+ await user.click(boxes[boxes.length - 1]);
204
+ });
205
+ await act(async () => {
206
+ await user.click(screen.getByRole("button", { name: /Apply 1 field/i }));
207
+ });
208
+
209
+ expect(setFieldValue).toHaveBeenCalledTimes(1);
210
+ expect(setFieldValue).toHaveBeenCalledWith("title", "Blue widget");
211
+ });
212
+
213
+ it("writes nothing when Discard is pressed, and closes", async () => {
214
+ mockService(FULL_RUN);
215
+ const { setFieldValue } = await mount({ title: "Original" });
216
+ await runAutofill({ title: "Original" });
217
+
218
+ const user = userEvent.setup();
219
+ await act(async () => {
220
+ await user.click(screen.getByRole("button", { name: /Discard/i }));
221
+ });
222
+
223
+ expect(setFieldValue).not.toHaveBeenCalled();
224
+ expect(screen.queryByText("Review autofill")).toBeNull();
225
+ });
226
+
227
+ it("disables Apply when nothing is ticked", async () => {
228
+ mockService(FULL_RUN);
229
+ await mount();
230
+ await runAutofill();
231
+
232
+ const user = userEvent.setup();
233
+ await act(async () => {
234
+ await user.click(screen.getAllByRole("checkbox")[0]);
235
+ });
236
+
237
+ const apply = screen.getByRole("button", { name: /Apply 0 fields/i });
238
+ expect(apply.hasAttribute("disabled")).toBe(true);
239
+ });
240
+
241
+ it("shows the instruction back to the operator while they review", async () => {
242
+ mockService(FULL_RUN);
243
+ await mount();
244
+ await act(async () => {
245
+ await controller.generate({ values: {},
246
+ instructions: "A burr grinder for travel" });
247
+ });
248
+
249
+ expect(screen.getByText(/A burr grinder for travel/)).toBeTruthy();
250
+ });
251
+
252
+ it("keeps what arrived when a run fails part-way, and explains", async () => {
253
+ // A run that produced one good field and then broke should still let
254
+ // the operator take the one.
255
+ mockService([
256
+ 'event: suggestion\ndata: {"key":"title","value":"Blue widget"}',
257
+ 'event: error\ndata: {"message":"quota exhausted"}',
258
+ ""
259
+ ].join("\n\n"));
260
+ await mount();
261
+ await runAutofill();
262
+
263
+ expect(screen.getByText(/quota exhausted/)).toBeTruthy();
264
+ expect(screen.getByText("Blue widget")).toBeTruthy();
265
+ expect(screen.getByRole("button", { name: /Apply 1 field/i })).toBeTruthy();
266
+ });
267
+
268
+ it("explains an empty result rather than showing a blank dialog", async () => {
269
+ mockService('event: done\ndata: {"suggestions":{}}\n\n');
270
+ await mount();
271
+ await runAutofill();
272
+
273
+ expect(screen.getByText(/Nothing to fill in/i)).toBeTruthy();
274
+ expect(screen.getByRole("button", { name: /Apply 0 fields/i })).toBeTruthy();
275
+ });
276
+
277
+ it("offers select-all only when there is more than one field", async () => {
278
+ mockService('event: suggestion\ndata: {"key":"title","value":"Only one"}\n\nevent: done\ndata: {"suggestions":{"title":"Only one"}}\n\n');
279
+ await mount();
280
+ await runAutofill();
281
+
282
+ expect(screen.queryByText(/Select all|Deselect all/i)).toBeNull();
283
+ expect(screen.getAllByRole("checkbox").length).toBe(1);
284
+ });
285
+
286
+ it("toggles every row at once", async () => {
287
+ mockService(FULL_RUN);
288
+ await mount();
289
+ await runAutofill();
290
+
291
+ const user = userEvent.setup();
292
+ await act(async () => {
293
+ await user.click(screen.getByText(/Deselect all/i));
294
+ });
295
+
296
+ expect(screen.getByRole("button", { name: /Apply 0 fields/i })).toBeTruthy();
297
+ expect(screen.getByText(/Select all/i)).toBeTruthy();
298
+ });
299
+
300
+ it("renders a date proposal readably rather than as an ISO string", async () => {
301
+ const collection = {
302
+ name: "Posts",
303
+ singularName: "Post",
304
+ properties: { publishedAt: { type: "date",
305
+ name: "Published at" } }
306
+ } as any;
307
+
308
+ (global as any).fetch = jest.fn((url: string) => {
309
+ if (String(url).endsWith("/status")) return Promise.resolve({ ok: true,
310
+ status: 200,
311
+ json: async () => ({ available: true }) });
312
+ return Promise.resolve(streamingResponse(
313
+ 'event: suggestion\ndata: {"key":"publishedAt","value":"2026-08-03T10:00:00.000Z"}\n\nevent: done\ndata: {"suggestions":{}}\n\n'
314
+ ));
315
+ });
316
+
317
+ const setFieldValue = jest.fn();
318
+ render(
319
+ <DataEnhancementControllerProvider
320
+ path={"posts"}
321
+ collection={collection}
322
+ formContext={{ values: {},
323
+ setFieldValue } as any}
324
+ {...({} as any)}>
325
+ <Capture/>
326
+ <AutofillReviewDialog/>
327
+ </DataEnhancementControllerProvider>
328
+ );
329
+ await act(async () => {
330
+ await Promise.resolve();
331
+ });
332
+ await runAutofill();
333
+
334
+ // Not the raw ISO string — the row shows a locale-formatted date, which
335
+ // is only possible because the controller coerced it to a Date first.
336
+ expect(screen.queryByText("2026-08-03T10:00:00.000Z")).toBeNull();
337
+ const row = screen.getByText("Published at").closest("label[class*=\"flex\"]");
338
+ expect(within(row as HTMLElement).getByText(/2026/)).toBeTruthy();
339
+ });
340
+ });
@@ -0,0 +1,283 @@
1
+ import { TextEncoder, TextDecoder } from "util";
2
+ Object.assign(global, { TextEncoder,
3
+ TextDecoder });
4
+
5
+ import {
6
+ DEFAULT_AI_ENDPOINT,
7
+ autocompleteStream,
8
+ autofillStream,
9
+ fetchAiStatus,
10
+ fetchPromptSuggestions
11
+ } from "../api";
12
+ import { AutofillRequest } from "../types/data_enhancement_controller";
13
+
14
+ /**
15
+ * The transport.
16
+ *
17
+ * This exists because of what it replaced. The old client split each network
18
+ * chunk on the literal `"&$# "` and `JSON.parse`d the pieces — so a delimiter
19
+ * landing across two reads corrupted the parse, and reads land wherever the
20
+ * network puts them. The central test below therefore re-delivers the same
21
+ * response one byte at a time and asserts the result is identical to
22
+ * delivering it whole. Anything that only ever feeds a complete body would
23
+ * have passed against the old code too.
24
+ */
25
+
26
+ /** A `Response` whose body yields exactly the chunks given, in order. */
27
+ function streamingResponse(chunks: string[], ok = true): any {
28
+ const encoder = new TextEncoder();
29
+ let i = 0;
30
+ return {
31
+ ok,
32
+ status: ok ? 200 : 500,
33
+ body: {
34
+ getReader: () => ({
35
+ read: async () =>
36
+ i < chunks.length
37
+ ? { done: false,
38
+ value: encoder.encode(chunks[i++]) }
39
+ : { done: true,
40
+ value: undefined }
41
+ })
42
+ }
43
+ };
44
+ }
45
+
46
+ function jsonResponse(body: unknown, ok = true, status = 200): any {
47
+ return { ok,
48
+ status,
49
+ json: async () => body };
50
+ }
51
+
52
+ /** Split a string into fixed-size pieces, to force boundaries anywhere. */
53
+ function chunked(body: string, size: number): string[] {
54
+ const out: string[] = [];
55
+ for (let i = 0; i < body.length; i += size) out.push(body.slice(i, i + size));
56
+ return out;
57
+ }
58
+
59
+ const REQUEST: AutofillRequest = {
60
+ entityName: "Product",
61
+ values: {},
62
+ properties: { title: { type: "string",
63
+ fieldConfigId: "text_field" } }
64
+ };
65
+
66
+ const BODY = [
67
+ "event: suggestion_delta",
68
+ 'data: {"key":"title","text":"Blue "}',
69
+ "",
70
+ "event: suggestion_delta",
71
+ 'data: {"key":"title","text":"widget"}',
72
+ "",
73
+ "event: suggestion",
74
+ 'data: {"key":"title","value":"Blue widget"}',
75
+ "",
76
+ "event: suggestion",
77
+ 'data: {"key":"stock","value":42}',
78
+ "",
79
+ "event: done",
80
+ 'data: {"suggestions":{"title":"Blue widget","stock":42},"usage":{"outputTokens":9}}',
81
+ "",
82
+ ""
83
+ ].join("\n");
84
+
85
+ async function runAutofill(chunks: string[]) {
86
+ (global as any).fetch = jest.fn().mockResolvedValue(streamingResponse(chunks));
87
+ const deltas: [string, string][] = [];
88
+ const values: [string, unknown][] = [];
89
+ const result = await autofillStream({
90
+ request: REQUEST,
91
+ onDelta: (k, t) => deltas.push([k, t]),
92
+ onValue: (k, v) => values.push([k, v])
93
+ });
94
+ return { deltas,
95
+ values,
96
+ result };
97
+ }
98
+
99
+ afterEach(() => {
100
+ jest.restoreAllMocks();
101
+ });
102
+
103
+ describe("autofillStream", () => {
104
+ it("reads deltas, values and the final result", async () => {
105
+ const { deltas, values, result } = await runAutofill([BODY]);
106
+ expect(deltas).toEqual([
107
+ ["title", "Blue "],
108
+ ["title", "widget"]
109
+ ]);
110
+ expect(values).toEqual([
111
+ ["title", "Blue widget"],
112
+ ["stock", 42]
113
+ ]);
114
+ expect(result.suggestions).toEqual({ title: "Blue widget",
115
+ stock: 42 });
116
+ expect(result.usage).toEqual({ outputTokens: 9 });
117
+ });
118
+
119
+ it("produces identical results at every chunk boundary", async () => {
120
+ const whole = await runAutofill([BODY]);
121
+ for (let size = 1; size <= BODY.length; size++) {
122
+ const split = await runAutofill(chunked(BODY, size));
123
+ expect(split.deltas).toEqual(whole.deltas);
124
+ expect(split.values).toEqual(whole.values);
125
+ expect(split.result).toEqual(whole.result);
126
+ }
127
+ });
128
+
129
+ it("handles CRLF record separators", async () => {
130
+ // Proxies rewrite line endings, and a four-character separator sliced as
131
+ // if it were two leaves a stray newline that eats the next `event:`.
132
+ const { values } = await runAutofill([BODY.replace(/\n/g, "\r\n")]);
133
+ expect(values).toEqual([
134
+ ["title", "Blue widget"],
135
+ ["stock", 42]
136
+ ]);
137
+ });
138
+
139
+ it("ignores keep-alive comments", async () => {
140
+ const withComments = ":keep-alive\n\n" + BODY;
141
+ const { result } = await runAutofill([withComments]);
142
+ expect(result.suggestions).toEqual({ title: "Blue widget",
143
+ stock: 42 });
144
+ });
145
+
146
+ it("joins a multi-line data field", async () => {
147
+ const body = 'event: suggestion\ndata: {"key":"body",\ndata: "value":"two lines"}\n\n';
148
+ const { values } = await runAutofill([body]);
149
+ expect(values).toEqual([["body", "two lines"]]);
150
+ });
151
+
152
+ it("keeps going past one malformed record", async () => {
153
+ // A single bad frame must not discard fields that arrived correctly.
154
+ const body = "event: suggestion\ndata: {not json\n\n" + BODY;
155
+ const { values } = await runAutofill([body]);
156
+ expect(values).toEqual([
157
+ ["title", "Blue widget"],
158
+ ["stock", 42]
159
+ ]);
160
+ });
161
+
162
+ it("throws the message carried on an error event", async () => {
163
+ (global as any).fetch = jest.fn().mockResolvedValue(
164
+ streamingResponse(['event: error\ndata: {"message":"quota exhausted"}\n\n'])
165
+ );
166
+ await expect(
167
+ autofillStream({ request: REQUEST,
168
+ onDelta: () => undefined,
169
+ onValue: () => undefined })
170
+ ).rejects.toThrow("quota exhausted");
171
+ });
172
+
173
+ it("surfaces the server's error envelope on a non-2xx", async () => {
174
+ // `{ error: { message } }` is the control plane's contract. Reading it
175
+ // is the difference between telling the operator the quota reset time
176
+ // and telling them "Request failed with status 429".
177
+ (global as any).fetch = jest.fn().mockResolvedValue(
178
+ jsonResponse({ error: { message: "The free AI quota for today has been used up.",
179
+ code: "upstream_error" } }, false, 429)
180
+ );
181
+ await expect(
182
+ autofillStream({ request: REQUEST,
183
+ onDelta: () => undefined,
184
+ onValue: () => undefined })
185
+ ).rejects.toThrow(/quota for today/);
186
+ });
187
+
188
+ it("sends no credentials of any kind", async () => {
189
+ // The FireCMS-era client sent the tenant's JWT as `Authorization: Basic`
190
+ // plus a hardcoded `fcms-…` key. No external service could verify the
191
+ // former, and the latter shipped in the published package.
192
+ const fetchMock = jest.fn().mockResolvedValue(streamingResponse([BODY]));
193
+ (global as any).fetch = fetchMock;
194
+ await autofillStream({ request: REQUEST,
195
+ onDelta: () => undefined,
196
+ onValue: () => undefined });
197
+ const [, init] = fetchMock.mock.calls[0];
198
+ expect(init.headers).toEqual({ "Content-Type": "application/json" });
199
+ expect(JSON.stringify(init)).not.toMatch(/fcms-|Bearer|Basic/);
200
+ });
201
+
202
+ it("posts to the hosted endpoint by default and to an override when given", async () => {
203
+ const fetchMock = jest.fn().mockResolvedValue(streamingResponse([BODY]));
204
+ (global as any).fetch = fetchMock;
205
+
206
+ await autofillStream({ request: REQUEST,
207
+ onDelta: () => undefined,
208
+ onValue: () => undefined });
209
+ expect(fetchMock.mock.calls[0][0]).toBe(`${DEFAULT_AI_ENDPOINT}/autofill`);
210
+
211
+ await autofillStream({
212
+ request: REQUEST,
213
+ endpoint: "https://ai.example.com/",
214
+ onDelta: () => undefined,
215
+ onValue: () => undefined
216
+ });
217
+ // Trailing slash trimmed — otherwise the override 404s on `//autofill`.
218
+ expect(fetchMock.mock.calls[1][0]).toBe("https://ai.example.com/autofill");
219
+ });
220
+ });
221
+
222
+ describe("autocompleteStream", () => {
223
+ it("concatenates deltas and returns the full continuation", async () => {
224
+ const body = [
225
+ 'event: delta\ndata: {"text":"the quick "}',
226
+ 'event: delta\ndata: {"text":"brown fox"}',
227
+ "event: done\ndata: {}",
228
+ ""
229
+ ].join("\n\n");
230
+ (global as any).fetch = jest.fn().mockResolvedValue(streamingResponse(chunked(body, 7)));
231
+
232
+ const seen: string[] = [];
233
+ const text = await autocompleteStream({
234
+ textBefore: "I saw ",
235
+ textAfter: "",
236
+ onDelta: (t) => seen.push(t)
237
+ });
238
+
239
+ expect(seen.join("")).toBe("the quick brown fox");
240
+ expect(text).toBe("the quick brown fox");
241
+ });
242
+ });
243
+
244
+ describe("fetchAiStatus", () => {
245
+ it("reports available when the service says so", async () => {
246
+ (global as any).fetch = jest.fn().mockResolvedValue(
247
+ jsonResponse({ available: true,
248
+ model: "claude-opus-5",
249
+ features: ["autofill"] })
250
+ );
251
+ await expect(fetchAiStatus({})).resolves.toEqual({
252
+ available: true,
253
+ model: "claude-opus-5",
254
+ features: ["autofill"]
255
+ });
256
+ });
257
+
258
+ it("reports unavailable rather than throwing when the service errors", async () => {
259
+ // This value decides whether a button renders. Any doubt must resolve to
260
+ // "no button" — that is the whole fix for the 404-on-click failure.
261
+ (global as any).fetch = jest.fn().mockResolvedValue(jsonResponse({}, false, 503));
262
+ await expect(fetchAiStatus({})).resolves.toEqual({ available: false });
263
+ });
264
+ });
265
+
266
+ describe("fetchPromptSuggestions", () => {
267
+ it("maps the service's prompts", async () => {
268
+ (global as any).fetch = jest.fn().mockResolvedValue(jsonResponse({ prompts: ["A blue widget", "A red one"] }));
269
+ await expect(fetchPromptSuggestions({ entityName: "Product" })).resolves.toEqual({
270
+ prompts: [
271
+ { prompt: "A blue widget",
272
+ type: "sample" },
273
+ { prompt: "A red one",
274
+ type: "sample" }
275
+ ]
276
+ });
277
+ });
278
+
279
+ it("degrades to no suggestions instead of failing the menu", async () => {
280
+ (global as any).fetch = jest.fn().mockRejectedValue(new Error("offline"));
281
+ await expect(fetchPromptSuggestions({ entityName: "Product" })).resolves.toEqual({ prompts: [] });
282
+ });
283
+ });