Skip to content

Commit efbe1a0

Browse files
fix(service-automation): a screen-flow resume refuses a non-string value for a text-like field before the pause is consumed (#31568)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X9QaksZP89FVq2pH6CVzsEb)_ Fixes #31491 Clause-②: no (narrowing) Metadata-protocol: none ## 维护者速读 - 改了什么:屏幕流程恢复时,`text`、`textarea`、`email`、`url`、`phone` 五种字段收到非字符串值(对象、数字、布尔、数组)一律在消费暂停之前拒绝 400 `INVALID_SCREEN_INPUT`,运行保持暂停,改正后再提交即可完成。 - 为什么改:按裁定 A+(卡片评论 6721960676)。修改前,对象值在暂停已被消费后才在写记录时失败,运行变成 failed,改正后的恢复只得到 `RUN_NOT_FOUND`;数字则被当成文本存下。 - 风险与回滚:这是收紧(narrowing),只判断类型,不判断邮箱、网址、电话的格式;缺失值的行为不变。无头调用(MCP `run_action`)给这类字段传非字符串时,屏幕改为保持交互,与数字字段现有做法一致。回滚即还原本 PR。 - 先看:`packages/services/service-automation/src/screen-input-contract.ts` 中的 `isString` 和 `SCREEN_FIELD_VALUE_DOMAINS` 新增的五项。 - 两分钟验证:`cd packages/services/service-automation && npx vitest run src/screen-input-contract.test.ts src/builtin/screen-string-field-resume.test.ts`,预期 40 个全绿。 - 需要你做的:无,席位做契约审查后落地。 ## What changes - `screen-input-contract.ts`: `text`, `textarea`, `email`, `url` and `phone` join `SCREEN_FIELD_VALUE_DOMAINS` on one predicate, `isString` (a JSON string). A present value of another JSON type is an `invalid_type` issue, `Screen field "answer" must be a string`, and `refuseInvalidScreenInput` answers it `400 INVALID_SCREEN_INPUT` before `claimAdvance`, so the suspension is not consumed (the #13937 order: consume before traverse, nothing consumed on a refusal). - Only the shape is judged. The format of an email, URL or phone number, and any other downstream refusal of a string, are unchanged and keep the operator restore as their exit. - An absent value, `null`, the empty string and a hidden field behave as today. A missing required field still yields only the `required` issue. - The docblocks name ruling A+ next to the existing A′ and #30178 groups, and the `judgeHeadlessScreen` comment says an issue may be a wrong-shape value as well as a missing required one. - Changesets: `@objectstack/service-automation` patch, titled with `!` and carrying the `**BREAKING**` banner, with `Clause-②: no (narrowing)` and an ADR-0087 `not-required (no-migration-prescription)` disposition (nothing at rest changes). It also names the two console paths that now stop at Submit with the run still paused: a text-kind field whose `options[].value` is a number or a boolean, and a text-kind field whose served `defaultValue` resolves to a non-string. A second `@objectstack/spec` patch docs changeset covers the contract text below. - Contract text (prose only, as #30178 did; no accept set, schema shape or export in spec moves): the `ScreenFieldSpec.type` docblock (`packages/spec/src/contracts/automation-service.ts`), the `ScreenFieldConfigSchema.type` docblock and describe string (`builtin-node-config.zod.ts`, with `content/docs/references/automation/builtin-node-config.mdx` regenerated), `content/docs/automation/flows.mdx` (the shape section and the list of refused conditions), and the opening of the `validateScreenInputs` docblock now name the five string kinds beside the numeric kinds and `file`. ## Tests - `screen-input-contract.test.ts`: for each of the five kinds, an object, `42`, `0`, `true` and an array are refused `invalid_type` with the message above; any string is admitted; absent, `null`, `''` and a hidden field are left alone. A headless pin: `judgeHeadlessScreen` with `42` is not satisfied (the field is listed as supplied and missing), and the string `'42'` is satisfied. - New `builtin/screen-string-field-resume.test.ts` (engine, start → screen → create_record → end): for each kind, an object resume and a number resume are refused `INVALID_SCREEN_INPUT`, nothing is inserted, and `hasSuspendedRun` stays true. A string resume then completes, is inserted, and the suspension is gone. A missing value gets the `is required` refusal and the run stays paused. - `screen-headless-provenance-durable-resume.test.ts` and `screen-headless-caller-signal.test.ts`: their review screen field is now untyped. Those fixtures bind an ARRAY to the field, which a `text` field would now refuse for its shape, a second reason to pause that is not the provenance verdict they measure. A comment in each says so. ## Verification <details><summary>Reproduction before the change (fresh showcase boot)</summary> ``` resume with an object for a text field -> 400 FLOW_FAILED, run failed, corrected resume 404 RUN_NOT_FOUND resume with 42 for a text field -> completed, '42' stored resume with the value missing -> 400, run still paused ``` </details> <details><summary>Pins and ablations</summary> ``` npx vitest run src/screen-input-contract.test.ts src/builtin/screen-string-field-resume.test.ts Tests 40 passed (40) ablation 1: isString admits any non-null value Tests 16 failed | 24 passed (40) ablation 2: delete the phone member of SCREEN_FIELD_VALUE_DOMAINS × refuses a present non-string on a `phone` field × leaves an absent value and a hidden `phone` field alone × `phone`: an object and a number are refused, the pause survives, and a corrected resume completes Tests 3 failed | 37 passed (40) ``` Both were run through `scripts/ablation-replace.mjs` on the committed tree and restored to the HEAD blob. </details> <details><summary>Suite, typecheck and gates</summary> `pnpm install --frozen-lockfile`, the turbo build, the full service-automation suite (`Test Files 265 passed`, `Tests 3454 passed`) and `pnpm run typecheck` all exit 0. After the contract-text round, on head 4d1aeaf: the spec schemas and reference docs were regenerated (`gen:schema`, `gen:docs`), the spec and service-automation builds and the full suite pass again (3454), and `node scripts/pm/dispatch-gates.mjs --commands` derives 114 families; all 114 run with each exit code captured before any pipe, all exit 0, and `--ran` reconciles: `114 derived famil(ies) accounted for — 114 run, 0 NOT-MEASURED (a DERIVED zero)`. The first round, before the prose, derived 66, all exit 0. The ADR-0087 registration gate went red on the first run because the changeset lacked a disposition; the marker was added in its own commit and the gate, plus every changeset-reading gate, was rerun to exit 0. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kheduG7p1MXMoHHr4)_ <!-- signed: domain:server#3 · os-project-manager · session_01FEkQ5kheduG7p1MXMoHHr4 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bafbc68 commit efbe1a0

11 files changed

Lines changed: 293 additions & 26 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec)!: the screen-field contract names `text`, `textarea`, `email`, `url` and `phone` as the string value domain the resume enforces (#31491)
6+
7+
Clause-②: no (narrowing) — the resume door refuses a non-string under a `type: 'text'`, `'textarea'`, `'email'`, `'url'` or `'phone'` screen field (`@objectstack/service-automation`); this package's `ScreenFieldSpec` and `ScreenFieldConfigSchema` text now states that rule. No schema shape changes.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) Only TSDoc and one describe string change: ScreenFieldConfigSchema and ScreenFieldSpec keep every key and type, so nothing an author wrote is refused at parse time and nothing at rest moves. The narrowing they describe is the resume door's, on a submitted value, which no migration can rewrite. -->
11+
12+
**BREAKING** (described here, enforced in `@objectstack/service-automation`): a `text`, `textarea`, `email`, `url` or `phone` screen field takes a JSON string on resume; an object, number, boolean or array is refused with `invalid_type`, never coerced, and the run stays paused. Only the shape is checked, not the format. `ScreenFieldSpec.type` and `ScreenFieldConfigSchema.type` say so, and the `type` describe text names the five kinds.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation)!: a screen-flow resume refuses a non-string value for a text-like field before the pause is consumed (#31491)
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any authorable schema is added, removed, renamed or re-shaped, and no flow an author wrote is refused. What moves is the answer a screen-flow resume gets when it sends a non-string for a text-like field, and nothing at rest holds that answer, so there is nothing for a migration to rewrite. -->
11+
12+
**BREAKING** accept-set narrowing at the screen-flow resume door (ruling A+).
13+
14+
- **Before.** A `type: 'text'`, `'textarea'`, `'email'`, `'url'` or `'phone'` screen field took any JSON value on resume, and the run consumed its suspension on it. An object then failed the run at the record write downstream, with no pause left: a corrected resume answered `RUN_NOT_FOUND`. A number was stored as the text it prints, a value the caller never sent.
15+
- **Now.** The five kinds are one value domain, a JSON string. A present object, number, boolean or array is refused with `invalid_type` ("must be a string") inside `400 INVALID_SCREEN_INPUT` before the suspension is consumed, so the run stays paused and a corrected resume completes it. A value is not coerced: `42` is not read as `"42"`.
16+
- **Unchanged.** An absent value, `null`, the empty string and a hidden field are not judged, and a missing required field is still refused as missing. Only the shape is checked: the format of an email, URL or phone number is not, and any other refusal of a string downstream keeps the operator restore as its exit. Untyped fields and the other kinds are not affected.
17+
- **Headless callers.** An MCP `run_action` caller that supplies a non-string for one of these fields has not answered the screen, so the screen stays interactive, as it already does for a non-number on a numeric field.
18+
- **For a caller that skips the console.** The resume bag carries a JSON string for these fields; the refusal names the field and says it must be a string.
19+
- **Two console paths now stop at Submit.** The console posts what the screen serves it, so two authored shapes now get the 400 at Submit, with the run still paused: a text-kind field whose `options[].value` is a number or a boolean, when the user picks that option; and a text-kind field whose served `defaultValue` resolves to a non-string, when the user submits it unedited. The author fixes the screen (string option values, a string default) or types the field to match its values. No stored metadata changes, so there is nothing for `objectstack migrate meta` to rewrite.

‎content/docs/automation/flows.mdx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,14 @@ coerced** into `25` and does not reach the bound; it is rejected as the wrong
816816
shape. The bound then compares numbers only, which is all a bound can do. The
817817
console renders all three kinds as a numeric input, which posts a number.
818818

819+
The string kinds are held to their shape the same way. On a `type: 'text'`,
820+
`'textarea'`, `'email'`, `'url'` or `'phone'` field, a submitted value that is
821+
not a JSON string (an object, a number, a boolean, an array) is **refused** on
822+
resume with `invalid_type`; `42` is **not coerced** into `"42"`. Only the shape
823+
is checked, not the format of an email address, URL or phone number. A refused
824+
resume leaves the run paused, so the caller can correct the value and resume
825+
again.
826+
819827
**What these keys refuse:**
820828

821829
- **A bound that is not a number.** `max: '20'` is refused at parse time
@@ -1365,9 +1373,9 @@ POST /api/v1/automation/{flow}/runs/{runId}/resume
13651373

13661374
These conditions are refused, all reported at once and all with
13671375
`code: 'INVALID_SCREEN_INPUT'` (the value shapes of the numeric kinds
1368-
`number` / `currency` / `percent` and of `type: 'file'`, and the `min` / `max`
1369-
bounds, are described with those fields
1370-
above):
1376+
`number` / `currency` / `percent`, of the string kinds `text` / `textarea` /
1377+
`email` / `url` / `phone` and of `type: 'file'`, and the `min` / `max` bounds,
1378+
are described with those fields above):
13711379

