@rebasepro/plugin-ai 0.13.0 → 0.13.1-canary.g06dbe5b

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.
@@ -2,13 +2,32 @@ import { TextEncoder, TextDecoder } from "util";
2
2
  Object.assign(global, { TextEncoder,
3
3
  TextDecoder });
4
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
+
5
21
  import React from "react";
6
22
  import { act, renderHook, waitFor } from "@testing-library/react";
7
23
 
24
+ import { AuthControllerContext } from "@rebasepro/app";
25
+
8
26
  import {
9
27
  DataEnhancementControllerProvider,
10
28
  useDataEnhancementController
11
29
  } from "../components/DataEnhancementControllerProvider";
30
+ import { clearAiStatusCache } from "../api";
12
31
 
13
32
  /**
14
33
  * The propose → review → apply contract.
@@ -87,19 +106,26 @@ json: async () => ({ prompts: [] }) });
87
106
  });
88
107
  }
89
108
 
90
- async function mountController(formValues: Record<string, unknown> = {}) {
109
+ async function mountController(formValues: Record<string, unknown> = {}, options: {
110
+ collection?: any,
111
+ getConfigForPath?: (props: any) => boolean,
112
+ user?: any
113
+ } = {}) {
91
114
  const setFieldValue = jest.fn();
92
115
  const formContext = { values: formValues,
93
116
  setFieldValue } as any;
94
117
 
95
118
  const wrapper = ({ children }: { children: React.ReactNode }) => (
96
- <DataEnhancementControllerProvider
97
- path={"products"}
98
- collection={COLLECTION}
99
- formContext={formContext}
100
- {...({} as any)}>
101
- {children}
102
- </DataEnhancementControllerProvider>
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>
103
129
  );
104
130
 
105
131
  const rendered = renderHook(() => useDataEnhancementController(), { wrapper });
@@ -107,8 +133,22 @@ setFieldValue } as any;
107
133
  setFieldValue };
108
134
  }
109
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
+
110
149
  afterEach(() => {
111
150
  jest.restoreAllMocks();
151
+ clearAiStatusCache();
112
152
  });
113
153
 
114
154
  describe("autofill review", () => {
@@ -350,6 +390,28 @@ json: async () => ({ prompts: [] }) });
350
390
  expect(setFieldValue).toHaveBeenCalledWith("stock", 9);
351
391
  });
352
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
+
353
415
  it("drops a date the model returned as unparseable rather than storing NaN", async () => {
354
416
  const collection = {
355
417
  name: "Posts",
@@ -391,3 +453,144 @@ setFieldValue } as any}
391
453
  expect(setFieldValue).not.toHaveBeenCalled();
392
454
  });
393
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,12 +1,72 @@
1
- export function flatMapEntityValues<M extends object>(values: M, path = ""): object {
1
+ import { InputProperty } from "../types/data_enhancement_controller";
2
+
3
+ /**
4
+ * Flatten a record onto the dotted paths the property map uses.
5
+ *
6
+ * The two halves of an autofill request have to be keyed the same way: the
7
+ * service decides a field is empty by looking up `values[key]` for every `key`
8
+ * in `properties`, so a value filed under a key the property map has never
9
+ * heard of is a value the service cannot see.
10
+ *
11
+ * Only plain objects are containers. This used to recurse into anything
12
+ * `typeof value === "object"`, which is both an array and a `Date` — so
13
+ * `tags: ["a", "b"]` was sent as `tags.0`/`tags.1` while the property map still
14
+ * called it `tags`, and a `Date` disappeared entirely (`Object.entries(date)` is
15
+ * `[]`). Both then read as empty on the far side and came back in the review
16
+ * pre-ticked to replace a value the record already had. `getSimplifiedProperties`
17
+ * names an array by its own path and never descends into one, so neither does
18
+ * this.
19
+ */
20
+ export function flatMapEntityValues<M extends object>(values: M, path = ""): Record<string, unknown> {
2
21
  if (!values) return {};
3
22
  return Object.entries(values).flatMap(([key, value]) => {
4
23
  const currentPath = path ? `${path}.${key}` : key;
5
- if (typeof value === "object") {
24
+ if (isPlainObject(value)) {
6
25
  return flatMapEntityValues(value, currentPath);
7
26
  } else {
8
27
  return { [currentPath]: value };
9
28
  }
10
29
  }).reduce((acc, curr) => ({ ...acc,
11
- ...curr }), {})
30
+ ...curr }), {});
31
+ }
32
+
33
+ /**
34
+ * A container, as opposed to a leaf value.
35
+ *
36
+ * Arrays, dates, files and every other class instance are values in their own
37
+ * right — a map property is the only thing whose children are separate fields.
38
+ */
39
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
40
+ if (value === null || typeof value !== "object") return false;
41
+ const proto = Object.getPrototypeOf(value);
42
+ return proto === Object.prototype || proto === null;
43
+ }
44
+
45
+ /**
46
+ * Drop the values of properties the panel will not let anyone edit.
47
+ *
48
+ * A `readOnly` or `disabled` property is already excluded from what the service
49
+ * may fill, but the values map was built from the whole record, and the prompt
50
+ * includes every value it is given as context. So a field marked read-only
51
+ * because a backend hook owns it — an internal note, a customer id — was still
52
+ * being transmitted and pasted into the prompt. The collection config lives
53
+ * here, so this is the honest place to decide it: disabled means neither
54
+ * fillable nor context.
55
+ *
56
+ * Prefixes match too: a disabled map takes its children with it.
57
+ */
58
+ export function omitDisabledValues(
59
+ values: Record<string, unknown>,
60
+ properties: Record<string, InputProperty>
61
+ ): Record<string, unknown> {
62
+ const disabled = Object.entries(properties ?? {})
63
+ // A property map can carry a non-object at a path (see the `oneOf`
64
+ // branch of `getSimplifiedProperty`), and `"disabled" in aString` throws.
65
+ .filter(([, property]) => property && typeof property === "object" && property.disabled)
66
+ .map(([key]) => key);
67
+ if (disabled.length === 0) return values;
68
+ return Object.fromEntries(
69
+ Object.entries(values).filter(([key]) =>
70
+ !disabled.some((prefix) => key === prefix || key.startsWith(`${prefix}.`)))
71
+ );
12
72
  }