Files
windmill/frontend/src/lib/components/job_args.test.ts
T
AlexRV12andClaude Opus 5 a6abf2c8a7 feat: run and test scripts from the AI chat through an argument form (#11001)
* fix: disable a dynamic input when its schema field is disabled

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* refactor: extract the run form's argument hygiene into job_args

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: give Tabs an opt-in sliding selection indicator

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* refactor: share the chat's scroll-fade measurement

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: give the chat a run-form contract and incremental job output

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: run and test a script from the chat through an argument form

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: carry a chat run's card and job across saves and reloads

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: render a chat run as a tool call row with its form, logs and result

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: open a pending run form in the sessions preview pane

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* test: benchmark running a deployed script from the chat

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: offer a test run's dynamic options from the draft it previews

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say that a test run's dynselect helper executes on form display

* fix: send a schema default the model omitted when yolo skips the form

* fix: infer a test run's schema when the stored one declares no properties

* docs: tighten the note on the form's mount-time helper job

* fix: apply a nested schema default the bypass posture counts as answered

* fix: apply a declared default to a null value and an optional nested field

* fix: check required fields inside a supplied optional object before bypassing

* fix: read required args as own properties before bypassing the form

* fix: stop the turn from the run form's action row in the preview panel

* refactor: drop the run-form prediction and share its secret minting

* refactor: prefill a proposed secret instead of emptying the field

* docs: correct the comments the run-form prediction left behind

* fix: keep a proposed secret out of the chat's stored messages

* docs: say what a literal secret argument now does

* test: restore the copilotInfo export the aiStore mock omits

* docs: cut the run form's helper-script note to its constraints

* refactor: settle a run form from one entry and fetch a job's logs once

* fix: separate colliding secret paths, gate plan mode, keep polled logs

* fix: mint before the form opens, skip empty fields, show what ran

* revert: mint a run form's secrets at submit, not before it opens

* fix: settle a cancelled run card on the form's arguments, not the proposal

* fix: settle a stopped run form like a cancelled one, and keep an empty secret empty

* fix: snapshot a run's arguments before minting its secrets

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 12:31:58 +00:00

277 lines
9.9 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
coerceArgsToSchema,
enforceDisabledDefaults,
redactFileArgs,
redactSecretArgs
} from './job_args'
describe('coerceArgsToSchema', () => {
// A scalar widget renders its own reading of a wrong-typed value and never writes that
// reading back, so an untouched form submits something it never displayed: a number
// input paints `"12"` as a filled-looking 12, and a toggle shows `"false"` as on.
it('converts a value its widget would read, so the form shows what runs', () => {
const schema = {
properties: {
count: { type: 'number' },
flag: { type: 'boolean' },
label: { type: 'string' },
name: { type: 'string' }
}
}
const { args, clearedKeys } = coerceArgsToSchema(
{ count: '12', flag: 'false', label: 3, name: 'ada' },
schema
)
expect(args).toEqual({ count: 12, flag: false, label: '3', name: 'ada' })
expect(clearedKeys).toEqual([])
})
// Cleared, not carried: the widget shows nothing for these, so nothing is what an
// untouched form should send.
it('empties a value with no reading in its declared type', () => {
const schema = {
properties: {
count: { type: 'number' },
flag: { type: 'boolean' },
label: { type: 'string' }
}
}
const { args, clearedKeys } = coerceArgsToSchema(
{ count: 'abc', flag: 'maybe', label: { a: 1 } },
schema
)
expect(args).toEqual({})
expect(clearedKeys.sort()).toEqual(['count', 'flag', 'label'])
})
// A field the schema does not name is a field no run surface in the product draws, so a
// value under that name would reach the job without anyone having been able to see it.
// `constructor` is declared by every object through its prototype and by no schema.
it('drops arguments the schema does not declare, naming them', () => {
const kept = coerceArgsToSchema({ a: 'keep', b: 2, constructor: 'x' }, {
properties: { a: { type: 'string' } }
} as any)
expect(kept.args).toEqual({ a: 'keep' })
expect(kept.undeclaredKeys).toEqual(['b', 'constructor'])
// Declaring nothing is declaring no arguments, which is what a `**kwargs` script and a
// schema that failed to infer both look like.
expect(coerceArgsToSchema({ a: 1 }, undefined).args).toEqual({})
})
// Resolved by the job, so the declared type describes what it receives and never the
// string standing in for it. `Number('$var:…')` is NaN, so coercing would destroy it.
it('leaves a variable or resource reference in any slot', () => {
const schema = {
properties: {
size: { type: 'number' },
on: { type: 'boolean' },
db: { type: 'object', format: 'resource-postgresql' }
}
}
const { args, clearedKeys } = coerceArgsToSchema(
{ size: '$var:u/admin/size', on: '$var:u/admin/on', db: '$res:u/admin/pg' },
schema
)
expect(args).toEqual({
size: '$var:u/admin/size',
on: '$var:u/admin/on',
db: '$res:u/admin/pg'
})
expect(clearedKeys).toEqual([])
})
// Not merely unreadable: `MultiSelect` maps over the value as it renders, so anything
// else throws and takes the whole card down, Cancel with it. A reference is no
// exception — the widget draws before anything resolves — so this slot is the one
// place the reference rule above does not hold.
it('empties a non-array in a dyn-multiselect slot, reference included', () => {
const schema = { properties: { tags: { type: 'object', format: 'dynmultiselect-list' } } }
expect(coerceArgsToSchema({ tags: ['a'] }, schema).args).toEqual({ tags: ['a'] })
for (const bad of [{ a: 1 }, '$var:u/admin/watchlist']) {
const { args, clearedKeys } = coerceArgsToSchema({ tags: bad }, schema)
expect(args).toEqual({})
expect(clearedKeys).toEqual(['tags'])
}
})
// Below the top the form has the same limitations as everywhere else in the product,
// and descending means resolving `oneOf` branches — where being wrong rewrites what the
// user typed into the branch they did open.
it('leaves nested and container values to the widget that renders them', () => {
const schema = {
properties: {
obj: { type: 'object', properties: { known: { type: 'string' } } },
rows: { type: 'array', items: { type: 'object' } }
}
}
const { args, clearedKeys } = coerceArgsToSchema(
{ obj: { known: 1, extra: 'b' }, rows: { id: 'x' } },
schema
)
expect(args).toEqual({ obj: { known: 1, extra: 'b' }, rows: { id: 'x' } })
expect(clearedKeys).toEqual([])
})
// Both sides parsed, never written as literals: `__proto__:` in an object literal is
// the prototype setter, so a literal declares nothing to coerce in the first place.
it('keeps a declared __proto__ instead of losing it to the setter', () => {
const { args } = coerceArgsToSchema(
JSON.parse('{"__proto__":"legit","keep":1}'),
JSON.parse('{"properties":{"__proto__":{"type":"string"},"keep":{"type":"number"}}}')
)
expect(Object.hasOwn(args, '__proto__')).toBe(true)
expect(args['__proto__']).toBe('legit')
})
})
describe('enforceDisabledDefaults', () => {
const schema = {
properties: {
locked: { type: 'string', disabled: true, default: 'fixed' },
open: { type: 'string' }
}
}
it('overwrites a disabled field and reports only what it changed', () => {
expect(enforceDisabledDefaults({ locked: 'mine', open: 'ok' }, schema)).toEqual({
args: { locked: 'fixed', open: 'ok' },
resetKeys: ['locked']
})
// Never supplied is not overwritten: the field shows the default either way, and a
// caller told otherwise would try to correct what it never sent.
expect(enforceDisabledDefaults({ open: 'ok' }, schema)).toEqual({
args: { locked: 'fixed', open: 'ok' },
resetKeys: []
})
})
it('reports no reset for an object default the caller already matched', () => {
const objSchema = {
properties: { conf: { type: 'object', disabled: true, default: { a: 1 } } }
}
expect(enforceDisabledDefaults({ conf: { a: 1 } }, objSchema).resetKeys).toEqual([])
})
})
describe('secret args at every level the form nests', () => {
const schema = {
properties: {
top: { type: 'string', password: true },
obj: { properties: { inner: { type: 'string', password: true } } },
list: { items: { properties: { secret: { type: 'string', password: true } } } },
either: {
oneOf: [
{ title: 'a', properties: { key: { type: 'string', password: true } } },
{ title: 'b', properties: { other: { type: 'string', password: true } } }
]
}
}
}
const args = {
top: 'hunter2',
obj: { inner: '$var:u/ada/prod', keep: 1 },
list: [{ secret: 'one', name: 'a' }, { secret: 'two' }],
// Tagged as branch 'a', but 'b' is stripped too: the tag is runtime state.
either: { kind: 'a', key: 'k', other: 'o' }
}
it('redacts every value and keeps every reference', () => {
const redacted = JSON.stringify(redactSecretArgs(args, schema))
for (const secret of ['hunter2', 'one', 'two', '"k"', '"o"']) {
expect(redacted).not.toContain(secret)
}
expect(redacted).toContain('<hidden>')
expect(redacted).toContain('"name":"a"')
expect(redacted).toContain('$var:u/ada/prod')
})
// ArgInput synthesises '' for every untouched string, so marking one would put a hidden
// value on the card for a field nobody filled in — and mint nothing to back it.
it('leaves an empty secret empty', () => {
expect(
redactSecretArgs({ tok: '' }, { properties: { tok: { type: 'string', password: true } } })
).toEqual({ tok: '' })
})
it('reaches a secret under a oneOf branch of an array element', () => {
const oneOfItems = {
properties: {
steps: {
type: 'array',
items: {
oneOf: [{ title: 'push', properties: { token: { type: 'string', password: true } } }]
}
}
}
}
expect(redactSecretArgs({ steps: [{ token: 'hunter2', name: 'a' }] }, oneOfItems)).toEqual({
steps: [{ token: '<hidden>', name: 'a' }]
})
})
// The walk descends on the value's shape: routing an array down `properties` because the
// declaration carries that key would visit none of its elements, leaving the secret in
// the persisted card verbatim.
it('reaches through a declaration carrying both items and properties', () => {
const both = {
properties: {
creds: {
type: 'array',
items: { properties: { token: { type: 'string', password: true } } },
properties: { token: { type: 'string', password: true } }
}
}
}
expect(redactSecretArgs({ creds: [{ token: 'hunter2' }] }, both)).toEqual({
creds: [{ token: '<hidden>' }]
})
})
// A container shaped unlike its declaration is kept, so the walk has to reach in through
// the half the declaration does carry — on the value's shape alone it stops at the
// mismatch, leaving the secret there for the persisted card and the model to read.
it('reaches through a container shaped unlike its declaration', () => {
const declaresArray = {
properties: {
rows: { type: 'array', items: { properties: { token: { password: true } } } }
}
}
expect(redactSecretArgs({ rows: { token: 'hunter2' } }, declaresArray)).toEqual({
rows: { token: '<hidden>' }
})
const declaresObject = {
properties: { cfg: { type: 'object', properties: { token: { password: true } } } }
}
expect(redactSecretArgs({ cfg: [{ token: 'hunter2' }] }, declaresObject)).toEqual({
cfg: [{ token: '<hidden>' }]
})
})
})
describe('redactFileArgs', () => {
const schema = {
properties: {
doc: { type: 'string', contentEncoding: 'base64' },
pics: { type: 'array', items: { type: 'string', contentEncoding: 'base64' } },
wrap: { properties: { inner: { type: 'string', contentEncoding: 'base64' } } },
note: { type: 'string' }
}
}
it('replaces the bytes with a size marker at every level, and keeps the rest', () => {
const oneMeg = 'A'.repeat(1024 * 1024 * 2)
const redacted = redactFileArgs(
{ doc: oneMeg, pics: ['B'.repeat(4096)], wrap: { inner: 'C'.repeat(2048) }, note: 'hi' },
schema
)
expect(redacted).toEqual({
doc: '<file: 1.5 MB>',
pics: ['<file: 3 KB>'],
wrap: { inner: '<file: 2 KB>' },
note: 'hi'
})
})
})