13721380
- a **`required` field the caller was actually asked for** is missing (an empty
13731381
or blank string counts as missing);

‎content/docs/references/automation/builtin-node-config.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
289289
| :--- | :--- | :--- | :--- |
290290
| **name** | `string` | ✅ | Field name (the flow variable the value binds to) — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
291291
| **label** | `string` | optional | Display label |
292-
| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `number`, `currency` and `percent` take a finite number on resume (a numeric string is refused); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field; `record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a link to its record page, and takes no input |
292+
| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `number`, `currency` and `percent` take a finite number on resume (a numeric string is refused); `text`, `textarea`, `email`, `url` and `phone` take a string on resume (any other JSON type is refused, never coerced); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field; `record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a link to its record page, and takes no input |
293293
| **required** | `boolean` | optional | Whether a value is required to submit |
294294
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
295295
| **defaultValue** | `any` | optional | Prefilled value: a CEL value envelope `{ dialect: 'cel', source }` evaluated when the screen is served (the client receives the value, never the envelope), or a literal written as it is — a `{…}` template token is refused |
@@ -311,7 +311,7 @@ A value: a CEL value envelope `{ dialect: 'cel', source }` evaluated by the expr
311311
| :--- | :--- | :--- | :--- |
312312
| **name** | `string` | ✅ | Field name (the flow variable the value binds to) — a name without a leading `$` (the `$` names are the flow engine's own), read as `{{ name }}` |
313313
| **label** | `string` | optional | Display label |
314-
| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `number`, `currency` and `percent` take a finite number on resume (a numeric string is refused); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field; `record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a link to its record page, and takes no input |
314+
| **type** | `string` | optional | Input type — an open widget hint (text, number, select, …); `number`, `currency` and `percent` take a finite number on resume (a numeric string is refused); `text`, `textarea`, `email`, `url` and `phone` take a string on resume (any other JSON type is refused, never coerced); `file` renders the platform upload widget and the screen resumes with the uploaded file's id, which a later node writes into a `file` field; `record_link` shows the `reference` record whose id `defaultValue` evaluates to, by its display name, as a link to its record page, and takes no input |
315315
| **required** | `boolean` | optional | Whether a value is required to submit |
316316
| **options** | `{ value: any; label: string }[]` | optional | Choices for a select-style field; option values must stay distinct when read as text |
317317
| **defaultValue** | `any` | optional | Prefilled value: a CEL value envelope `{ dialect: 'cel', source }` evaluated when the screen is served (the client receives the value, never the envelope), or a literal written as it is — a `{…}` template token is refused |

‎packages/services/service-automation/src/builtin/screen-headless-caller-signal.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,10 @@ function twoScreenFlow() {
215215
},
216216
{
217217
id: 'review', type: 'screen', label: 'Review',
218-
config: { fields: [{ name: 'tags', label: 'Tags', type: 'text' }] },
218+
// Untyped on purpose: since #31491 a `text` field is a string
219+
// value domain, and `tags` here is an ARRAY — its shape would
220+
// pause the screen for a reason this test does not measure.
221+
config: { fields: [{ name: 'tags', label: 'Tags' }] },
219222
},
220223
{ id: 'end', type: 'end', label: 'End' },
221224
],

‎packages/services/service-automation/src/builtin/screen-headless-provenance-durable-resume.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,11 @@ function twoScreenFlow(secondField: string) {
8787
id: 'review', type: 'screen', label: 'Review',
8888
// All-optional and single-field: no `required` can force this
8989
// pause, so "it paused" means exactly "nothing was judged
90-
// caller-supplied".
91-
config: { title: 'Review', fields: [{ name: secondField, label: 'Review', type: 'text' }] },
90+
// caller-supplied". Untyped on purpose: since #31491 a `text`
91+
// field is a string value domain, so the ARRAY column this file
92+
// measures would be refused for its shape — a second reason to
93+
// pause that is not the provenance verdict under test.
94+
config: { title: 'Review', fields: [{ name: secondField, label: 'Review' }] },
9295
},
9396
{ id: 'end', type: 'end', label: 'End' },
9497
],
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A screen asks for a string and the node after it writes it (#31491).
5+
*
6+
* The reproduction: the showcase reassign wizard (`screen` → `update_record`).
7+
* A resume that sent an object for its `text` field passed the screen-input
8+
* door, the resume consumed the suspension (#13937's shape-4 order), and the
9+
* record write refused the object — the run ended `failed`, and a corrected
10+
* resume answered `RUN_NOT_FOUND`. A number was stored as text instead.
11+
*
12+
* Ruling A+ (maintainer, 2026-11-08): `text`, `textarea`, `email`, `url` and
13+
* `phone` are string value domains at the door. Pinned here, at the engine,
14+
* for each kind:
15+
* - an object and a number are refused `INVALID_SCREEN_INPUT` before anything
16+
* is written, and the pause survives;
17+
* - a corrected resume then completes the run and writes the string;
18+
* - a missing value is refused as `required`, as before, and the pause survives.
19+
*/
20+
21+
import { describe, it, expect } from 'vitest';
22+
import { AutomationEngine } from '../engine.js';
23+
import { installBuiltinNodes } from './index.js';
24+
25+
function silentLogger(): any {
26+
const l: any = { info() {}, warn() {}, error() {}, debug() {} };
27+
l.child = () => l;
28+
return l;
29+
}
30+
31+
/** A data engine that records every insert's object and fields. */
32+
function fakeData() {
33+
const inserts: Array<{ obj: string; fields: Record<string, unknown> }> = [];
34+
const data: any = {
35+
async insert(obj: string, fields: Record<string, unknown>) {
36+
inserts.push({ obj, fields });
37+
return { id: `${obj}_1` };
38+
},
39+
};
40+
return { data, inserts };
41+
}
42+
43+
const CEL = (source: string) => ({ dialect: 'cel', source });
44+
45+
/** start → screen (one required field of `type`) → create_record (writes it) → end. */
46+
function askFlow(type: string) {
47+
return {
48+
name: 'ask_string',
49+
label: 'Ask a string',
50+
type: 'screen',
51+
status: 'active',
52+
runAs: 'system',
53+
nodes: [
54+
{ id: 'start', type: 'start', label: 'Start' },
55+
{
56+
id: 'ask', type: 'screen', label: 'Ask',
57+
config: { fields: [{ name: 'answer', label: 'Answer', type, required: true }] },
58+
},
59+
{
60+
id: 'write', type: 'create_record', label: 'Write',
61+
config: { objectName: 'reply', fields: { answer: CEL('answer') } },
62+
},
63+
{ id: 'end', type: 'end', label: 'End' },
64+
],
65+
edges: [
66+
{ id: 'e1', source: 'start', target: 'ask' },
67+
{ id: 'e2', source: 'ask', target: 'write' },
68+
{ id: 'e3', source: 'write', target: 'end' },
69+
],
70+
};
71+
}
72+
73+
function makeEngine(type: string) {
74+
const { data, inserts } = fakeData();
75+
const engine = new AutomationEngine(silentLogger());
76+
installBuiltinNodes(engine, { logger: silentLogger(), getService: (n: string) => (n === 'data' ? data : undefined) } as any);
77+
engine.registerFlow('ask_string', askFlow(type) as any);
78+
return { engine, inserts };
79+
}
80+
81+
describe('a screen string field refuses a non-string before the pause is consumed (#31491, ruling A+)', () => {
82+
for (const type of ['text', 'textarea', 'email', 'url', 'phone']) {
83+
it(`\`${type}\`: an object and a number are refused, the pause survives, and a corrected resume completes`, async () => {
84+
const { engine, inserts } = makeEngine(type);
85+
const started = await engine.execute('ask_string', {} as any);
86+
expect(started.status).toBe('paused');
87+
const runId = started.runId!;
88+
89+
for (const bad of [{ x: 1 }, 42]) {
90+
const refused = await engine.resume(runId, { variables: { answer: bad } });
91+
expect(refused.success, JSON.stringify(bad)).toBe(false);
92+
expect(refused.code).toBe('INVALID_SCREEN_INPUT');
93+
expect(refused.error).toContain('Screen field "answer" must be a string');
94+
expect(inserts).toEqual([]);
95+
expect(await engine.hasSuspendedRun(runId)).toBe(true);
96+
}
97+
98+
const resumed = await engine.resume(runId, { variables: { answer: 'alice' } });
99+
expect(resumed.success, resumed.error).toBe(true);
100+
expect(inserts).toEqual([{ obj: 'reply', fields: { answer: 'alice' } }]);
101+
expect(await engine.hasSuspendedRun(runId)).toBe(false);
102+
});
103+
}
104+
105+
it('a missing value is refused as `required`, as before, and the pause survives', async () => {
106+
const { engine, inserts } = makeEngine('text');
107+
const { runId } = await engine.execute('ask_string', {} as any);
108+
const refused = await engine.resume(runId!, { variables: {} });
109+
expect(refused.code).toBe('INVALID_SCREEN_INPUT');
110+
expect(refused.error).toContain('Screen field "answer" is required');
111+
expect(inserts).toEqual([]);
112+
expect(await engine.hasSuspendedRun(runId!)).toBe(true);
113+
});
114+
});

