mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-29 08:12:56 +00:00
* fix(frontend): keep nested template literals intact in template inputs Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: fail a flow step with an unresolvable $args tag instead of hanging Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): surface input expression errors when running a step test Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): treat an escaped \${ as literal text when escaping backticks Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: accept the string "null" as a tag component, reject only JSON null Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: leave a same_worker step's inert tag alone, log an unresolved flow tag Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): escape every backtick when the template walk desynchronizes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: leave a dedicated runnable's inert step tag alone Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: reroute a step only when its own tag is what failed to resolve Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor: name the inert-tag guard step_is_pulled_by_tag Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: reject a tag only when it interpolates to nothing at all Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): validate the template walk instead of trusting a balanced stack Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: describe what an unresolvable tag actually interpolates to Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(frontend): decide template escaping with a real parser, not a hand-rolled scan Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor: name is_flow_step on push now that it is load-bearing Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): heal an expression escaped before nested templates were handled Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: reroute a step whose tag reads args that failed to evaluate Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: never hand a job that failed before running to a dedicated runner Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: reroute only a step whose args failed, leave other tags untouched Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: drop the post-preprocessor tag fallback, leaving tag resolution untouched Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor: leave interpolate_args exactly as it was Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: use a generic example in the template literal tests Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): show an expression escaped by the old rule as it was authored Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): surface input expression errors from every step-run entry point Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs: state what is_dedicated_worker actually reads Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): heal only text whose backticks were all escaped by the old rule Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): match the old rule textually so an authored backslash still heals Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(frontend): heal only expressions the old rule broke, never ones that parse Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
134 lines
5.7 KiB
TypeScript
134 lines
5.7 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { escapeTemplateBackticks, unescapeTemplateBackticks } from './templateLiteral'
|
|
|
|
// Template mode stores its value as a JS template literal, so backticks in the text have to be
|
|
// escaped. Escaping them inside `${...}` too is what broke nested template literals: a backslash
|
|
// is a syntax error in expression position.
|
|
describe('escapeTemplateBackticks', () => {
|
|
const nested =
|
|
'--input ${flow_input.iter.value}${results.config ? ` --config ${results.config}` : ""} --host ${flow_input.hostname}'
|
|
|
|
it('leaves a nested template literal inside an interpolation intact', () => {
|
|
const expr = '`' + escapeTemplateBackticks(nested) + '`'
|
|
expect(expr).not.toContain('\\`')
|
|
expect(
|
|
new Function('flow_input', 'results', 'return ' + expr)(
|
|
{ iter: { value: 'data.csv' }, hostname: 'host1' },
|
|
{ config: '/tmp/cfg.json' }
|
|
)
|
|
).toBe('--input data.csv --config /tmp/cfg.json --host host1')
|
|
})
|
|
|
|
it('still escapes a backtick in the literal text', () => {
|
|
expect(escapeTemplateBackticks('a ` b')).toBe('a \\` b')
|
|
expect(escapeTemplateBackticks('a ` ${x} ` b')).toBe('a \\` ${x} \\` b')
|
|
})
|
|
|
|
it('leaves an escaped interpolation as literal text', () => {
|
|
// `\\${...}` is escaped in the template source, so the backticks inside it are literal
|
|
// text and still need escaping.
|
|
expect(escapeTemplateBackticks('\\${foo `bar`}')).toBe('\\${foo \\`bar\\`}')
|
|
expect(unescapeTemplateBackticks('\\${foo \\`bar\\`}')).toBe('\\${foo `bar`}')
|
|
expect(
|
|
() => new Function('return `' + escapeTemplateBackticks('\\${foo `bar`}') + '`')
|
|
).not.toThrow()
|
|
})
|
|
|
|
// Braces, quotes, regex literals and comments all hide backticks and braces from anything
|
|
// short of a real lexer, which is why the parser decides.
|
|
it('handles text only a lexer can read correctly', () => {
|
|
const inputs = [
|
|
'${ x["}"] } `',
|
|
"${ f({ a: '`' }) } `",
|
|
"${ x.replace(/'/g, '') } `",
|
|
'${ /* ` */ x } `',
|
|
'{"match": ${/{/.test(flow_input.x)}, "literal": "`x`"}'
|
|
]
|
|
for (const v of inputs) {
|
|
expect(() => new Function('return `' + escapeTemplateBackticks(v) + '`')).not.toThrow()
|
|
expect(unescapeTemplateBackticks(escapeTemplateBackticks(v))).toBe(v)
|
|
}
|
|
})
|
|
|
|
// The failure that matters most is not a syntax error but literal text quietly becoming code:
|
|
// here the author's `+ flow_input.y +` must stay text rather than being evaluated.
|
|
it('never lets literal text escape into the expression', () => {
|
|
const v = '{"match": ${/{/.test(flow_input.x)}, "literal": "` + flow_input.y + `"}'
|
|
const evaluated = new Function('flow_input', 'return `' + escapeTemplateBackticks(v) + '`')({
|
|
x: 'x',
|
|
y: 'LEAKED'
|
|
})
|
|
expect(evaluated).not.toContain('LEAKED')
|
|
expect(evaluated).toContain('` + flow_input.y + `')
|
|
})
|
|
|
|
// An expression the old blanket rule broke — it escaped backticks inside `${...}`, which
|
|
// does not parse — comes back as the author typed it, instead of showing the backslashes
|
|
// and escaping them one deeper on every save.
|
|
it('heals an expression the old rule broke', () => {
|
|
const broken = '-p ${a}${b ? \\` --x ${c}\\` : ""}'
|
|
const clean = '-p ${a}${b ? ` --x ${c}` : ""}'
|
|
expect(unescapeTemplateBackticks(broken)).toBe(clean)
|
|
expect(escapeTemplateBackticks(clean)).toBe(clean)
|
|
})
|
|
|
|
// A text that already parses is left alone: an over-escaped legacy value and a backslash the
|
|
// author wrote are the same bytes, so healing on looks alone would drop a real character.
|
|
it('leaves an expression that already parses alone', () => {
|
|
const run = (body: string) => new Function('return `' + body + '`')()
|
|
for (const stored of ['${"\\`"}', '${"a\\\\`"}']) {
|
|
expect(unescapeTemplateBackticks(stored)).toBe(stored)
|
|
expect(run(escapeTemplateBackticks(unescapeTemplateBackticks(stored)))).toBe(run(stored))
|
|
}
|
|
})
|
|
|
|
// ...but a backtick escaped inside a nested template belongs there and must survive.
|
|
it('leaves an escaped backtick that is inside a nested template alone', () => {
|
|
const stored = '${cond ? `a\\`b` : ""}'
|
|
expect(unescapeTemplateBackticks(stored)).toBe(stored)
|
|
})
|
|
|
|
// Escapes the author wrote inside a nested template are not the old rule's doing, and
|
|
// stripping them changes what the expression means — here into chained tagged templates,
|
|
// which throw. Only a text whose backticks are *all* escaped came from the old rule.
|
|
it('leaves escapes that belong to a nested template alone', () => {
|
|
const run = (body: string) => new Function('flag', 'value', 'return `' + body + '`')(true, 'X')
|
|
for (const stored of ['${flag ? `\\`\\`${value}\\`\\`` : ""}', '${flag ? `a\\`b` : ""}']) {
|
|
expect(unescapeTemplateBackticks(stored)).toBe(stored)
|
|
expect(escapeTemplateBackticks(unescapeTemplateBackticks(stored))).toBe(stored)
|
|
expect(run(stored)).toBe(run(escapeTemplateBackticks(unescapeTemplateBackticks(stored))))
|
|
}
|
|
})
|
|
|
|
it('round-trips through unescapeTemplateBackticks', () => {
|
|
for (const v of [
|
|
nested,
|
|
'a ` b',
|
|
'${ x["}"] } `',
|
|
'plain',
|
|
'${a}${b}',
|
|
'\\${a}',
|
|
'${cond ? `a\\`b` : ""}'
|
|
]) {
|
|
expect(unescapeTemplateBackticks(escapeTemplateBackticks(v))).toBe(v)
|
|
}
|
|
})
|
|
// The guarantee that matters: opening a flow in the editor and saving it back must not change
|
|
// the stored expression, including for a value that only the all-or-nothing fallback can
|
|
// handle.
|
|
it('never rewrites the stored expression on a view/save cycle', () => {
|
|
const inputs = [
|
|
'{"match": ${/{/.test(flow_input.x)}, "literal": "`x`"}',
|
|
'${cond ? `a\\`b` : ""}',
|
|
"${ x.replace(/'/g, '') } `",
|
|
'a ` b',
|
|
'${ /* ` */ x } `'
|
|
]
|
|
for (const v of inputs) {
|
|
const stored = escapeTemplateBackticks(v)
|
|
expect(() => new Function('return `' + stored + '`')).not.toThrow()
|
|
expect(escapeTemplateBackticks(unescapeTemplateBackticks(stored))).toBe(stored)
|
|
}
|
|
})
|
|
})
|