@terreno/blocks 57.10.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 +21 -0
- package/README.md +62 -0
- package/biome.jsonc +18 -0
- package/bunfig.toml +6 -0
- package/dist/asks/errors.d.ts +77 -0
- package/dist/asks/errors.js +364 -0
- package/dist/asks/files.d.ts +53 -0
- package/dist/asks/files.js +171 -0
- package/dist/asks/formValues.d.ts +32 -0
- package/dist/asks/formValues.js +229 -0
- package/dist/asks/headless.d.ts +227 -0
- package/dist/asks/headless.js +94 -0
- package/dist/asks/jsonSchema.d.ts +6 -0
- package/dist/asks/jsonSchema.js +62 -0
- package/dist/asks/limits.d.ts +58 -0
- package/dist/asks/limits.js +61 -0
- package/dist/asks/prompt.d.ts +10 -0
- package/dist/asks/prompt.js +144 -0
- package/dist/asks/schema.d.ts +826 -0
- package/dist/asks/schema.js +864 -0
- package/dist/asks/simpleCard.d.ts +85 -0
- package/dist/asks/simpleCard.js +279 -0
- package/dist/asks/validateInput.d.ts +13 -0
- package/dist/asks/validateInput.js +19 -0
- package/dist/asks/validateResponse.d.ts +13 -0
- package/dist/asks/validateResponse.js +278 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +58 -0
- package/dist/errors.d.ts +51 -0
- package/dist/errors.js +106 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +111 -0
- package/dist/jsonSchema.d.ts +5 -0
- package/dist/jsonSchema.js +12 -0
- package/dist/limits.d.ts +27 -0
- package/dist/limits.js +30 -0
- package/dist/lint.d.ts +21 -0
- package/dist/lint.js +388 -0
- package/dist/parse.d.ts +16 -0
- package/dist/parse.js +95 -0
- package/dist/parsePartial.d.ts +12 -0
- package/dist/parsePartial.js +105 -0
- package/dist/prompt.d.ts +9 -0
- package/dist/prompt.js +95 -0
- package/dist/schema.d.ts +199 -0
- package/dist/schema.js +277 -0
- package/dist/validate.d.ts +17 -0
- package/dist/validate.js +331 -0
- package/package.json +60 -0
- package/schemas/pendingAsksResponse.schema.json +195 -0
- package/schemas/simpleCard.schema.json +135 -0
- package/schemas/turnRequest.schema.json +108 -0
- package/schemas/turnResponse.schema.json +204 -0
- package/scripts/writeJsonSchemas.ts +11 -0
- package/src/asks/docParity.test.ts +65 -0
- package/src/asks/errors.test.ts +250 -0
- package/src/asks/errors.ts +449 -0
- package/src/asks/files.test.ts +294 -0
- package/src/asks/files.ts +216 -0
- package/src/asks/fixtures/invalid/choice-blank-text.json +20 -0
- package/src/asks/fixtures/invalid/choice-default-not-in-options.json +13 -0
- package/src/asks/fixtures/invalid/choice-default-selects-two.json +16 -0
- package/src/asks/fixtures/invalid/choice-description-too-long.json +24 -0
- package/src/asks/fixtures/invalid/choice-duplicate-ids.json +17 -0
- package/src/asks/fixtures/invalid/choice-label-too-long.json +23 -0
- package/src/asks/fixtures/invalid/choice-many-bounds-not-whole.json +17 -0
- package/src/asks/fixtures/invalid/choice-many-bounds-out-of-range.json +17 -0
- package/src/asks/fixtures/invalid/choice-many-default-duplicate.json +17 -0
- package/src/asks/fixtures/invalid/choice-many-default-too-many.json +15 -0
- package/src/asks/fixtures/invalid/choice-many-max-above-options.json +14 -0
- package/src/asks/fixtures/invalid/choice-many-min-above-choices.json +13 -0
- package/src/asks/fixtures/invalid/choice-many-min-above-max.json +15 -0
- package/src/asks/fixtures/invalid/choice-missing-required.json +14 -0
- package/src/asks/fixtures/invalid/choice-mixed-errors.json +19 -0
- package/src/asks/fixtures/invalid/choice-not-an-object.json +5 -0
- package/src/asks/fixtures/invalid/choice-one-with-many-fields.json +19 -0
- package/src/asks/fixtures/invalid/choice-option-id-format.json +35 -0
- package/src/asks/fixtures/invalid/choice-options-not-objects.json +12 -0
- package/src/asks/fixtures/invalid/choice-other-label-too-long.json +25 -0
- package/src/asks/fixtures/invalid/choice-other-label-without-allow-other.json +14 -0
- package/src/asks/fixtures/invalid/choice-prompt-empty.json +12 -0
- package/src/asks/fixtures/invalid/choice-prompt-too-long.json +23 -0
- package/src/asks/fixtures/invalid/choice-select-unknown.json +12 -0
- package/src/asks/fixtures/invalid/choice-submit-label-too-long.json +13 -0
- package/src/asks/fixtures/invalid/choice-title-too-long.json +24 -0
- package/src/asks/fixtures/invalid/choice-too-few-options.json +9 -0
- package/src/asks/fixtures/invalid/choice-too-many-options.json +219 -0
- package/src/asks/fixtures/invalid/choice-unknown-key-option.json +15 -0
- package/src/asks/fixtures/invalid/choice-unknown-key-root.json +13 -0
- package/src/asks/fixtures/invalid/choice-wrong-types.json +16 -0
- package/src/asks/fixtures/invalid/confirm-emoji-label-too-long.json +13 -0
- package/src/asks/fixtures/invalid/confirm-label-too-long.json +14 -0
- package/src/asks/fixtures/invalid/confirm-labels-alike-after-trim.json +14 -0
- package/src/asks/fixtures/invalid/confirm-labels-alike.json +13 -0
- package/src/asks/fixtures/invalid/confirm-missing-prompt-blank-label.json +16 -0
- package/src/asks/fixtures/invalid/confirm-unknown-keys.json +23 -0
- package/src/asks/fixtures/invalid/confirm-wrong-types.json +18 -0
- package/src/asks/fixtures/invalid/files-accept-duplicate.json +13 -0
- package/src/asks/fixtures/invalid/files-accept-empty.json +13 -0
- package/src/asks/fixtures/invalid/files-accept-unknown.json +13 -0
- package/src/asks/fixtures/invalid/files-counts-out-of-range.json +19 -0
- package/src/asks/fixtures/invalid/files-min-above-max.json +15 -0
- package/src/asks/fixtures/invalid/files-missing-accept-unknown-key.json +17 -0
- package/src/asks/fixtures/invalid/form-default-bad-format.json +40 -0
- package/src/asks/fixtures/invalid/form-default-invalid-date.json +40 -0
- package/src/asks/fixtures/invalid/form-default-not-in-options.json +58 -0
- package/src/asks/fixtures/invalid/form-default-out-of-range.json +33 -0
- package/src/asks/fixtures/invalid/form-default-text-bounds.json +42 -0
- package/src/asks/fixtures/invalid/form-default-wrong-type.json +30 -0
- package/src/asks/fixtures/invalid/form-duplicate-field-ids.json +24 -0
- package/src/asks/fixtures/invalid/form-field-id-format.json +19 -0
- package/src/asks/fixtures/invalid/form-field-text-limits.json +33 -0
- package/src/asks/fixtures/invalid/form-keys-of-another-type.json +39 -0
- package/src/asks/fixtures/invalid/form-length-above-cap.json +30 -0
- package/src/asks/fixtures/invalid/form-min-above-max.json +32 -0
- package/src/asks/fixtures/invalid/form-no-fields.json +13 -0
- package/src/asks/fixtures/invalid/form-select-options.json +44 -0
- package/src/asks/fixtures/invalid/form-too-many-fields.json +59 -0
- package/src/asks/fixtures/invalid/form-unknown-field-type.json +27 -0
- package/src/asks/fixtures/invalid/markdown-bounds-out-of-range.json +18 -0
- package/src/asks/fixtures/invalid/markdown-initial-above-cap.json +13 -0
- package/src/asks/fixtures/invalid/markdown-max-above-cap.json +13 -0
- package/src/asks/fixtures/invalid/markdown-min-above-cap.json +13 -0
- package/src/asks/fixtures/invalid/markdown-min-above-max.json +14 -0
- package/src/asks/fixtures/invalid/markdown-placeholder-too-long.json +13 -0
- package/src/asks/fixtures/invalid/markdown-wrong-types-unknown-key.json +27 -0
- package/src/asks/fixtures/valid/choice-emoji-label-cut.json +32 -0
- package/src/asks/fixtures/valid/choice-labels-collide-after-cut.json +21 -0
- package/src/asks/fixtures/valid/choice-long-text-cut.json +41 -0
- package/src/asks/fixtures/valid/choice-many-default-below-min.json +24 -0
- package/src/asks/fixtures/valid/choice-many-every-choice-with-other.json +24 -0
- package/src/asks/fixtures/valid/choice-many-no-default.json +21 -0
- package/src/asks/fixtures/valid/choice-many-optional-no-buttons.json +20 -0
- package/src/asks/fixtures/valid/choice-many-options-default-label-collides.json +23 -0
- package/src/asks/fixtures/valid/choice-many-options-no-buttons.json +21 -0
- package/src/asks/fixtures/valid/choice-many-options-no-default.json +22 -0
- package/src/asks/fixtures/valid/choice-many-options-with-default.json +30 -0
- package/src/asks/fixtures/valid/choice-many-required-with-default.json +29 -0
- package/src/asks/fixtures/valid/choice-many-single-pick-or-other.json +29 -0
- package/src/asks/fixtures/valid/choice-many-toppings-with-default.json +36 -0
- package/src/asks/fixtures/valid/choice-max-limits.json +245 -0
- package/src/asks/fixtures/valid/choice-plan-with-default.json +42 -0
- package/src/asks/fixtures/valid/choice-two-options-no-decline.json +32 -0
- package/src/asks/fixtures/valid/choice-two-options.json +32 -0
- package/src/asks/fixtures/valid/confirm-allow-decline.json +40 -0
- package/src/asks/fixtures/valid/confirm-default-labels.json +36 -0
- package/src/asks/fixtures/valid/confirm-destructive-delete.json +41 -0
- package/src/asks/fixtures/valid/confirm-emoji-labels.json +38 -0
- package/src/asks/fixtures/valid/confirm-long-text-cut.json +40 -0
- package/src/asks/fixtures/valid/files-every-type.json +28 -0
- package/src/asks/fixtures/valid/files-one-csv-no-decline.json +16 -0
- package/src/asks/fixtures/valid/files-receipt-photos.json +24 -0
- package/src/asks/fixtures/valid/form-boolean-newsletter.json +30 -0
- package/src/asks/fixtures/valid/form-date-due.json +44 -0
- package/src/asks/fixtures/valid/form-datetime-meeting.json +44 -0
- package/src/asks/fixtures/valid/form-datetime-minutes-offset.json +44 -0
- package/src/asks/fixtures/valid/form-eight-fields.json +71 -0
- package/src/asks/fixtures/valid/form-email-contact.json +44 -0
- package/src/asks/fixtures/valid/form-invoice-details.json +68 -0
- package/src/asks/fixtures/valid/form-multiselect-channels.json +64 -0
- package/src/asks/fixtures/valid/form-number-budget.json +45 -0
- package/src/asks/fixtures/valid/form-phone-callback.json +44 -0
- package/src/asks/fixtures/valid/form-select-shipping.json +58 -0
- package/src/asks/fixtures/valid/form-text-required-default.json +47 -0
- package/src/asks/fixtures/valid/form-textarea-notes.json +23 -0
- package/src/asks/fixtures/valid/form-time-reminder.json +44 -0
- package/src/asks/fixtures/valid/form-url-website.json +30 -0
- package/src/asks/fixtures/valid/markdown-announcement-draft.json +39 -0
- package/src/asks/fixtures/valid/markdown-empty-draft.json +34 -0
- package/src/asks/fixtures/valid/markdown-initial-blank-below-min.json +24 -0
- package/src/asks/fixtures/valid/markdown-initial-over-max.json +24 -0
- package/src/asks/fixtures/valid/markdown-long-text-cut.json +37 -0
- package/src/asks/fixtures/valid/markdown-no-decline.json +28 -0
- package/src/asks/fixtures/valid/markdown-no-initial-with-min.json +25 -0
- package/src/asks/formValues.ts +320 -0
- package/src/asks/headless.test.ts +146 -0
- package/src/asks/headless.ts +130 -0
- package/src/asks/jsonSchema.test.ts +171 -0
- package/src/asks/jsonSchema.ts +89 -0
- package/src/asks/limits.ts +58 -0
- package/src/asks/prompt.test.ts +200 -0
- package/src/asks/prompt.ts +173 -0
- package/src/asks/schema.test.ts +198 -0
- package/src/asks/schema.ts +1203 -0
- package/src/asks/simpleCard.test.ts +998 -0
- package/src/asks/simpleCard.ts +341 -0
- package/src/asks/validateInput.test.ts +875 -0
- package/src/asks/validateInput.ts +24 -0
- package/src/asks/validateResponse.test.ts +740 -0
- package/src/asks/validateResponse.ts +341 -0
- package/src/blocksDocParity.test.ts +21 -0
- package/src/cli.test.ts +134 -0
- package/src/cli.ts +65 -0
- package/src/display.test.ts +68 -0
- package/src/errors.test.ts +29 -0
- package/src/errors.ts +125 -0
- package/src/fixtures/invalid/column-not-found.yaml +14 -0
- package/src/fixtures/invalid/column-type.yaml +14 -0
- package/src/fixtures/invalid/dataset-not-found.yaml +7 -0
- package/src/fixtures/invalid/dataset-too-large.yaml +20 -0
- package/src/fixtures/invalid/depth-exceeded.yaml +10 -0
- package/src/fixtures/invalid/duplicate-id.yaml +6 -0
- package/src/fixtures/invalid/invalid-enum.yaml +5 -0
- package/src/fixtures/invalid/key-order.yaml +4 -0
- package/src/fixtures/invalid/missing-required.yaml +3 -0
- package/src/fixtures/invalid/not-a-document.txt +1 -0
- package/src/fixtures/invalid/row-arity.yaml +11 -0
- package/src/fixtures/invalid/select-target.yaml +12 -0
- package/src/fixtures/invalid/table-too-wide.yaml +9 -0
- package/src/fixtures/invalid/too-many-points.yaml +8 -0
- package/src/fixtures/invalid/unknown-key.yaml +5 -0
- package/src/fixtures/invalid/unsupported-version.yaml +4 -0
- package/src/fixtures/invalid/yaml-anchor.yaml +6 -0
- package/src/fixtures/partial/cut-after-divider.yaml +5 -0
- package/src/fixtures/valid/actions.yaml +32 -0
- package/src/fixtures/valid/chart.yaml +20 -0
- package/src/fixtures/valid/layout.yaml +26 -0
- package/src/fixtures/valid/prose.yaml +4 -0
- package/src/html.test.ts +52 -0
- package/src/index.test.ts +114 -0
- package/src/index.ts +175 -0
- package/src/jsonSchema.test.ts +33 -0
- package/src/jsonSchema.ts +11 -0
- package/src/limits.ts +27 -0
- package/src/lint.test.ts +289 -0
- package/src/lint.ts +484 -0
- package/src/parse.test.ts +281 -0
- package/src/parse.ts +104 -0
- package/src/parsePartial.test.ts +53 -0
- package/src/parsePartial.ts +116 -0
- package/src/prompt.test.ts +24 -0
- package/src/prompt.ts +104 -0
- package/src/schema.ts +509 -0
- package/src/tests/askFixtures.ts +40 -0
- package/src/validate.perf.test.ts +43 -0
- package/src/validate.ts +369 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import {describe, expect, it} from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
checkAskFileBytes,
|
|
4
|
+
fileNotOwnedError,
|
|
5
|
+
isTextFileMimeType,
|
|
6
|
+
parseAskDataUrl,
|
|
7
|
+
sniffFileBytes,
|
|
8
|
+
} from "./files";
|
|
9
|
+
import type {FilesAskInput} from "./schema";
|
|
10
|
+
import {validateAskResponse} from "./validateResponse";
|
|
11
|
+
|
|
12
|
+
const RECEIPT_INPUT: FilesAskInput = {
|
|
13
|
+
accept: ["image", "pdf"],
|
|
14
|
+
maxFiles: 3,
|
|
15
|
+
prompt: "Upload a photo of the receipt.",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const MB = 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
const bytesOf = (...values: (number | string)[]): Uint8Array =>
|
|
21
|
+
new Uint8Array(
|
|
22
|
+
values.flatMap((value) =>
|
|
23
|
+
typeof value === "string" ? [...Buffer.from(value, "utf8")] : [value]
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const PNG = bytesOf(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, "IHDR");
|
|
28
|
+
const JPEG = bytesOf(0xff, 0xd8, 0xff, 0xe0, 0, 16, "JFIF");
|
|
29
|
+
const GIF = bytesOf("GIF89a", 1, 0, 1, 0);
|
|
30
|
+
const WEBP = bytesOf("RIFF", 36, 0, 0, 0, "WEBPVP8 ");
|
|
31
|
+
const PDF = bytesOf("%PDF-1.7\n%âãÏÓ\n");
|
|
32
|
+
|
|
33
|
+
const dataUrl = (mimeType: string, bytes: Uint8Array): string =>
|
|
34
|
+
`data:${mimeType};base64,${Buffer.from(bytes).toString("base64")}`;
|
|
35
|
+
|
|
36
|
+
const check = (files: unknown[], input: FilesAskInput = RECEIPT_INPUT, maxFileSizeBytes?: number) =>
|
|
37
|
+
validateAskResponse({
|
|
38
|
+
input,
|
|
39
|
+
kind: "files",
|
|
40
|
+
maxFileSizeBytes,
|
|
41
|
+
response: {action: "accept", content: {files}},
|
|
42
|
+
}).map(({code, path}) => ({code, path}));
|
|
43
|
+
|
|
44
|
+
const uploaded = {
|
|
45
|
+
fileId: "6710c2a4f1",
|
|
46
|
+
filename: "receipt.jpg",
|
|
47
|
+
mimeType: "image/jpeg",
|
|
48
|
+
size: 482113,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("validateAskResponse for files", () => {
|
|
52
|
+
it("accepts uploaded files and data URLs within the count, types, and cap", () => {
|
|
53
|
+
expect(check([uploaded])).toEqual([]);
|
|
54
|
+
expect(
|
|
55
|
+
check([
|
|
56
|
+
uploaded,
|
|
57
|
+
{
|
|
58
|
+
filename: "scan.pdf",
|
|
59
|
+
mimeType: "application/pdf",
|
|
60
|
+
size: PDF.length,
|
|
61
|
+
url: dataUrl("application/pdf", PDF),
|
|
62
|
+
},
|
|
63
|
+
])
|
|
64
|
+
).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("accepts decline by default and rejects it when allowDecline is false", () => {
|
|
68
|
+
expect(
|
|
69
|
+
validateAskResponse({input: RECEIPT_INPUT, kind: "files", response: {action: "decline"}})
|
|
70
|
+
).toEqual([]);
|
|
71
|
+
expect(
|
|
72
|
+
validateAskResponse({
|
|
73
|
+
input: {...RECEIPT_INPUT, allowDecline: false},
|
|
74
|
+
kind: "files",
|
|
75
|
+
response: {action: "decline"},
|
|
76
|
+
}).map((error) => error.code)
|
|
77
|
+
).toEqual(["DECLINE_NOT_ALLOWED"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("FILE_COUNT for fewer than minFiles or more than maxFiles", () => {
|
|
81
|
+
expect(check([])).toEqual([{code: "FILE_COUNT", path: "content.files"}]);
|
|
82
|
+
expect(check([uploaded, uploaded, uploaded, uploaded])).toEqual([
|
|
83
|
+
{code: "FILE_COUNT", path: "content.files"},
|
|
84
|
+
]);
|
|
85
|
+
expect(
|
|
86
|
+
validateAskResponse({
|
|
87
|
+
input: {...RECEIPT_INPUT, maxFiles: 2, minFiles: 2},
|
|
88
|
+
kind: "files",
|
|
89
|
+
response: {action: "accept", content: {files: [uploaded]}},
|
|
90
|
+
})
|
|
91
|
+
).toEqual([
|
|
92
|
+
{
|
|
93
|
+
code: "FILE_COUNT",
|
|
94
|
+
fix: "Send exactly 2 files in content.files.",
|
|
95
|
+
message: "This ask takes exactly 2 files; the answer has 1.",
|
|
96
|
+
path: "content.files",
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("FILE_TYPE_NOT_ACCEPTED for a declared type the accept list does not allow", () => {
|
|
102
|
+
expect(
|
|
103
|
+
validateAskResponse({
|
|
104
|
+
input: RECEIPT_INPUT,
|
|
105
|
+
kind: "files",
|
|
106
|
+
response: {
|
|
107
|
+
action: "accept",
|
|
108
|
+
content: {files: [{...uploaded, filename: "notes.txt", mimeType: "text/plain"}]},
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
).toEqual([
|
|
112
|
+
{
|
|
113
|
+
code: "FILE_TYPE_NOT_ACCEPTED",
|
|
114
|
+
fix: "Send a file of type image/jpeg, image/png, image/gif, image/webp, application/pdf.",
|
|
115
|
+
message: '"notes.txt" is text/plain, which this ask does not accept.',
|
|
116
|
+
path: "content.files[0].mimeType",
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("FILE_TOO_LARGE above the 10 MB default or the host's cap", () => {
|
|
122
|
+
expect(check([{...uploaded, size: 10 * MB}])).toEqual([]);
|
|
123
|
+
expect(
|
|
124
|
+
validateAskResponse({
|
|
125
|
+
input: RECEIPT_INPUT,
|
|
126
|
+
kind: "files",
|
|
127
|
+
response: {action: "accept", content: {files: [{...uploaded, size: 10.5 * MB}]}},
|
|
128
|
+
})
|
|
129
|
+
).toEqual([
|
|
130
|
+
{
|
|
131
|
+
code: "FILE_TOO_LARGE",
|
|
132
|
+
fix: "Send a file of 10 MB or less.",
|
|
133
|
+
message: "The file is 10.5 MB, but the limit is 10 MB.",
|
|
134
|
+
path: "content.files[0].size",
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
expect(check([{...uploaded, size: 10 * MB + 1}])).toEqual([
|
|
138
|
+
{code: "FILE_TOO_LARGE", path: "content.files[0].size"},
|
|
139
|
+
]);
|
|
140
|
+
expect(check([{...uploaded, size: 2 * MB}], RECEIPT_INPUT, MB)).toEqual([
|
|
141
|
+
{code: "FILE_TOO_LARGE", path: "content.files[0].size"},
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("measures a data URL by its bytes, not its declared size", () => {
|
|
146
|
+
const url = dataUrl("image/png", new Uint8Array(2048));
|
|
147
|
+
expect(
|
|
148
|
+
check([{filename: "a.png", mimeType: "image/png", size: 1, url}], RECEIPT_INPUT, 1024)
|
|
149
|
+
).toEqual([{code: "FILE_TOO_LARGE", path: "content.files[0].size"}]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("MIME_MISMATCH when the data URL's media type is not the declared type", () => {
|
|
153
|
+
expect(
|
|
154
|
+
check([
|
|
155
|
+
{
|
|
156
|
+
filename: "a.png",
|
|
157
|
+
mimeType: "image/png",
|
|
158
|
+
size: PNG.length,
|
|
159
|
+
url: dataUrl("image/gif", PNG),
|
|
160
|
+
},
|
|
161
|
+
])
|
|
162
|
+
).toEqual([{code: "MIME_MISMATCH", path: "content.files[0].url"}]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("needs exactly one of fileId and a base64 data URL", () => {
|
|
166
|
+
const {fileId: _fileId, ...withoutId} = uploaded;
|
|
167
|
+
expect(check([withoutId])).toEqual([{code: "MISSING_REQUIRED", path: "content.files[0]"}]);
|
|
168
|
+
expect(check([{...uploaded, url: dataUrl("image/jpeg", JPEG)}])).toEqual([
|
|
169
|
+
{code: "INVALID_FORMAT", path: "content.files[0].url"},
|
|
170
|
+
]);
|
|
171
|
+
for (const url of [
|
|
172
|
+
"https://example.com/a.jpg",
|
|
173
|
+
"data:image/jpeg,raw",
|
|
174
|
+
"data:image/jpeg;base64,@@@",
|
|
175
|
+
]) {
|
|
176
|
+
expect(check([{...withoutId, url}])).toEqual([
|
|
177
|
+
{code: "INVALID_FORMAT", path: "content.files[0].url"},
|
|
178
|
+
]);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("rejects unknown keys, blank filenames, and negative sizes", () => {
|
|
183
|
+
expect(check([{...uploaded, filename: " ", path: "/tmp/a", size: -1}])).toEqual([
|
|
184
|
+
{code: "TOO_SHORT", path: "content.files[0].filename"},
|
|
185
|
+
{code: "UNKNOWN_KEY", path: "content.files[0].path"},
|
|
186
|
+
{code: "RANGE_INVALID", path: "content.files[0].size"},
|
|
187
|
+
]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("parseAskDataUrl", () => {
|
|
192
|
+
it("returns the lowercase media type without parameters, and the payload", () => {
|
|
193
|
+
expect(parseAskDataUrl("data:Text/Plain;charset=utf-8;base64,aGk=")).toEqual({
|
|
194
|
+
base64: "aGk=",
|
|
195
|
+
mediaType: "text/plain",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("rejects remote URLs, URL-encoded data, and bad base64", () => {
|
|
200
|
+
expect(parseAskDataUrl("https://example.com/a.png")).toBeUndefined();
|
|
201
|
+
expect(parseAskDataUrl("data:text/plain,hi")).toBeUndefined();
|
|
202
|
+
expect(parseAskDataUrl("data:;base64,aGk=")).toBeUndefined();
|
|
203
|
+
expect(parseAskDataUrl("data:text/plain;base64,aGk")).toBeUndefined();
|
|
204
|
+
expect(parseAskDataUrl("data:text/plain;base64")).toBeUndefined();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("sniffFileBytes", () => {
|
|
209
|
+
it("reads images and PDFs from their signatures", () => {
|
|
210
|
+
expect(sniffFileBytes(PNG)).toBe("image/png");
|
|
211
|
+
expect(sniffFileBytes(JPEG)).toBe("image/jpeg");
|
|
212
|
+
expect(sniffFileBytes(GIF)).toBe("image/gif");
|
|
213
|
+
expect(sniffFileBytes(bytesOf("GIF87a"))).toBe("image/gif");
|
|
214
|
+
expect(sniffFileBytes(WEBP)).toBe("image/webp");
|
|
215
|
+
expect(sniffFileBytes(PDF)).toBe("application/pdf");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("reads UTF-8 without NUL as text, with or without a byte order mark", () => {
|
|
219
|
+
expect(sniffFileBytes(bytesOf("date,amount\n2026-09-28,12.50\n"))).toBe("text");
|
|
220
|
+
expect(sniffFileBytes(bytesOf(0xef, 0xbb, 0xbf, "Grüße ✓"))).toBe("text");
|
|
221
|
+
expect(sniffFileBytes(new Uint8Array())).toBe("text");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("reads anything else as unknown", () => {
|
|
225
|
+
expect(sniffFileBytes(bytesOf(0xc3, 0x28))).toBe("unknown");
|
|
226
|
+
expect(sniffFileBytes(bytesOf("a", 0, "b"))).toBe("unknown");
|
|
227
|
+
expect(sniffFileBytes(bytesOf("RIFF", 0, 0, 0, 0, "WAVE"))).toBe("unknown");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("checkAskFileBytes", () => {
|
|
232
|
+
const checkBytes = (bytes: Uint8Array, mimeType: string, maxFileSizeBytes = MB) =>
|
|
233
|
+
checkAskFileBytes({bytes, index: 1, maxFileSizeBytes, mimeType});
|
|
234
|
+
|
|
235
|
+
it("accepts bytes that match the declared type", () => {
|
|
236
|
+
expect(checkBytes(PNG, "image/png")).toEqual([]);
|
|
237
|
+
expect(checkBytes(PDF, "application/pdf")).toEqual([]);
|
|
238
|
+
expect(checkBytes(bytesOf("hello"), "text/plain")).toEqual([]);
|
|
239
|
+
expect(checkBytes(bytesOf("a,b\n1,2"), "text/csv")).toEqual([]);
|
|
240
|
+
expect(checkBytes(bytesOf('{"total": 12.5}'), "application/json")).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("MIME_MISMATCH when the bytes are another type", () => {
|
|
244
|
+
expect(checkBytes(PNG, "image/jpeg")).toEqual([
|
|
245
|
+
{
|
|
246
|
+
code: "MIME_MISMATCH",
|
|
247
|
+
fix: "Send the file with its real type, or a file that is image/jpeg.",
|
|
248
|
+
message: "The file is declared as image/jpeg, but its bytes are image/png.",
|
|
249
|
+
path: "content.files[1].mimeType",
|
|
250
|
+
},
|
|
251
|
+
]);
|
|
252
|
+
expect(checkBytes(bytesOf("not an image"), "image/png")[0]?.message).toBe(
|
|
253
|
+
"The file is declared as image/png, but its bytes are text."
|
|
254
|
+
);
|
|
255
|
+
expect(checkBytes(PNG, "text/plain")[0]?.code).toBe("MIME_MISMATCH");
|
|
256
|
+
expect(checkBytes(bytesOf(0xc3, 0x28), "text/csv")[0]?.message).toBe(
|
|
257
|
+
"The file is declared as text/csv, but its bytes are no accepted type."
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("MIME_MISMATCH when a JSON file does not parse", () => {
|
|
262
|
+
expect(checkBytes(bytesOf("{total: 12}"), "application/json")[0]?.code).toBe("MIME_MISMATCH");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("FILE_TOO_LARGE when the bytes are over the cap, whatever size was declared", () => {
|
|
266
|
+
expect(checkBytes(new Uint8Array(2048), "text/plain", 1024)).toEqual([
|
|
267
|
+
{
|
|
268
|
+
code: "FILE_TOO_LARGE",
|
|
269
|
+
fix: "Send a file of 1 KB or less.",
|
|
270
|
+
message: "The file is 2 KB, but the limit is 1 KB.",
|
|
271
|
+
path: "content.files[1].size",
|
|
272
|
+
},
|
|
273
|
+
]);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("fileNotOwnedError", () => {
|
|
278
|
+
it("points at the fileId of the file", () => {
|
|
279
|
+
expect(fileNotOwnedError({index: 2})).toEqual({
|
|
280
|
+
code: "FILE_NOT_OWNED",
|
|
281
|
+
fix: "Upload the file with POST /files/upload and send the id it returns, or send a data: URL.",
|
|
282
|
+
message: "The file id does not name one of your uploads.",
|
|
283
|
+
path: "content.files[2].fileId",
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("isTextFileMimeType", () => {
|
|
289
|
+
it("is true for the types that reach the model as text", () => {
|
|
290
|
+
expect(["text/plain", "text/csv", "application/json"].every(isTextFileMimeType)).toBe(true);
|
|
291
|
+
expect(isTextFileMimeType("image/png")).toBe(false);
|
|
292
|
+
expect(isTextFileMimeType("application/pdf")).toBe(false);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AskErrorDraft,
|
|
3
|
+
type AskValidationError,
|
|
4
|
+
finalizeAskErrors,
|
|
5
|
+
formatAskPath,
|
|
6
|
+
} from "./errors";
|
|
7
|
+
|
|
8
|
+
/** What a `files` ask may accept, and the MIME types each value allows. */
|
|
9
|
+
export const ASK_FILE_ACCEPT_MIME_TYPES = {
|
|
10
|
+
csv: ["text/csv"],
|
|
11
|
+
image: ["image/jpeg", "image/png", "image/gif", "image/webp"],
|
|
12
|
+
json: ["application/json"],
|
|
13
|
+
pdf: ["application/pdf"],
|
|
14
|
+
text: ["text/plain"],
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/** The values of a `files` ask's `accept` list, in the order the docs and prompt list them. */
|
|
18
|
+
export const ASK_FILE_ACCEPT = ["image", "pdf", "text", "csv", "json"] as const;
|
|
19
|
+
|
|
20
|
+
export type AskFileAccept = (typeof ASK_FILE_ACCEPT)[number];
|
|
21
|
+
|
|
22
|
+
export type AskFileMimeType = (typeof ASK_FILE_ACCEPT_MIME_TYPES)[AskFileAccept][number];
|
|
23
|
+
|
|
24
|
+
/** The MIME types an `accept` list allows, each listed once, in `accept` order. */
|
|
25
|
+
export const acceptedFileMimeTypes = (accept: readonly AskFileAccept[]): AskFileMimeType[] => [
|
|
26
|
+
...new Set(accept.flatMap((value) => ASK_FILE_ACCEPT_MIME_TYPES[value])),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const TEXT_MIME_TYPES: readonly string[] = ["text/plain", "text/csv", "application/json"];
|
|
30
|
+
|
|
31
|
+
/** Whether a file of this type reaches the model as text rather than as bytes. */
|
|
32
|
+
export const isTextFileMimeType = (mimeType: string): boolean => TEXT_MIME_TYPES.includes(mimeType);
|
|
33
|
+
|
|
34
|
+
const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Splits a base64 `data:` URL into its media type (lowercase, without parameters) and payload.
|
|
38
|
+
* Returns undefined for anything else, including URL-encoded data URLs and remote URLs.
|
|
39
|
+
*/
|
|
40
|
+
export const parseAskDataUrl = (url: string): {base64: string; mediaType: string} | undefined => {
|
|
41
|
+
if (!url.startsWith("data:")) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const comma = url.indexOf(",");
|
|
45
|
+
if (comma === -1) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const params = url.slice("data:".length, comma).split(";");
|
|
49
|
+
if (params.length < 2 || params.at(-1)?.toLowerCase() !== "base64") {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const mediaType = params[0].trim().toLowerCase();
|
|
53
|
+
const base64 = url.slice(comma + 1);
|
|
54
|
+
if (mediaType === "" || base64.length % 4 !== 0 || !BASE64_PATTERN.test(base64)) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
return {base64, mediaType};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The number of bytes a base64 payload decodes to. */
|
|
61
|
+
export const base64ByteLength = (base64: string): number => {
|
|
62
|
+
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
|
|
63
|
+
return (base64.length / 4) * 3 - padding;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const startsWith = (bytes: Uint8Array, signature: readonly number[], offset = 0): boolean =>
|
|
67
|
+
bytes.length >= offset + signature.length &&
|
|
68
|
+
signature.every((byte, index) => bytes[offset + index] === byte);
|
|
69
|
+
|
|
70
|
+
const ascii = (text: string): number[] => [...text].map((character) => character.charCodeAt(0));
|
|
71
|
+
|
|
72
|
+
/** What the bytes of a file are: one of the accepted binary types, UTF-8 text, or unknown. */
|
|
73
|
+
export type SniffedFileType =
|
|
74
|
+
| "application/pdf"
|
|
75
|
+
| "image/gif"
|
|
76
|
+
| "image/jpeg"
|
|
77
|
+
| "image/png"
|
|
78
|
+
| "image/webp"
|
|
79
|
+
| "text"
|
|
80
|
+
| "unknown";
|
|
81
|
+
|
|
82
|
+
const decodeUtf8 = (bytes: Uint8Array): string | undefined => {
|
|
83
|
+
try {
|
|
84
|
+
return new TextDecoder("utf-8", {fatal: true}).decode(bytes);
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Reads a file's type from its first bytes. Images and PDFs are matched by their signatures;
|
|
92
|
+
* anything else that is valid UTF-8 with no NUL character is text.
|
|
93
|
+
*/
|
|
94
|
+
export const sniffFileBytes = (bytes: Uint8Array): SniffedFileType => {
|
|
95
|
+
if (startsWith(bytes, [0xff, 0xd8, 0xff])) {
|
|
96
|
+
return "image/jpeg";
|
|
97
|
+
}
|
|
98
|
+
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
|
|
99
|
+
return "image/png";
|
|
100
|
+
}
|
|
101
|
+
if (startsWith(bytes, ascii("GIF87a")) || startsWith(bytes, ascii("GIF89a"))) {
|
|
102
|
+
return "image/gif";
|
|
103
|
+
}
|
|
104
|
+
if (startsWith(bytes, ascii("RIFF")) && startsWith(bytes, ascii("WEBP"), 8)) {
|
|
105
|
+
return "image/webp";
|
|
106
|
+
}
|
|
107
|
+
if (startsWith(bytes, ascii("%PDF-"))) {
|
|
108
|
+
return "application/pdf";
|
|
109
|
+
}
|
|
110
|
+
const text = decodeUtf8(bytes);
|
|
111
|
+
if (text === undefined || text.includes("\u0000")) {
|
|
112
|
+
return "unknown";
|
|
113
|
+
}
|
|
114
|
+
return "text";
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const isJson = (bytes: Uint8Array): boolean => {
|
|
118
|
+
try {
|
|
119
|
+
JSON.parse(decodeUtf8(bytes) ?? "");
|
|
120
|
+
return true;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const bytesMatchType = (bytes: Uint8Array, mimeType: string): boolean => {
|
|
127
|
+
const sniffed = sniffFileBytes(bytes);
|
|
128
|
+
if (!isTextFileMimeType(mimeType)) {
|
|
129
|
+
return sniffed === mimeType;
|
|
130
|
+
}
|
|
131
|
+
if (sniffed !== "text") {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
return mimeType !== "application/json" || isJson(bytes);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const describeSniffed = (bytes: Uint8Array): string => {
|
|
138
|
+
const sniffed = sniffFileBytes(bytes);
|
|
139
|
+
if (sniffed === "text") {
|
|
140
|
+
return "text";
|
|
141
|
+
}
|
|
142
|
+
if (sniffed === "unknown") {
|
|
143
|
+
return "no accepted type";
|
|
144
|
+
}
|
|
145
|
+
return sniffed;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const oneDecimal = (value: number): number => Math.round(value * 10) / 10;
|
|
149
|
+
|
|
150
|
+
/** "512 bytes", "4.5 KB", or "10 MB". */
|
|
151
|
+
const formatBytes = (bytes: number): string => {
|
|
152
|
+
if (bytes < 1024) {
|
|
153
|
+
return bytes === 1 ? "1 byte" : `${bytes} bytes`;
|
|
154
|
+
}
|
|
155
|
+
if (bytes < 1024 * 1024) {
|
|
156
|
+
return `${oneDecimal(bytes / 1024)} KB`;
|
|
157
|
+
}
|
|
158
|
+
return `${oneDecimal(bytes / 1024 / 1024)} MB`;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const fileTooLargeDraft = ({
|
|
162
|
+
maxFileSizeBytes,
|
|
163
|
+
segments,
|
|
164
|
+
size,
|
|
165
|
+
}: {
|
|
166
|
+
maxFileSizeBytes: number;
|
|
167
|
+
segments: readonly PropertyKey[];
|
|
168
|
+
size: number;
|
|
169
|
+
}): AskErrorDraft => ({
|
|
170
|
+
code: "FILE_TOO_LARGE",
|
|
171
|
+
fix: `Send a file of ${formatBytes(maxFileSizeBytes)} or less.`,
|
|
172
|
+
message: `The file is ${formatBytes(size)}, but the limit is ${formatBytes(maxFileSizeBytes)}.`,
|
|
173
|
+
segments,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Checks the bytes of the file at `content.files[index]` on the server: they must fit the per-file
|
|
178
|
+
* cap and match the declared MIME type (a JSON file must also parse).
|
|
179
|
+
*/
|
|
180
|
+
export const checkAskFileBytes = ({
|
|
181
|
+
bytes,
|
|
182
|
+
index,
|
|
183
|
+
maxFileSizeBytes,
|
|
184
|
+
mimeType,
|
|
185
|
+
}: {
|
|
186
|
+
bytes: Uint8Array;
|
|
187
|
+
index: number;
|
|
188
|
+
maxFileSizeBytes: number;
|
|
189
|
+
mimeType: string;
|
|
190
|
+
}): AskValidationError[] => {
|
|
191
|
+
const segments = ["content", "files", index];
|
|
192
|
+
if (bytes.length > maxFileSizeBytes) {
|
|
193
|
+
return finalizeAskErrors([
|
|
194
|
+
fileTooLargeDraft({maxFileSizeBytes, segments: [...segments, "size"], size: bytes.length}),
|
|
195
|
+
]);
|
|
196
|
+
}
|
|
197
|
+
if (bytesMatchType(bytes, mimeType)) {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
return finalizeAskErrors([
|
|
201
|
+
{
|
|
202
|
+
code: "MIME_MISMATCH",
|
|
203
|
+
fix: `Send the file with its real type, or a file that is ${mimeType}.`,
|
|
204
|
+
message: `The file is declared as ${mimeType}, but its bytes are ${describeSniffed(bytes)}.`,
|
|
205
|
+
segments: [...segments, "mimeType"],
|
|
206
|
+
},
|
|
207
|
+
]);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** The error for a `fileId` that the caller did not upload, or that the host cannot load. */
|
|
211
|
+
export const fileNotOwnedError = ({index}: {index: number}): AskValidationError => ({
|
|
212
|
+
code: "FILE_NOT_OWNED",
|
|
213
|
+
fix: "Upload the file with POST /files/upload and send the id it returns, or send a data: URL.",
|
|
214
|
+
message: "The file id does not name one of your uploads.",
|
|
215
|
+
path: formatAskPath(["content", "files", index, "fileId"]),
|
|
216
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "choice",
|
|
3
|
+
"input": {
|
|
4
|
+
"title": " ",
|
|
5
|
+
"prompt": " \n\t ",
|
|
6
|
+
"select": "one",
|
|
7
|
+
"options": [
|
|
8
|
+
{"id": "yes", "label": " "},
|
|
9
|
+
{"id": "no", "label": "No", "description": " "}
|
|
10
|
+
],
|
|
11
|
+
"submitLabel": "\t"
|
|
12
|
+
},
|
|
13
|
+
"errors": [
|
|
14
|
+
{"path": "options[0].label", "code": "TOO_SHORT"},
|
|
15
|
+
{"path": "options[1].description", "code": "TOO_SHORT"},
|
|
16
|
+
{"path": "prompt", "code": "TOO_SHORT"},
|
|
17
|
+
{"path": "submitLabel", "code": "TOO_SHORT"},
|
|
18
|
+
{"path": "title", "code": "TOO_SHORT"}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "choice",
|
|
3
|
+
"input": {
|
|
4
|
+
"prompt": "Which plan?",
|
|
5
|
+
"select": "one",
|
|
6
|
+
"options": [
|
|
7
|
+
{"id": "team", "label": "Team"},
|
|
8
|
+
{"id": "solo", "label": "Solo"}
|
|
9
|
+
],
|
|
10
|
+
"default": ["gold"]
|
|
11
|
+
},
|
|
12
|
+
"errors": [{"path": "default[0]", "code": "DEFAULT_NOT_IN_OPTIONS"}]
|
|
13
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "choice",
|
|
3
|
+
"input": {
|
|
4
|
+
"prompt": "Which plan?",
|
|
5
|
+
"select": "one",
|
|
6
|
+
"options": [
|
|
7
|
+
{"id": "team", "label": "Team"},
|
|
8
|
+
{"id": "solo", "label": "Solo"}
|
|
9
|
+
],
|
|
10
|
+
"default": ["team", "gold"]
|
|
11
|
+
},
|
|
12
|
+
"errors": [
|
|
13
|
+
{"path": "default", "code": "SELECTION_COUNT"},
|
|
14
|
+
{"path": "default[1]", "code": "DEFAULT_NOT_IN_OPTIONS"}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "choice",
|
|
3
|
+
"input": {
|
|
4
|
+
"prompt": "Pick one.",
|
|
5
|
+
"select": "one",
|
|
6
|
+
"options": [
|
|
7
|
+
{
|
|
8
|
+
"id": "a",
|
|
9
|
+
"label": "A"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "b",
|
|
13
|
+
"label": "B",
|
|
14
|
+
"description": "ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"errors": [
|
|
19
|
+
{
|
|
20
|
+
"path": "options[1].description",
|
|
21
|
+
"code": "TOO_LONG"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "choice",
|
|
3
|
+
"input": {
|
|
4
|
+
"prompt": "Which plan?",
|
|
5
|
+
"select": "one",
|
|
6
|
+
"options": [
|
|
7
|
+
{"id": "team", "label": "Team"},
|
|
8
|
+
{"id": "solo", "label": "Solo"},
|
|
9
|
+
{"id": "team", "label": "Team (annual)"},
|
|
10
|
+
{"id": "solo", "label": "Solo (annual)"}
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"errors": [
|
|
14
|
+
{"path": "options[2].id", "code": "DUPLICATE_ID"},
|
|
15
|
+
{"path": "options[3].id", "code": "DUPLICATE_ID"}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "choice",
|
|
3
|
+
"input": {
|
|
4
|
+
"prompt": "Pick one.",
|
|
5
|
+
"select": "one",
|
|
6
|
+
"options": [
|
|
7
|
+
{
|
|
8
|
+
"id": "a",
|
|
9
|
+
"label": "lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "b",
|
|
13
|
+
"label": "B"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"errors": [
|
|
18
|
+
{
|
|
19
|
+
"path": "options[0].label",
|
|
20
|
+
"code": "TOO_LONG"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "choice",
|
|
3
|
+
"input": {
|
|
4
|
+
"prompt": "Which toppings?",
|
|
5
|
+
"select": "many",
|
|
6
|
+
"options": [
|
|
7
|
+
{"id": "cheese", "label": "Cheese"},
|
|
8
|
+
{"id": "olives", "label": "Olives"}
|
|
9
|
+
],
|
|
10
|
+
"minSelected": 1.5,
|
|
11
|
+
"maxSelected": "2"
|
|
12
|
+
},
|
|
13
|
+
"errors": [
|
|
14
|
+
{"path": "maxSelected", "code": "INVALID_TYPE"},
|
|
15
|
+
{"path": "minSelected", "code": "INVALID_TYPE"}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "choice",
|
|
3
|
+
"input": {
|
|
4
|
+
"prompt": "Which toppings?",
|
|
5
|
+
"select": "many",
|
|
6
|
+
"options": [
|
|
7
|
+
{"id": "cheese", "label": "Cheese"},
|
|
8
|
+
{"id": "olives", "label": "Olives"}
|
|
9
|
+
],
|
|
10
|
+
"minSelected": -1,
|
|
11
|
+
"maxSelected": 0
|
|
12
|
+
},
|
|
13
|
+
"errors": [
|
|
14
|
+
{"path": "maxSelected", "code": "RANGE_INVALID"},
|
|
15
|
+
{"path": "minSelected", "code": "RANGE_INVALID"}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "choice",
|
|
3
|
+
"input": {
|
|
4
|
+
"prompt": "Which toppings?",
|
|
5
|
+
"select": "many",
|
|
6
|
+
"options": [
|
|
7
|
+
{"id": "cheese", "label": "Cheese"},
|
|
8
|
+
{"id": "olives", "label": "Olives"}
|
|
9
|
+
],
|
|
10
|
+
"default": ["cheese", "cheese", "ham"]
|
|
11
|
+
},
|
|
12
|
+
"errors": [
|
|
13
|
+
{"path": "default", "code": "SELECTION_COUNT"},
|
|
14
|
+
{"path": "default[1]", "code": "DUPLICATE_ID"},
|
|
15
|
+
{"path": "default[2]", "code": "DEFAULT_NOT_IN_OPTIONS"}
|
|
16
|
+
]
|
|
17
|
+
}
|