‎packages/services/service-automation/src/screen-input-contract.test.ts‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { describe, expect, it } from 'vitest';
2222

2323
import type { ScreenFieldSpec } from '@objectstack/spec/contracts';
2424

25-
import { validateScreenInputs } from './screen-input-contract.js';
25+
import { judgeHeadlessScreen, validateScreenInputs } from './screen-input-contract.js';
2626

2727
const ALWAYS_VISIBLE = () => true;
2828

@@ -170,9 +170,69 @@ describe('validateScreenInputs — `currency` and `percent` are numeric value do
170170
}
171171

172172
it('does not widen past the numeric kinds — `rating`, `slider`, `progress`, `text` and untyped still take a string', () => {
173+
// `text` is a value domain since #31491, but its domain IS a string.
173174
for (const type of ['rating', 'slider', 'progress', 'text', undefined]) {
174175
const field: ScreenFieldSpec = { name: 'discount', type, min: 0, max: 60 };
175176
expect(validateScreenInputs([field], { discount: '75' }, ALWAYS_VISIBLE), `type ${String(type)}`).toEqual([]);
176177
}
177178
});
178179
});
180+
181+
// [#31491, ruling A+ 2026-11-08] The five string kinds joined the value domain
182+
// on one predicate. Before it, an object or a number under a `text` field
183+
// passed this door, the resume consumed the suspension, and the record write
184+
// after it refused the object (the run failed, a corrected resume found no
185+
// pause) or stored the number as text the caller never sent.
186+
describe('validateScreenInputs — `text`, `textarea`, `email`, `url` and `phone` are string value domains (#31491)', () => {
187+
const STRING_KINDS = ['text', 'textarea', 'email', 'url', 'phone'] as const;
188+
for (const type of STRING_KINDS) {
189+
const field: ScreenFieldSpec = { name: 'answer', type };
190+
const codes = (value: unknown, visibility: () => boolean | undefined = ALWAYS_VISIBLE) =>
191+
validateScreenInputs([field], { answer: value }, visibility).map((i) => i.code);
192+
193+
it(`refuses a present non-string on a \`${type}\` field — an object, a number, a boolean, an array`, () => {
194+
for (const value of [{ x: 1 }, 42, 0, true, ['a']]) {
195+
expect(codes(value), `value ${JSON.stringify(value)}`).toEqual(['invalid_type']);
196+
}
197+
const [issue] = validateScreenInputs([field], { answer: { x: 1 } }, ALWAYS_VISIBLE);
198+
expect(issue!.field).toBe('answer');
199+
expect(issue!.message).toBe('Screen field "answer" must be a string');
200+
});
201+
202+
it(`admits any string on a \`${type}\` field — its format is not judged here`, () => {
203+
for (const value of ['alice', '42', 'not an email', 'x']) {
204+
expect(codes(value), `value ${JSON.stringify(value)}`).toEqual([]);
205+
}
206+
});
207+
208+
it(`leaves an absent value and a hidden \`${type}\` field alone`, () => {
209+
expect(codes(undefined)).toEqual([]);
210+
expect(codes(null)).toEqual([]);
211+
expect(codes('')).toEqual([]);
212+
const conditional: ScreenFieldSpec = { ...field, visibleWhen: 'asked == true' };
213+
expect(validateScreenInputs([conditional], { answer: 42 }, () => false)).toEqual([]);
214+
expect(validateScreenInputs([conditional], { answer: 42 }, () => undefined)).toEqual([]);
215+
// …and the control, or the two readings above prove nothing.
216+
expect(validateScreenInputs([conditional], { answer: 42 }, () => true).map((i) => i.code))
217+
.toEqual(['invalid_type']);
218+
});
219+
}
220+
221+
it('a headless caller that supplies a non-string for a `text` field has not answered it: the screen stays interactive', () => {
222+
// The same contract the resume door enforces (`judgeHeadlessScreen` judges
223+
// with `validateScreenInputs`), so a run that would be refused on resume
224+
// does not continue past the screen on the value either.
225+
const field: ScreenFieldSpec = { name: 'answer', type: 'text', required: true };
226+
const ctx = { params: { answer: 42 }, callerParamKeys: ['answer'] };
227+
expect(judgeHeadlessScreen([field], new Map<string, unknown>([['answer', 42]]), ctx))
228+
.toEqual({ satisfied: false, supplied: ['answer'], missing: ['answer'] });
229+
// …and the control: a string answers it.
230+
expect(judgeHeadlessScreen([field], new Map<string, unknown>([['answer', '42']]), { params: { answer: '42' }, callerParamKeys: ['answer'] }))
231+
.toEqual({ satisfied: true, supplied: ['answer'], missing: [] });
232+
});
233+
234+
it('a missing required string field is still `required` alone, not a shape issue', () => {
235+
const field: ScreenFieldSpec = { name: 'answer', type: 'text', required: true };
236+
expect(validateScreenInputs([field], {}, ALWAYS_VISIBLE).map((i) => i.code)).toEqual(['required']);
237+
});
238+
});

0 commit comments

Comments
 (0)