gentle-pi 3.3.0 → 3.5.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 (88) hide show
  1. package/README.md +88 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/bin/gentle-shell.mjs +198 -0
  4. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  5. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  6. package/docs/assets/features/agents-view.png +0 -0
  7. package/docs/assets/features/changes-view.png +0 -0
  8. package/docs/assets/features/command-palette.png +0 -0
  9. package/docs/assets/features/profiles-routing.png +0 -0
  10. package/docs/gentle-agents-activity.md +95 -0
  11. package/docs/gentle-shell.md +26 -2
  12. package/docs/readme-reference.md +99 -6
  13. package/extensions/ask-user-choice.ts +70 -22
  14. package/extensions/ask-user-question.ts +338 -0
  15. package/extensions/gentle-agents.ts +41 -1
  16. package/extensions/gentle-ai.ts +59 -18
  17. package/extensions/gentle-shell.ts +99 -10
  18. package/extensions/quiet-tools.ts +28 -5
  19. package/extensions/startup-banner.ts +25 -10
  20. package/lib/agents-rpc-publisher.ts +342 -0
  21. package/lib/agents-runner.ts +7 -2
  22. package/lib/animation-policy.ts +52 -0
  23. package/lib/background-cache-warming.ts +38 -0
  24. package/lib/command-palette-catalog.ts +1 -0
  25. package/lib/gentle-shell-launcher.ts +482 -0
  26. package/lib/inprocess-reviewer.ts +38 -1
  27. package/lib/native-review-cli.ts +36 -10
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +10 -0
  34. package/lib/review-integration-v2.ts +4 -1
  35. package/lib/rpc-host.ts +36 -0
  36. package/lib/shell-bar.ts +13 -0
  37. package/lib/shell-sidebar-layout.ts +10 -4
  38. package/lib/shell-usage-view.ts +5 -2
  39. package/lib/shell-usage.ts +120 -6
  40. package/package.json +5 -1
  41. package/runtime/gentle-shell-launcher.mjs +483 -0
  42. package/runtime/native-review-cli.mjs +35 -9
  43. package/runtime/review-integration-v2.mjs +4 -1
  44. package/scripts/build-runtime-modules.mjs +1 -0
  45. package/scripts/gentle-ai-installer.mjs +10 -10
  46. package/scripts/install-gentle-ai.mjs +14 -7
  47. package/scripts/install-tui-mode-setting.mjs +78 -1
  48. package/scripts/verify-package-files.mjs +6 -3
  49. package/tests/agents-rpc-publisher.test.ts +407 -0
  50. package/tests/agents-runner.test.ts +10 -0
  51. package/tests/animation-policy.test.ts +42 -0
  52. package/tests/ask-user-choice.test.ts +129 -0
  53. package/tests/ask-user-question.test.ts +661 -0
  54. package/tests/background-cache-warming.test.ts +60 -0
  55. package/tests/background-subagents.test.ts +68 -0
  56. package/tests/command-palette.test.ts +9 -0
  57. package/tests/gentle-agents.test.ts +161 -2
  58. package/tests/gentle-ai-binary.test.ts +1 -1
  59. package/tests/gentle-ai-installer.test.ts +47 -47
  60. package/tests/gentle-ai.test.ts +56 -4
  61. package/tests/gentle-shell-bin.test.ts +188 -0
  62. package/tests/gentle-shell-launcher.test.ts +718 -0
  63. package/tests/gentle-shell.test.ts +355 -2
  64. package/tests/inprocess-reviewer.test.ts +92 -0
  65. package/tests/install-tui-mode-guard.test.ts +99 -0
  66. package/tests/install-tui-mode-setting.test.ts +39 -1
  67. package/tests/native-review-capability-contract.test.ts +16 -1
  68. package/tests/native-review-parity.test.ts +19 -0
  69. package/tests/package-manifest.test.ts +6 -17
  70. package/tests/questionnaire-schema.test.ts +274 -0
  71. package/tests/questionnaire-view.test.ts +446 -0
  72. package/tests/rdd-status-line.test.ts +21 -4
  73. package/tests/review-candidate-owner-retry.test.ts +63 -0
  74. package/tests/review-candidate-view.test.ts +15 -0
  75. package/tests/review-controller-native-routing.test.ts +86 -0
  76. package/tests/review-host-relay.test.ts +21 -0
  77. package/tests/review-integration-v2.test.ts +30 -0
  78. package/tests/review-ledger-contract.test.ts +1 -2
  79. package/tests/review-relay-transport-agent.test.ts +107 -2
  80. package/tests/review-risk-assessment.test.ts +104 -0
  81. package/tests/rpc-host.test.ts +77 -0
  82. package/tests/shell-bar.test.ts +8 -0
  83. package/tests/shell-sidebar-layout.test.ts +60 -5
  84. package/tests/shell-usage.test.ts +129 -0
  85. package/tests/skill-collision-prefixes.test.ts +1 -1
  86. package/tests/startup-banner.test.ts +93 -2
  87. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  88. package/skills/release/SKILL.md +0 -137
