@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.
- package/README.md +6 -2
- package/dist/api.d.ts +19 -0
- package/dist/components/DataEnhancementControllerProvider.d.ts +8 -1
- package/dist/index.es.js +114 -26
- package/dist/index.es.js.map +1 -1
- package/dist/utils/values.d.ts +33 -1
- package/package.json +8 -8
- package/src/api.ts +56 -1
- package/src/components/DataEnhancementControllerProvider.tsx +50 -14
- package/src/components/FormEnhanceAction.tsx +8 -5
- package/src/tests/api.test.ts +101 -2
- package/src/tests/request_agreement.test.ts +240 -0
- package/src/tests/review.test.tsx +211 -8
- package/src/utils/values.ts +63 -3
|
@@ -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
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
});
|
package/src/utils/values.ts
CHANGED
|
@@ -1,12 +1,72 @@
|
|
|
1
|
-
|
|
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 (
|
|
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
|
}
|