Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/sim/executor/orchestrators/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,62 @@ describe('LoopOrchestrator', () => {
)
})

describe('while condition reference interpolation', () => {
async function evaluateWhileConditionFor(condition: string, resolved: unknown) {
const resolver = { resolveSingleReference: vi.fn().mockResolvedValue(resolved) }
const orchestrator = new LoopOrchestrator(
{ loopConfigs: new Map(), parallelConfigs: new Map(), nodes: new Map() } as any,
createState(),
resolver as any
)
const ctx = createContext({
iteration: 0,
currentIterationOutputs: new Map(),
allIterationOutputs: [],
loopType: 'while',
condition,
})

await orchestrator.evaluateInitialCondition(ctx, 'loop-1')

return mockExecuteInIsolatedVM.mock.calls[0][0].code as string
}

it('does not let a quote in a resolved value escape into expression position', async () => {
const payload = 'z" ) && Boolean( "INJ".length === 3 ) && Boolean( "never'

const code = await evaluateWhileConditionFor('<start.input> === "never"', payload)

expect(new Function(code)()).toBe(false)
expect(code).toBe(`return Boolean(${JSON.stringify(payload)} === "never")`)
})

it('escapes backslashes and newlines so the condition stays compilable', async () => {
const code = await evaluateWhileConditionFor('<start.input> === "x"', 'a\\"\nb')

expect(() => new Function(code)).not.toThrow()
expect(new Function(code)()).toBe(false)
})

it('escapes line separators that are legal in JSON but not in every JS host', async () => {
const code = await evaluateWhileConditionFor('<start.input> === "x"', 'a\u2028b\u2029c')

expect(code).not.toMatch(/[\u2028\u2029]/)
expect(new Function(code)()).toBe(false)
})

it.each([
['TRUE', 'return Boolean(true)'],
[' false ', 'return Boolean(false)'],
[7, 'return Boolean(7)'],
[true, 'return Boolean(true)'],
[null, 'return Boolean(null)'],
[{ a: 1 }, 'return Boolean({"a":1})'],
])('serializes %o as a literal operand', async (resolved, expected) => {
expect(await evaluateWhileConditionFor('<start.input>', resolved)).toBe(expected)
})
})

it('exits doWhile loops when the configured iteration cap is reached', async () => {
const { orchestrator } = createOrchestrator()
const ctx = createContext({
Expand Down
48 changes: 37 additions & 11 deletions apps/sim/executor/orchestrators/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,42 @@ const logger = createLogger('LoopOrchestrator')

const LOOP_CONDITION_TIMEOUT_MS = 5000

/**
* Serializes a resolved reference value into a JavaScript literal for the loop
* condition expression.
*
* The result is concatenated into source that is compiled and run in the
* execution isolate, so every value must be emitted as a self-contained literal.
* Interpolating a string without escaping would let a `"` in the resolved data
* terminate the literal and continue in expression position.
*/
function formatConditionOperand(resolved: unknown): string {
if (typeof resolved === 'boolean' || typeof resolved === 'number') {
return String(resolved)
}

if (typeof resolved === 'string') {
const lower = resolved.toLowerCase().trim()
if (lower === 'true' || lower === 'false') {
return lower
}
}

return toJsLiteral(resolved)
}

/**
* JSON-serializes a value and escapes the code points that are valid inside a
* JSON string but historically hazardous inside a JavaScript source literal.
*/
function toJsLiteral(value: unknown): string {
const serialized = JSON.stringify(value)
if (serialized === undefined) {
return 'undefined'
}
return serialized.replace(/\u2028/g, String.raw`\u2028`).replace(/\u2029/g, String.raw`\u2029`)
}

async function replaceLoopConditionReferences(
condition: string,
replacer: (match: string) => Promise<string>
Expand Down Expand Up @@ -724,17 +760,7 @@ export class LoopOrchestrator {
resolvedType: resolved === null ? 'null' : typeof resolved,
})
if (resolved !== undefined) {
if (typeof resolved === 'boolean' || typeof resolved === 'number') {
return String(resolved)
}
if (typeof resolved === 'string') {
const lower = resolved.toLowerCase().trim()
if (lower === 'true' || lower === 'false') {
return lower
}
return `"${resolved}"`
}
return JSON.stringify(resolved)
return formatConditionOperand(resolved)
}
return match
})
Expand Down
Loading