Files
windmill/frontend/src/lib/components/secretArgUtils.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

131 lines
4.8 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const created: { path: string; value: string; is_secret?: boolean }[] = []
vi.mock('$lib/gen', () => ({
VariableService: {
createVariable: vi.fn(async ({ requestBody }: any) => {
created.push(requestBody)
return requestBody.path
})
}
}))
vi.mock('$lib/stores', async () => {
const { writable } = await import('svelte/store')
return { workspaceStore: writable('test-ws'), userStore: writable({ username: 'ada' }) }
})
import { processSecretArgs } from './secretArgUtils'
describe('processSecretArgs', () => {
beforeEach(() => (created.length = 0))
const schema = {
properties: {
token: { type: 'string', password: true },
creds: { type: 'object', password: true, properties: { user: { type: 'string' } } },
nested: { type: 'object', properties: { inner: { type: 'string', password: true } } },
plain: { type: 'string' }
}
} as any
// Nothing else turns a proposed secret into a reference when no form mounts, so a literal
// left alone here is a plaintext credential stored on the job for anyone who can see it.
it('mints a reference for a literal at every level, leaving other arguments alone', async () => {
const out = await processSecretArgs(
{ token: 'hunter2', creds: { user: 'ada' }, nested: { inner: 'deep' }, plain: 'kept' },
schema
)
expect(out.token).toMatch(/^\$var:u\/ada\/secret_arg\//)
expect(out.creds).toMatch(/^\$jsonvar:u\/ada\/secret_arg\//)
expect(out.nested.inner).toMatch(/^\$var:u\/ada\/secret_arg\//)
expect(out.plain).toBe('kept')
// The object goes into the variable as JSON, which is what `$jsonvar:` parses back.
expect(created.map((c) => c.value).sort()).toEqual(['deep', 'hunter2', '{"user":"ada"}'])
expect(created.every((c) => c.is_secret)).toBe(true)
})
it('leaves a reference the caller already named alone', async () => {
const out = await processSecretArgs(
{ token: '$var:f/team/api_token', creds: '$jsonvar:u/ada/existing' },
schema
)
expect(out).toEqual({ token: '$var:f/team/api_token', creds: '$jsonvar:u/ada/existing' })
expect(created).toEqual([])
})
// `$var:` hands the job the variable's text; a field declaring an object needs it parsed,
// which is the same variable read the other way rather than a secret the caller cannot see.
it('reads a plain variable as JSON where the field cannot hold a string', async () => {
const out = await processSecretArgs({ creds: '$var:u/ada/stripe' }, schema)
expect(out.creds).toBe('$jsonvar:u/ada/stripe')
expect(created).toEqual([])
})
// Reached by every run form in the product, not only the ones a chat opens.
it('leaves an absent, null or empty secret alone', async () => {
expect(await processSecretArgs({ token: null, plain: 'kept' }, schema)).toEqual({
token: null,
plain: 'kept'
})
expect(await processSecretArgs({ token: '', plain: 'kept' }, schema)).toEqual({
token: '',
plain: 'kept'
})
expect(created).toEqual([])
})
// A property name can itself contain a dot. Reported under one label these two leaves
// would share a mint, and the flat field would run on the nested field's secret.
it("tells apart a key that spells another key's path", async () => {
const out = await processSecretArgs({ 'db.password': 'FLAT', db: { password: 'NESTED' } }, {
properties: {
'db.password': { type: 'string', password: true },
db: {
type: 'object',
properties: { password: { type: 'string', password: true } }
}
}
} as any)
const flat = out['db.password'].slice('$var:'.length)
const nested = out.db.password.slice('$var:'.length)
expect(flat).not.toBe(nested)
expect(created.find((c) => c.path === flat)?.value).toBe('FLAT')
expect(created.find((c) => c.path === nested)?.value).toBe('NESTED')
})
// Callers bind a form that stays editable while the mints are in flight, and a leaf is
// addressed by its path: a row moved between the two walks would take the other row's
// reference and the job would run it on the wrong credentials.
it('ignores the caller mutating the arguments while minting', async () => {
const rows = {
creds: [
{ name: 'alpha', secret: 'FIRST' },
{ name: 'beta', secret: 'SECOND' }
]
}
const arraySchema = {
properties: {
creds: {
type: 'array',
items: { properties: { name: {}, secret: { password: true } } }
}
}
} as any
const pending = processSecretArgs(rows, arraySchema)
rows.creds.reverse()
const out = await pending
// Keyed by the row's own name, not its index: a substitution by position lands the
// first-minted reference on index 0 either way.
const secretOf = (name: string) => {
const row = out.creds.find((c: any) => c.name === name)
return created.find((c) => c.path === row.secret.slice('$var:'.length))?.value
}
expect(secretOf('alpha')).toBe('FIRST')
expect(secretOf('beta')).toBe('SECOND')
})
})