@@ -319,11 +319,26 @@ test("3.4.0 repeats 3.2.1 because the negotiated lane Pi consumes is unchanged",
319
319
  assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.2.1"] as Record<string, boolean>);
320
320
  });
321
321
 
322
+ test("3.5.0 repeats 3.4.0 because the negotiated lane Pi consumes is unchanged", () => {
323
+ // Published v3.4.0 and v3.5.0 provider bundles are byte-identical at
324
+ // contract 1.2.0, both with archive SHA-256:
325
+ // 547b68e172cc87aa297309d61624e5fc2c24d407a494b53eeb5a2b053904352c.
326
+ // Both binaries advertise capabilities/v2.6; only build-identity fields
327
+ // differ. No review-integration schema changed between the tags. The v2
328
+ // preflight failure identity fix changes no closed START/STATUS field
329
+ // this row negotiates, so it repeats 3.4.0. riskEvidence and hint remain
330
+ // dark because neither is proven to reach Pi's negotiated START path.
331
+ const contract = NATIVE_CLI_CONTRACTS["3.5.0"] as Record<string, boolean>;
332
+ assert.equal(contract.riskEvidence, false);
333
+ assert.equal(contract.hint, false);
334
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["3.4.0"] as Record<string, boolean>);
335
+ });
336
+
322
337
  test("no shipped version key was added beyond the pin bump", () => {
323
338
  // Rows are promises to consumers, so a new key only ever appears in a
324
339
  // dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
325
340
  // v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
326
341
  // so they get no row: a row asserts ground truth measured against a binary
327
342
  // Pi actually ran, and the table only has to be ascending, not gapless.
328
- assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1", "3.4.0"]);
343
+ assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1", "3.0.0", "3.0.1", "3.1.0", "3.2.1", "3.4.0", "3.5.0"]);
329
344
  });
@@ -344,6 +344,25 @@ function assertStaleConsentBinding(outcome: Record<string, unknown>, binding: un
344
344
  assert.equal(outcome.next_action, "restart-for-fresh-consent");
345
345
  }
346
346
 
