mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-04 08:02:23 +00:00
* 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>
277 lines
9.9 KiB
TypeScript
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'
|
|
})
|
|
})
|
|
})
|