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.
- package/README.md +88 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/bin/gentle-shell.mjs +198 -0
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-agents-activity.md +95 -0
- package/docs/gentle-shell.md +26 -2
- package/docs/readme-reference.md +99 -6
- package/extensions/ask-user-choice.ts +70 -22
- package/extensions/ask-user-question.ts +338 -0
- package/extensions/gentle-agents.ts +41 -1
- package/extensions/gentle-ai.ts +59 -18
- package/extensions/gentle-shell.ts +99 -10
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/agents-rpc-publisher.ts +342 -0
- package/lib/agents-runner.ts +7 -2
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/gentle-shell-launcher.ts +482 -0
- package/lib/inprocess-reviewer.ts +38 -1
- package/lib/native-review-cli.ts +36 -10
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +10 -0
- package/lib/review-integration-v2.ts +4 -1
- package/lib/rpc-host.ts +36 -0
- package/lib/shell-bar.ts +13 -0
- package/lib/shell-sidebar-layout.ts +10 -4
- package/lib/shell-usage-view.ts +5 -2
- package/lib/shell-usage.ts +120 -6
- package/package.json +5 -1
- package/runtime/gentle-shell-launcher.mjs +483 -0
- package/runtime/native-review-cli.mjs +35 -9
- package/runtime/review-integration-v2.mjs +4 -1
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +14 -7
- package/scripts/install-tui-mode-setting.mjs +78 -1
- package/scripts/verify-package-files.mjs +6 -3
- package/tests/agents-rpc-publisher.test.ts +407 -0
- package/tests/agents-runner.test.ts +10 -0
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-choice.test.ts +129 -0
- package/tests/ask-user-question.test.ts +661 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +9 -0
- package/tests/gentle-agents.test.ts +161 -2
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +56 -4
- package/tests/gentle-shell-bin.test.ts +188 -0
- package/tests/gentle-shell-launcher.test.ts +718 -0
- package/tests/gentle-shell.test.ts +355 -2
- package/tests/inprocess-reviewer.test.ts +92 -0
- package/tests/install-tui-mode-guard.test.ts +99 -0
- package/tests/install-tui-mode-setting.test.ts +39 -1
- package/tests/native-review-capability-contract.test.ts +16 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay.test.ts +21 -0
- package/tests/review-integration-v2.test.ts +30 -0
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +107 -2
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/rpc-host.test.ts +77 -0
- package/tests/shell-bar.test.ts +8 -0
- package/tests/shell-sidebar-layout.test.ts +60 -5
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- 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.
|
|
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\.
|
|
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:
|
|
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\.
|
|
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.
|
|
1566
|
+
test("Gentle Shell v3.5.0 package and runtime stop before publication", () => {
|
|
1578
1567
|
const packageJson = readPackageJson();
|
|
1579
|
-
assert.equal(packageJson.version, "3.
|
|
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
|
+
});
|