347
+ test("synthetic default-ON status reaches review consent without changing RDD mode", async (t) => {
348
+ const cwd = repository(t);
349
+ const fixture = consentNative(cwd);
350
+ const operations: string[] = [];
351
+ fixture.native.reviewMode = async ({ operation }) => {
352
+ operations.push(operation);
353
+ return {
354
+ operation: "status",
355
+ scope: "clone",
356
+ status: { global: "", cloneLocal: "", effective: "on", source: "default" },
357
+ };
358
+ };
359
+ await beginConsent(parityRuntime(fixture.native), cwd);
360
+ assert.equal(fixture.starts.count, 1, "default ON must not skip native review START");
361
+ assert.ok(operations.length > 0);
362
+ assert.ok(operations.every((operation) => operation === "status"), "automation must only read RDD mode");
363
+ assert.deepEqual(fixture.answers, [], "default ON does not grant candidate consent");
364
+ });
365
+
347
366
  test("review-mode gate retains every off-source continuation and fails closed on native errors", async () => {
348
367
  for (const [source, expected] of [
349
368
  ["clone_local", /clear this clone-local override/],
@@ -142,7 +142,6 @@ test("package verification names the native review runtime boundary and packaged
142
142
 
143
143
  test("npm publication is bound to the exact package tag and triggering commit", () => {
144
144
  const workflow = readFileSync(join(PACKAGE_ROOT, ".github", "workflows", "publish.yml"), "utf8");
145
- const releaseSkill = readFileSync(join(PACKAGE_ROOT, "skills", "release", "SKILL.md"), "utf8");
146
145
  const packageJson = readPackageJson();
147
146
  const dispatchBlock = workflow.match(
148
147
  /^ {2}workflow_dispatch:\n([\s\S]*?)^\npermissions:/m,
@@ -189,16 +188,6 @@ test("npm publication is bound to the exact package tag and triggering commit",
189
188
  );
190
189
  assert.match(workflow, /npm publish --provenance --access public/);
191
190
  assert.doesNotMatch(workflow, /pnpm publish|--no-git-checks|NODE_AUTH_TOKEN/);
192
-
193
- assert.match(releaseSkill, /tag="v\$\{version\}"/);
194
- assert.match(releaseSkill, /release_sha="\$\(git rev-parse 'origin\/main\^\{commit\}'\)"/);
195
- assert.match(releaseSkill, /git rev-parse "\$\{tag\}\^\{commit\}"/);
196
- assert.match(releaseSkill, /git fetch --no-tags origin "refs\/tags\/\$\{tag\}"/);
197
- assert.match(releaseSkill, /gh release create "\$\{tag\}"[\s\S]*--verify-tag/);
198
- assert.match(releaseSkill, /--ref main/);
199
- assert.match(releaseSkill, /-f tag="\$\{tag\}"/);
200
- assert.match(releaseSkill, /trusted OIDC with provenance/);
201
- assert.doesNotMatch(releaseSkill, /--ref "\$\{tag\}"|-f dist-tag=/);
202
191
  });
203
192
 
204
193
  test("Pi delivery relay is absent from the packaged extension", () => {
@@ -309,20 +298,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
309
298
  );
310
299
  });
311
300
 
312
- test("package verification binds the published Gentle AI v3.4.0 runtime pin", () => {
301
+ test("package verification binds the published Gentle AI v3.5.0 runtime pin", () => {
313
302
  const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
314
303
  const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
315
304
  const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
316
305
 
317
- assert.match(installer, /INSTALLER_VERSION = "3\.4\.0"/);
306
+ assert.match(installer, /INSTALLER_VERSION = "3\.5\.0"/);
318
307
  assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
319
- assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:bQOM\+Xa3WN5\+idTagEk15rgEEQQWVhZvq8BPF\/FHtSs="/);
308
+ assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:3y\+Nb7CtgGB3ne3bSNxkzV6j19etkOnGDXIiuv7tMp8="/);
320
309
  assert.match(installer, /GOTOOLCHAIN: "local"/);
321
310
  assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
322
311
  assert.match(binary, /GENTLE_AI_VERSION = INSTALLER_VERSION/);
323
312
  assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
324
313
  assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
325
- assert.match(verifier, /v3\.4\.0/);
314
+ assert.match(verifier, /v3\.5\.0/);
326
315
  });
327
316
 
328
317
 
@@ -1574,9 +1563,9 @@ test("pi-pretty wrapper uses cached ESM loading for compiled and pnpm symlink in
1574
1563
  assert.match(wrapper, /quietToolsEnabled/);
1575
1564
  });
1576
1565
 
1577
- test("Gentle Shell v3.3.0 package and runtime stop before publication", () => {
1566
+ test("Gentle Shell v3.5.0 package and runtime stop before publication", () => {
1578
1567
  const packageJson = readPackageJson();
1579
- assert.equal(packageJson.version, "3.3.0", "the release manifest must remain explicitly pinned to v3.3.0");
1568
+ assert.equal(packageJson.version, "3.5.0", "the release manifest must remain explicitly pinned to v3.5.0");
1580
1569
  assert.equal(
1581
1570
  packageJson.scripts?.test,
1582
1571
  "node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
@@ -0,0 +1,274 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { Value } from "typebox/value";
4
+ import {
5
+ CUSTOM_ROW_LABEL,
6
+ MAX_HEADER_LENGTH,
7
+ MAX_LABEL_LENGTH,
8
+ MAX_OPTIONS,
9
+ MAX_QUESTIONS,
10
+ MIN_OPTIONS,
11
+ QuestionParamsSchema,
12
+ type OptionData,
13
+ type QuestionParams,
14
+ } from "../lib/questionnaire/schema.ts";
15
+ import { validateQuestionnaire, type QuestionnaireError } from "../lib/questionnaire/validate.ts";
16
+
17
+ const H = "Header";
18
+ const option = (label: string, preview?: string): OptionData =>
19
+ preview === undefined ? { label, description: `${label} description` } : { label, description: `${label} description`, preview };
20
+
21
+ const question = (
22
+ text: string,
23
+ options: OptionData[],
24
+ overrides: { header?: string; multiSelect?: boolean } = {},
25
+ ) => ({
26
+ question: text,
27
+ header: overrides.header ?? H,
28
+ options,
29
+ ...(overrides.multiSelect === undefined ? {} : { multiSelect: overrides.multiSelect }),
30
+ });
31
+
32
+ const params = (questions: ReturnType<typeof question>[]): QuestionParams => ({ questions });
33
+ const valid = (): ReturnType<typeof question>[] => [question("First?", [option("Alpha"), option("Beta")])];
34
+
35
+ interface Case {
36
+ name: string;
37
+ input: ReturnType<typeof params>;
38
+ expected: QuestionnaireError | undefined;
39
+ }
40
+
41
+ function makeCases(): Case[] {
42
+ return [
43
+ { name: "accepts a minimal valid questionnaire", input: params(valid()), expected: undefined },
44
+ {
45
+ name: "accepts exactly one question with two options",
46
+ input: params([question("Solo?", [option("A"), option("B")])]),
47
+ expected: undefined,
48
+ },
49
+ {
50
+ name: "accepts exactly four questions and four options",
51
+ input: params([
52
+ question("Q1", [option("A1"), option("B1"), option("C1"), option("D1")]),
53
+ question("Q2", [option("A2"), option("B2")]),
54
+ question("Q3", [option("A3"), option("B3")]),
55
+ question("Q4", [option("A4"), option("B4")]),
56
+ ]),
57
+ expected: undefined,
58
+ },
59
+ {
60
+ name: "accepts a header at the 16 character limit",
61
+ input: params([question("Long header?", [option("A"), option("B")], { header: "x".repeat(MAX_HEADER_LENGTH) })]),
62
+ expected: undefined,
63
+ },
64
+ {
65
+ name: "accepts a label at the 60 character limit",
66
+ input: params([question("Long label?", [option("y".repeat(MAX_LABEL_LENGTH)), option("B")])]),
67
+ expected: undefined,
68
+ },
69
+ {
70
+ name: "rejects an empty question list",
71
+ input: params([]),
72
+ expected: { code: "no_questions", message: "At least one question is required." },
73
+ },
74
+ {
75
+ name: "rejects a question with empty options",
76
+ input: params([question("Empty?", [])]),
77
+ expected: {
78
+ code: "empty_options",
79
+ message: "Question 1 must declare at least one option.",
80
+ questionIndex: 0,
81
+ },
82
+ },
83
+ {
84
+ name: "rejects a single option below the minimum",
85
+ input: params([question("One?", [option("Only")])]),
86
+ expected: {
87
+ code: "option_count",
88
+ message: "Question 1 must have between 2 and 4 options, received 1.",
89
+ questionIndex: 0,
90
+ count: 1,
91
+ },
92
+ },
93
+ {
94
+ name: "rejects five options above the maximum",
95
+ input: params([question("Five?", [option("A"), option("B"), option("C"), option("D"), option("E")])]),
96
+ expected: {
97
+ code: "option_count",
98
+ message: "Question 1 must have between 2 and 4 options, received 5.",
99
+ questionIndex: 0,
100
+ count: 5,
101
+ },
102
+ },
103
+ {
104
+ name: "rejects more than four questions",
105
+ input: params([
106
+ question("Q1", [option("A1"), option("B1")]),
107
+ question("Q2", [option("A2"), option("B2")]),
108
+ question("Q3", [option("A3"), option("B3")]),
109
+ question("Q4", [option("A4"), option("B4")]),
110
+ question("Q5", [option("A5"), option("B5")]),
111
+ ]),
112
+ expected: {
113
+ code: "too_many_questions",
114
+ message: "A questionnaire accepts at most 4 questions, received 5.",
115
+ count: 5,
116
+ },
117
+ },
118
+ {
119
+ name: "rejects duplicate question text",
120
+ input: params([
121
+ question("Same?", [option("A1"), option("B1")]),
122
+ question("Same?", [option("A2"), option("B2")]),
123
+ ]),
124
+ expected: {
125
+ code: "duplicate_question",
126
+ message: "Question 2 duplicates an earlier question text.",
127
+ questionIndex: 1,
128
+ },
129
+ },
130
+ {
131
+ name: "rejects a duplicate option label within one question",
132
+ input: params([question("Dupe?", [option("Alpha"), option("Alpha")])]),
133
+ expected: {
134
+ code: "duplicate_option_label",
135
+ message: 'Question 1 option 2 duplicates the label "Alpha".',
136
+ questionIndex: 0,
137
+ optionIndex: 1,
138
+ label: "Alpha",
139
+ },
140
+ },
141
+ {
142
+ name: "rejects a label over the 60 character limit",
143
+ input: params([question("Too long?", [option("z".repeat(MAX_LABEL_LENGTH + 1)), option("B")])]),
144
+ expected: {
145
+ code: "label_too_long",
146
+ message: "Question 1 option 1 label exceeds 60 characters.",
147
+ questionIndex: 0,
148
+ optionIndex: 0,
149
+ length: MAX_LABEL_LENGTH + 1,
150
+ },
151
+ },
152
+ {
153
+ name: "rejects a header over the 16 character limit",
154
+ input: params([question("Header too long?", [option("A"), option("B")], { header: "x".repeat(MAX_HEADER_LENGTH + 1) })]),
155
+ expected: {
156
+ code: "header_too_long",
157
+ message: "Question 1 header exceeds 16 characters.",
158
+ questionIndex: 0,
159
+ length: MAX_HEADER_LENGTH + 1,
160
+ },
161
+ },
162
+ {
163
+ name: "rejects the reserved Other label",
164
+ input: params([question("Reserved?", [option("Other"), option("B")])]),
165
+ expected: {
166
+ code: "reserved_label",
167
+ message: 'Question 1 option 1 uses the reserved label "Other".',
168
+ questionIndex: 0,
169
+ optionIndex: 0,
170
+ label: "Other",
171
+ },
172
+ },
173
+ {
174
+ name: "rejects the reserved custom-row label",
175
+ input: params([question("Reserved?", [option(CUSTOM_ROW_LABEL), option("B")])]),
176
+ expected: {
177
+ code: "reserved_label",
178
+ message: `Question 1 option 1 uses the reserved label "${CUSTOM_ROW_LABEL}".`,
179
+ questionIndex: 0,
180
+ optionIndex: 0,
181
+ label: CUSTOM_ROW_LABEL,
182
+ },
183
+ },
184
+ {
185
+ name: "does not treat a reserved label with different casing as reserved",
186
+ input: params([question("Case?", [option("other"), option("type something.")])]),
187
+ expected: undefined,
188
+ },
189
+ {
190
+ name: "reports a duplicated question before its duplicated option label",
191
+ input: params([
192
+ question("Same?", [option("A"), option("B")]),
193
+ question("Same?", [option("A"), option("B")]),
194
+ ]),
195
+ expected: {
196
+ code: "duplicate_question",
197
+ message: "Question 2 duplicates an earlier question text.",
198
+ questionIndex: 1,
199
+ },
200
+ },
201
+ ];
202
+ }
203
+
204
+ test("validateQuestionnaire reports the first violation for each guard", () => {
205
+ for (const testCase of makeCases()) {
206
+ const actual = validateQuestionnaire(testCase.input);
207
+ assert.deepEqual(actual, testCase.expected, testCase.name);
208
+ }
209
+ });
210
+
211
+ test("schema constants expose the documented limits", () => {
212
+ assert.equal(MAX_QUESTIONS, 4);
213
+ assert.equal(MIN_OPTIONS, 2);
214
+ assert.equal(MAX_OPTIONS, 4);
215
+ assert.equal(MAX_HEADER_LENGTH, 16);
216
+ assert.equal(MAX_LABEL_LENGTH, 60);
217
+ assert.equal(CUSTOM_ROW_LABEL, "Type something.");
218
+ });
219
+
220
+ test("QuestionParamsSchema pins the questionnaire parameter shape", () => {
221
+ const root = QuestionParamsSchema as unknown as {
222
+ additionalProperties?: boolean;
223
+ properties?: {
224
+ questions?: {
225
+ minItems?: number;
226
+ maxItems?: number;
227
+ items?: {
228
+ additionalProperties?: boolean;
229
+ properties?: Record<string, unknown>;
230
+ required?: string[];
231
+ };
232
+ };
233
+ };
234
+ };
235
+ const questions = root.properties?.questions;
236
+ const questionSchema = questions?.items;
237
+ const questionProperties = questionSchema?.properties ?? {};
238
+
239
+ assert.equal(root.additionalProperties, false);
240
+ assert.equal(questions?.minItems, 1);
241
+ assert.equal(questions?.maxItems, MAX_QUESTIONS);
242
+ assert.equal(questionSchema?.additionalProperties, false);
243
+ assert.deepEqual([...(questionSchema?.required ?? [])].sort(), ["header", "options", "question"]);
244
+ assert.deepEqual(Object.keys(questionProperties).sort(), ["header", "multiSelect", "options", "question"]);
245
+ });
246
+
247
+ test("schema rejects malformed questionnaire parameters", () => {
248
+ const validParams = params(valid());
249
+ assert.equal(Value.Check(QuestionParamsSchema, validParams), true);
250
+
251
+ const invalidCases: Array<[string, unknown]> = [
252
+ ["missing questions", {}],
253
+ ["empty questions", { questions: [] }],
254
+ ["five questions", params([
255
+ question("Q1", [option("A1"), option("B1")]),
256
+ question("Q2", [option("A2"), option("B2")]),
257
+ question("Q3", [option("A3"), option("B3")]),
258
+ question("Q4", [option("A4"), option("B4")]),
259
+ question("Q5", [option("A5"), option("B5")]),
260
+ ])],
261
+ ["one option", params([question("One?", [option("Only")])])],
262
+ ["label over the limit", params([question("Long?", [option("z".repeat(MAX_LABEL_LENGTH + 1)), option("B")])])],
263
+ ["header over the limit", params([question("Long?", [option("A"), option("B")], { header: "x".repeat(MAX_HEADER_LENGTH + 1) })])],
264
+ ["unknown root property", { questions: valid(), extra: true }],
265
+ ];
266
+ for (const [name, subject] of invalidCases) {
267
+ assert.equal(Value.Check(QuestionParamsSchema, subject), false, name);
268
+ }
269
+ });
270
+
271
+ test("schema accepts multiSelect and preview as optional fields", () => {
272
+ const withOptionals = params([question("Options?", [option("A", "Preview A"), option("B")], { multiSelect: true })]);
273
+ assert.equal(Value.Check(QuestionParamsSchema, withOptionals), true);
274
+ });