Skip to content

Commit a3bca71

Browse files
Merge remote-tracking branch 'origin/staging' into feat/credential-v2-api
2 parents 96ef2bd + 5a88ce2 commit a3bca71

21 files changed

Lines changed: 2326 additions & 43 deletions

apps/docs/content/docs/en/integrations/ashby.mdx

Lines changed: 272 additions & 12 deletions
Large diffs are not rendered by default.

apps/sim/blocks/blocks/ashby.test.ts

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,252 @@ describe('AshbyBlock', () => {
7878
expect(alternateEmailAddresses?.wandConfig?.generationType).not.toBe('json-object')
7979
expect(socialLinks?.wandConfig?.generationType).not.toBe('json-object')
8080
})
81+
82+
it('does not force braces or brackets on the polymorphic fieldValue', () => {
83+
// fieldValue legitimately takes a bare boolean, number, string, or null,
84+
// so neither the 'json-object' nor the 'json-array' reinforcement applies -
85+
// both would make the wand emit a wrapper the field must not receive.
86+
const fieldValue = AshbyBlock.subBlocks.find((s) => s.id === 'fieldValue')
87+
expect(fieldValue?.wandConfig?.enabled).toBe(true)
88+
expect(fieldValue?.wandConfig?.generationType).toBeUndefined()
89+
})
90+
91+
it('requests array output for fieldValues, whose contract is a JSON array', () => {
92+
const fieldValues = AshbyBlock.subBlocks.find((s) => s.id === 'fieldValues')
93+
expect(fieldValues?.wandConfig?.generationType).toBe('json-array')
94+
})
95+
})
96+
97+
describe('fieldValue parsing (set_custom_field_value)', () => {
98+
const parse = (fieldValue: unknown) =>
99+
AshbyBlock.tools.config.params!(buildParams('set_custom_field_value', { fieldValue }))
100+
.fieldValue
101+
102+
it('decodes null so the annotation can be cleared', () => {
103+
// Ashby clears a custom field when it receives an explicit null, which is
104+
// what makes a written annotation reversible.
105+
expect(parse('null')).toBeNull()
106+
})
107+
108+
it('decodes booleans and numbers for Boolean and Number fields', () => {
109+
expect(parse('true')).toBe(true)
110+
expect(parse('42')).toBe(42)
111+
})
112+
113+
it('decodes a JSON array for MultiValueSelect fields', () => {
114+
expect(parse('["Remote","Hybrid"]')).toEqual(['Remote', 'Hybrid'])
115+
})
116+
117+
it('decodes a JSON object for Currency and range fields', () => {
118+
expect(parse('{"value":150000,"currencyCode":"USD"}')).toEqual({
119+
value: 150000,
120+
currencyCode: 'USD',
121+
})
122+
})
123+
124+
it('passes unparseable text through as a plain string', () => {
125+
// A bare option name is the most common input for String, LongText, and
126+
// ValueSelect fields, so it must not be rejected as invalid JSON.
127+
expect(parse('Senior Engineer')).toBe('Senior Engineer')
128+
})
129+
130+
it('decodes a quoted numeric string back to a string', () => {
131+
// The escape hatch for a String field whose value looks like a number.
132+
expect(parse('"123"')).toBe('123')
133+
})
134+
135+
it('does not let an overflowing number become a field clear', () => {
136+
// 1e999 parses to Infinity, which JSON.stringify emits as null - and null
137+
// clears the field. The user typed a number, not a clear.
138+
expect(parse('1e999')).toBe('1e999')
139+
})
140+
141+
it('does not silently lose precision on long numeric ids', () => {
142+
expect(parse('12345678901234567890')).toBe('12345678901234567890')
143+
expect(parse('0123')).toBe('0123')
144+
})
145+
146+
it('leaves prose that merely starts like JSON alone when it does not parse', () => {
147+
expect(parse('{not really json')).toBe('{not really json')
148+
})
149+
150+
it('passes an already-parsed value through untouched', () => {
151+
// An upstream block reference resolves to a real value, not to text.
152+
expect(parse({ value: 1 })).toEqual({ value: 1 })
153+
expect(parse(false)).toBe(false)
154+
})
155+
156+
it('leaves fieldValue alone for other operations', () => {
157+
const result = AshbyBlock.tools.config.params!(
158+
buildParams('list_jobs', { fieldValue: 'Senior Engineer' })
159+
)
160+
expect(result.fieldValue).toBeUndefined()
161+
})
162+
})
163+
164+
describe('fieldValues parsing (set_custom_field_values)', () => {
165+
it('maps the fieldValues subBlock onto the tool’s values param', () => {
166+
const result = AshbyBlock.tools.config.params!(
167+
buildParams('set_custom_field_values', {
168+
fieldValues: '[{"fieldId":"abc","fieldValue":"High"}]',
169+
})
170+
)
171+
expect(result.values).toEqual([{ fieldId: 'abc', fieldValue: 'High' }])
172+
expect(result.fieldValues).toBeUndefined()
173+
})
174+
175+
it('throws instead of silently dropping the writes when the JSON is malformed', () => {
176+
expect(() =>
177+
AshbyBlock.tools.config.params!(
178+
buildParams('set_custom_field_values', { fieldValues: 'not json' })
179+
)
180+
).toThrow(/Invalid JSON in Ashby custom field values/)
181+
})
182+
183+
it('throws when the parsed JSON is not an array', () => {
184+
expect(() =>
185+
AshbyBlock.tools.config.params!(
186+
buildParams('set_custom_field_values', { fieldValues: '{"fieldId":"abc"}' })
187+
)
188+
).toThrow(/expected a JSON array/)
189+
})
190+
})
191+
192+
describe('change_application_source', () => {
193+
it('emits sourceId as undefined when the field is left blank', () => {
194+
// The key must be PRESENT and undefined, not absent. The executor merges
195+
// `{ ...inputs, ...transformedParams }`, so an absent key inherits whatever
196+
// inputs held - which is exactly how a stale create-path sourceId used to
197+
// leak in. Presence is what overrides it.
198+
const result = AshbyBlock.tools.config.params!(
199+
buildParams('change_application_source', { applicationId: 'app-1', changeSourceId: '' })
200+
)
201+
expect(result).toHaveProperty('sourceId')
202+
expect(result.sourceId).toBeUndefined()
203+
expect(result).not.toHaveProperty('unsetSource')
204+
})
205+
206+
it('passes unsetSource through only when the switch is on', () => {
207+
const result = AshbyBlock.tools.config.params!(
208+
buildParams('change_application_source', { changeSourceId: '', unsetSource: 'true' })
209+
)
210+
expect(result.unsetSource).toBe(true)
211+
})
212+
213+
it('never sends a stale source id alongside a clear request', () => {
214+
// The Source ID field is hidden once the clear switch is on, but a value
215+
// typed beforehand is still stored. Sending both would trip the tool's
216+
// exclusivity guard and surface as an error the user cannot see the cause of.
217+
const result = AshbyBlock.tools.config.params!(
218+
buildParams('change_application_source', {
219+
changeSourceId: 'src-left-over',
220+
unsetSource: 'true',
221+
})
222+
)
223+
expect(result.unsetSource).toBe(true)
224+
expect(result).toHaveProperty('sourceId')
225+
expect(result.sourceId).toBeUndefined()
226+
})
227+
228+
it('never inherits a stale create-path source id through the executor merge', () => {
229+
// The executor runs `{ ...inputs, ...transformedParams }`, so any key this
230+
// mapping leaves unset inherits whatever inputs held. The shared
231+
// create-path `sourceId` subblock reaches inputs even on this operation:
232+
// it is mode 'advanced', and the serializer includes an advanced subblock
233+
// on a non-empty value without evaluating its condition. Assert the merged
234+
// result, not just the mapping, since that gap is where the bug lived.
235+
const merge = (inputs: Record<string, unknown>) => ({
236+
...inputs,
237+
...AshbyBlock.tools.config.params!(inputs),
238+
})
239+
240+
const cleared = merge(
241+
buildParams('change_application_source', {
242+
applicationId: 'app-1',
243+
sourceId: 'stale-from-create-application',
244+
changeSourceId: '',
245+
unsetSource: 'true',
246+
})
247+
)
248+
expect(cleared.sourceId).toBeUndefined()
249+
expect(cleared.unsetSource).toBe(true)
250+
251+
const untouched = merge(
252+
buildParams('change_application_source', {
253+
applicationId: 'app-1',
254+
sourceId: 'stale-from-create-application',
255+
changeSourceId: '',
256+
})
257+
)
258+
expect(untouched.sourceId).toBeUndefined()
259+
260+
const explicit = merge(
261+
buildParams('change_application_source', {
262+
applicationId: 'app-1',
263+
sourceId: 'stale-from-create-application',
264+
changeSourceId: 'src-intended',
265+
})
266+
)
267+
expect(explicit.sourceId).toBe('src-intended')
268+
})
269+
270+
it('hides the source id field while the clear switch is on', () => {
271+
const sourceField = AshbyBlock.subBlocks.find((s) => s.id === 'changeSourceId')
272+
const condition = sourceField?.condition as { and?: { field: string; not?: boolean } }
273+
expect(condition.and).toEqual({ field: 'unsetSource', value: true, not: true })
274+
})
275+
276+
it('maps a provided source id onto sourceId', () => {
277+
const result = AshbyBlock.tools.config.params!(
278+
buildParams('change_application_source', { changeSourceId: 'src-1' })
279+
)
280+
expect(result.sourceId).toBe('src-1')
281+
})
282+
283+
it('does not emit a null sourceId for other operations', () => {
284+
// create_candidate treats an absent source as "no source", so a null here
285+
// would turn an omitted optional field into an explicit write.
286+
const result = AshbyBlock.tools.config.params!(buildParams('create_candidate', {}))
287+
expect(result).not.toHaveProperty('sourceId')
288+
})
289+
})
290+
291+
describe('operation and tool registration stay in sync', () => {
292+
it('has a matching ashby_<operation> tool in access for every dropdown option', () => {
293+
// tools.config.tool is a bare `ashby_${operation}` concat, so a dropdown
294+
// option without a matching tool id resolves to a tool that does not exist.
295+
const operation = AshbyBlock.subBlocks.find((s) => s.id === 'operation')
296+
const optionIds = (operation?.options as Array<{ id: string }>).map((o) => o.id)
297+
const access = new Set(AshbyBlock.tools.access)
298+
const missing = optionIds.filter((id) => !access.has(`ashby_${id}`))
299+
expect(missing).toEqual([])
300+
})
301+
302+
it('has a dropdown option for every tool listed in access', () => {
303+
const operation = AshbyBlock.subBlocks.find((s) => s.id === 'operation')
304+
const optionIds = new Set(
305+
(operation?.options as Array<{ id: string }>).map((o) => `ashby_${o.id}`)
306+
)
307+
const unreachable = AshbyBlock.tools.access!.filter((id) => !optionIds.has(id))
308+
expect(unreachable).toEqual([])
309+
})
310+
311+
it('has a canvas sentence for every dropdown option', () => {
312+
const operation = AshbyBlock.subBlocks.find((s) => s.id === 'operation')
313+
const optionIds = (operation?.options as Array<{ id: string }>).map((o) => o.id)
314+
const sentences = AshbyBlock.canvasPresentation?.sentences?.byOperation ?? {}
315+
const missing = optionIds.filter((id) => !(id in sentences))
316+
expect(missing).toEqual([])
317+
})
318+
})
319+
320+
describe('list_jobs incremental sync', () => {
321+
it('offers the syncToken field on list_jobs', () => {
322+
// Without a sync token every scheduled run rescans the full req set.
323+
const syncToken = AshbyBlock.subBlocks.find((s) => s.id === 'syncToken')
324+
const condition = syncToken?.condition as { value: string[] }
325+
expect(condition.value).toContain('list_jobs')
326+
})
81327
})
82328

83329
describe('list_applications candidateId filter', () => {

0 commit comments

Comments
 (0)