Something went wrong. Try again.
Training wheels for the terminal
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774import { describe, it, expect } from "vitest";import { assembleCommand, interpolate, quoteReason, RecipeEvaluation, shellQuote, tokensInArg, tokensInUse, TokenValueLike,} from "./commandAssembly";import { templateText } from "./recipeTemplate";import { Recipe, TokenDef } from "./commandBuilderTypes";
const text = (over: Partial<TokenDef> = {}): TokenDef => ({ type: "text", label: "l", description: "d", placeholder: "", ...over, }) as TokenDef;
const on = (value: unknown): TokenValueLike => ({ value, enabled: true });const off = (value: unknown = ""): TokenValueLike => ({ value, enabled: false });
describe("tokensInArg", () => { it("finds every token in an arg, in order", () => { expect(tokensInArg("scale={{w}}:{{h}}")).toEqual(["w", "h"]); }); it("returns [] for a static flag", () => { expect(tokensInArg("-c:v h264")).toEqual([]); });});
describe("tokensInUse", () => { it("collects tokens across args, de-duped and ordered", () => { const recipe: Recipe = { name: "r", description: "", base: "cmd", args: ["{{a}}", "x={{b}}:{{a}}", "{{c}}"], tokens: { a: text(), b: text(), c: text() }, }; expect(tokensInUse(recipe)).toEqual(["a", "b", "c"]); });});
describe("assembleCommand", () => { it("always includes static (token-free) args", () => { const recipe: Recipe = { name: "r", description: "", base: "ffmpeg", args: ["-hide_banner", "-i {{input}}"], tokens: { input: text({ required: true }) }, }; expect(assembleCommand(recipe, { input: on("in.mp4") })).toBe( "ffmpeg -hide_banner -i in.mp4", ); });
it("drops an optional single-token arg when empty or disabled (regression)", () => { const recipe: Recipe = { name: "r", description: "", base: "cmd", args: ["--flag {{opt}}"], tokens: { opt: text({ required: false }) }, }; expect(assembleCommand(recipe, { opt: on("") })).toBe("cmd"); expect(assembleCommand(recipe, { opt: off("value") })).toBe("cmd"); expect(assembleCommand(recipe, { opt: on("value") })).toBe( "cmd --flag value", ); });
it("drops an arg when a required token it references is unfilled", () => { const recipe: Recipe = { name: "r", description: "", base: "cmd", args: ["-o {{out}}"], tokens: { out: text({ required: true }) }, }; expect(assembleCommand(recipe, { out: on("") })).toBe("cmd"); });
it("supports multiple tokens in one arg (Phase 1)", () => { const recipe: Recipe = { name: "r", description: "", base: "ffmpeg", args: ["-vf scale={{w}}:{{h}}"], tokens: { w: text({ required: false }), h: text({ required: false }) }, }; expect(assembleCommand(recipe, { w: on("800"), h: on("600") })).toBe( "ffmpeg -vf scale=800:600", ); });
it("keeps a multi-token arg when at least one token is filled", () => { const recipe: Recipe = { name: "r", description: "", base: "ffmpeg", args: ["-vf scale={{w}}:{{h}}"], tokens: { w: text(), h: text() }, }; // h empty -> arg kept because w is filled; empty h interpolates to "" expect(assembleCommand(recipe, { w: on("800"), h: on("") })).toBe( "ffmpeg -vf scale=800:", ); // both empty -> arg dropped entirely expect(assembleCommand(recipe, { w: on(""), h: on("") })).toBe("ffmpeg"); });
it("drops a multi-token arg if any of its required tokens is unfilled", () => { const recipe: Recipe = { name: "r", description: "", base: "ffmpeg", args: ["-vf scale={{w}}:{{h}}"], tokens: { w: text({ required: true }), h: text({ required: false }) }, }; expect(assembleCommand(recipe, { w: on(""), h: on("600") })).toBe("ffmpeg"); });
it("quotes path values with spaces and applies the injected pathTransform", () => { const recipe: Recipe = { name: "r", description: "", base: "cmd", args: ["{{in}}"], tokens: { in: { type: "open", label: "l", description: "d", required: true, filters: [], } as TokenDef, }, }; const out = assembleCommand( recipe, { in: on("/abs/My Videos/clip.mp4") }, { pathTransform: (p) => p.replace("/abs/", "./") }, ); expect(out).toBe("cmd './My Videos/clip.mp4'"); });
it("interpolates a select token's chosen value, and drops it when optional+disabled", () => { const recipe: Recipe = { name: "r", description: "", base: "ffmpeg", args: ["-i in.mp4", "-c:v {{codec}}", "out.mp4"], tokens: { codec: { type: "select", label: "Codec", description: "d", required: false, options: [ { label: "H.264", value: "libx264" }, { label: "H.265", value: "libx265" }, ], } as TokenDef, }, }; expect(assembleCommand(recipe, { codec: on("libx265") })).toBe( "ffmpeg -i in.mp4 -c:v libx265 out.mp4", ); expect(assembleCommand(recipe, { codec: off("libx264") })).toBe( "ffmpeg -i in.mp4 out.mp4", ); });
it("assembles a composite token from sub-fields, collapsing empty ones", () => { const recipe: Recipe = { name: "r", description: "", base: "magick", args: ["in.png", "-resize {{geometry}}", "out.png"], tokens: { geometry: { type: "composite", label: "Resize", description: "d", required: false, template: "{{w}}x{{h}}{{flag}}", fields: { w: { type: "number", label: "W", description: "" }, h: { type: "number", label: "H", description: "" }, flag: { type: "select", label: "Mode", description: "", options: [ { label: "Fit", value: "" }, { label: "Exact", value: "!" }, ], }, }, } as TokenDef, }, }; // full geometry -- the "!" flag forces shell-quoting of the whole value expect( assembleCommand(recipe, { geometry: on({ w: "800", h: "600", flag: "!" }) }), ).toBe("magick in.png -resize '800x600!' out.png"); // height blank collapses to "" -> still valid ImageMagick expect( assembleCommand(recipe, { geometry: on({ w: "800", h: "", flag: "" }) }), ).toBe("magick in.png -resize 800x out.png"); // all sub-fields empty -> the whole arg drops (no "-resize x") expect( assembleCommand(recipe, { geometry: on({ w: "", h: "", flag: "" }) }), ).toBe("magick in.png out.png"); // disabled -> dropped regardless of contents expect( assembleCommand(recipe, { geometry: off({ w: "800", h: "600", flag: "" }) }), ).toBe("magick in.png out.png"); });
it("ignores stale token values not defined on the recipe", () => { const recipe: Recipe = { name: "r", description: "", base: "cmd", args: ["{{a}}"], tokens: { a: text() }, }; expect( assembleCommand(recipe, { a: on("x"), ghost: on("should-not-appear") }), ).toBe("cmd x"); });
it("quotes a value with a shell metacharacter per the target shell", () => { const recipe: Recipe = { name: "r", description: "", base: "magick", args: ["-resize {{geo}}"], tokens: { geo: text({ required: true }) }, }; // '>' is a redirection operator in every shell -> must be quoted expect(assembleCommand(recipe, { geo: on("800x600>") })).toBe( "magick -resize '800x600>'", ); expect( assembleCommand(recipe, { geo: on("800x600>") }, { shell: "powershell" }), ).toBe("magick -resize '800x600>'"); });});
describe("shellQuote", () => { it("leaves safe values unquoted", () => { expect(shellQuote("libx264")).toBe("libx264"); expect(shellQuote("800x600")).toBe("800x600"); expect(shellQuote("C:/Users/me/out.png")).toBe("C:/Users/me/out.png"); expect(shellQuote("")).toBe(""); });
it("quotes whitespace and shell metacharacters", () => { expect(shellQuote("my file.mp4")).toBe("'my file.mp4'"); expect(shellQuote("800x600>")).toBe("'800x600>'"); expect(shellQuote("a|b")).toBe("'a|b'"); expect(shellQuote("not(x)")).toBe("'not(x)'"); });
it("escapes an embedded single quote per shell family", () => { expect(shellQuote("it's", "posix")).toBe(`'it'\\''s'`); expect(shellQuote("it's", "powershell")).toBe("'it''s'"); });});
describe("shellQuote per shell family", () => { it("leaves commas unquoted in POSIX but quotes them in PowerShell", () => { expect(shellQuote("16,32,64", "posix")).toBe("16,32,64"); expect(shellQuote("16,32,64", "powershell")).toBe("'16,32,64'"); });
it("leaves backslash paths unquoted in PowerShell only", () => { expect(shellQuote("C:\\Users\\me\\in.mp4", "powershell")).toBe( "C:\\Users\\me\\in.mp4", ); expect(shellQuote("C:\\Users\\me\\in.mp4", "posix")).toBe( "'C:\\Users\\me\\in.mp4'", ); });
it("leaves printf-style patterns unquoted", () => { expect(shellQuote("frame_%04d.png", "posix")).toBe("frame_%04d.png"); expect(shellQuote("frame_%04d.png", "powershell")).toBe("frame_%04d.png"); });
it("quotes PowerShell words that misparse despite safe characters", () => { // `@x` is splatting; `-foo.bar` is split at the dot by PowerShell 5.1. expect(shellQuote("@x", "powershell")).toBe("'@x'"); expect(shellQuote("-Dfoo.bar=1", "powershell")).toBe("'-Dfoo.bar=1'"); // ...but ordinary flags and `--long.opt` stay bare. expect(shellQuote("-c:v", "powershell")).toBe("-c:v"); expect(shellQuote("--flag=value.x", "powershell")).toBe("--flag=value.x"); expect(shellQuote("black@0.5", "powershell")).toBe("black@0.5"); });
it("quotes a leading = for zsh", () => { expect(shellQuote("=ls", "posix")).toBe("'=ls'"); });
it("escapes embedded double quotes C-runtime style for PowerShell 5.1", () => { expect(shellQuote('a"b', "powershell")).toBe(`'a\\"b'`); expect(shellQuote('say "hi there"', "powershell")).toBe( `'say \\"hi there\\"'`, ); // backslashes before a quote are doubled, then the quote escaped expect(shellQuote('x\\"y', "powershell")).toBe(`'x\\\\\\"y'`); // POSIX needs none of this expect(shellQuote('a"b', "posix")).toBe(`'a"b'`); });
it("doubles a trailing backslash when PowerShell will wrap the word in quotes", () => { expect(shellQuote("C:\\dir with space\\", "powershell")).toBe( "'C:\\dir with space\\\\'", ); // no whitespace -> PowerShell adds no quotes -> nothing to protect expect(shellQuote("C:\\dir\\", "powershell")).toBe("C:\\dir\\"); });});
describe("interpolate", () => { it("keeps backslashes verbatim (ffmpeg filter escapes, Windows paths)", () => { expect(interpolate("select=not(mod(n\\,{{n}}))", { n: "90" })).toBe( "select=not(mod(n\\,90))", ); expect(interpolate("C:\\Users\\{{u}}", { u: "me" })).toBe("C:\\Users\\me"); expect(interpolate("text='%{pts\\:hms}'", {})).toBe("text='%{pts\\:hms}'"); });
it("renders missing tokens as empty", () => { expect(interpolate("{{a}}-{{missing}}", { a: "1" })).toBe("1-"); });});
const recipeOf = ( args: Recipe["args"], tokens: Recipe["tokens"], base = "cmd",): Recipe => ({ name: "r", description: "", base, args, tokens });
const flag = (over: Partial<TokenDef> = {}): TokenDef => ({ type: "flag", label: "l", description: "d", ...over }) as TokenDef;
const select = (values: string[], over: Partial<TokenDef> = {}): TokenDef => ({ type: "select", label: "l", description: "d", options: values.map((v) => ({ label: v, value: v })), ...over, }) as TokenDef;
const directory = (over: Partial<TokenDef> = {}): TokenDef => ({ type: "directory", label: "l", description: "d", ...over }) as TokenDef;
describe("word-level quoting", () => { it("quotes a token glued to literal text as one word (PowerShell splits 'a b'/c)", () => { const recipe = recipeOf(["{{dir}}/frame_%04d.png"], { dir: directory({ required: true }), }); expect( assembleCommand( recipe, { dir: on("C:\\My Videos\\frames") }, { shell: "powershell" }, ), ).toBe("cmd 'C:\\My Videos\\frames/frame_%04d.png'"); expect(assembleCommand(recipe, { dir: on("/tmp/My Frames") })).toBe( "cmd '/tmp/My Frames/frame_%04d.png'", ); });
it("quotes static words containing shell metacharacters", () => { const recipe = recipeOf( ["-vf select=not(mod(n\\,{{n}}))", "-fps_mode vfr"], { n: text({ required: true }) }, "ffmpeg", ); expect(assembleCommand(recipe, { n: on("90") })).toBe( "ffmpeg -vf 'select=not(mod(n\\,90))' -fps_mode vfr", ); });
it("applies pathTransform to directory tokens", () => { const recipe = recipeOf(["{{dir}}/f.png"], { dir: directory() }); expect( assembleCommand( recipe, { dir: on("/home/me/out") }, { pathTransform: () => "./out" }, ), ).toBe("cmd ./out/f.png"); });
it("drops words that interpolate to nothing", () => { const recipe = recipeOf(["{{a}} {{b}}"], { a: text(), b: text() }); expect(assembleCommand(recipe, { a: on("x"), b: on("") })).toBe("cmd x"); });});
describe("tokensInUse with when conditions", () => { it("includes tokens referenced only from when", () => { const recipe = recipeOf( ["-c:v {{codec}}", { arg: "-an", when: { "no-audio": true } }], { codec: select(["a"]), "no-audio": flag() }, ); expect(tokensInUse(recipe)).toEqual(["codec", "no-audio"]); });});
describe("when conditions", () => { const recipe = recipeOf( [ "-c:v {{codec}}", { arg: "-crf {{crf}}", when: { codec: ["libx264", "libx265"] } }, { arg: "-tag:v hvc1", when: { codec: "libx265" } }, { arg: "-an", when: { "no-audio": true } }, { arg: "-c:a aac", when: { "no-audio": false } }, ], { codec: select(["libx264", "libx265", "libsvtav1"], { required: true }), crf: text(), "no-audio": flag(), }, "ffmpeg", );
it("includes args whose condition matches a select value", () => { expect( assembleCommand(recipe, { codec: on("libx265"), crf: on("28"), "no-audio": off(), }), ).toBe("ffmpeg -c:v libx265 -crf 28 -tag:v hvc1 -c:a aac"); });
it("drops args whose condition doesn't match", () => { expect( assembleCommand(recipe, { codec: on("libsvtav1"), crf: on("35"), "no-audio": on(""), }), ).toBe("ffmpeg -c:v libsvtav1 -an"); });
it("treats a flag as filled only while switched on", () => { expect( assembleCommand(recipe, { codec: on("libx264"), crf: on("23"), "no-audio": on(""), }), ).toBe("ffmpeg -c:v libx264 -crf 23 -an"); });
it("a value condition never matches a disabled token", () => { const r = recipeOf([{ arg: "-x", when: { mode: "a" } }], { mode: select(["a", "b"]), }); expect(assembleCommand(r, { mode: off("a") })).toBe("cmd"); expect(assembleCommand(r, { mode: on("a") })).toBe("cmd -x"); });});
describe("visibleTokens", () => { const recipe = recipeOf( [ "-c:v {{codec}}", { arg: "-preset {{x264-preset}}", when: { codec: "libx264" } }, { arg: "-preset {{av1-preset}}", when: { codec: "libsvtav1" } }, { arg: "-an", when: { "no-audio": true } }, ], { codec: select(["libx264", "libsvtav1"], { required: true }), "x264-preset": text(), "av1-preset": text(), "no-audio": flag(), }, );
it("hides tokens whose only args are switched off by another token", () => { const ev = new RecipeEvaluation(recipe, { codec: on("libx264"), "x264-preset": on("medium"), "av1-preset": on("8"), "no-audio": off(), }); expect(ev.visibleTokens()).toEqual(["codec", "x264-preset", "no-audio"]); });
it("orders tokens as the recipe defines them, not by argument order", () => { const r = recipeOf( [ { arg: "-skip_frame nokey", when: { mode: "keys" } }, "-i {{input}}", ], { input: text(), mode: select(["keys", "all"]) }, ); expect( new RecipeEvaluation(r, { input: on(""), mode: on("all") }).visibleTokens(), ).toEqual(["input", "mode"]); });
it("keeps a flag visible while it is switching its own arg off", () => { const ev = new RecipeEvaluation(recipe, { codec: on("libsvtav1"), "no-audio": off(), }); expect(ev.visibleTokens()).toEqual(["codec", "av1-preset", "no-audio"]); });
it("respects conditions on enclosing args for tokens inside parts", () => { const r = recipeOf( [ { arg: "-vf", join: ",", when: { mode: "scale" }, parts: ["scale={{w}}:-2"], }, "-m {{mode}}", ], { mode: select(["scale", "none"]), w: text() }, ); expect( new RecipeEvaluation(r, { mode: on("none"), w: on("") }).visibleTokens(), ).toEqual(["mode"]); expect( new RecipeEvaluation(r, { mode: on("scale"), w: on("") }).visibleTokens(), ).toEqual(["mode", "w"]); });});
describe("missingRequired", () => { const recipe = recipeOf( [ "-i {{input}}", { arg: "-vf select=not(mod(n\\,{{step}}))", when: { mode: "frames" } }, { arg: "-vf fps=1/{{seconds}}", when: { mode: "seconds" } }, "{{out}}", ], { input: text({ required: true }), mode: select(["frames", "seconds"], { required: true }), step: text({ required: true }), seconds: text({ required: true }), out: text({ required: false }), }, );
it("lists visible required tokens that are empty", () => { const ev = new RecipeEvaluation(recipe, { input: on(""), mode: on("frames"), step: on(""), seconds: on(""), out: on(""), }); // `seconds` is required but hidden by `mode`, so it isn't missing. expect(ev.missingRequired()).toEqual(["input", "step"]); });
it("is empty once everything visible is filled", () => { const ev = new RecipeEvaluation(recipe, { input: on("in.mp4"), mode: on("seconds"), step: on(""), seconds: on("5"), out: on(""), }); expect(ev.missingRequired()).toEqual([]); });});
describe("joined parts (filter chains)", () => { const recipe = recipeOf( [ "-i in.mp4", { arg: "-vf", join: ",", parts: [ "fps=({{cols}}*{{rows}})/{{duration}}", "scale={{width}}:-1", { arg: "drawtext=text='%{pts\\:hms}':x=5:y=h-th-5", when: { timestamps: true }, }, "tile={{cols}}x{{rows}}", ], }, "-frames:v 1", "{{out}}", ], { cols: text({ required: true }), rows: text({ required: true }), duration: text({ required: true }), width: text(), timestamps: flag(), out: text({ required: true }), }, "ffmpeg", ); const base = { cols: on("4"), rows: on("3"), duration: on("20.5"), width: on("320"), timestamps: off(), out: on("sheet.png"), };
it("joins the active parts into one quoted word", () => { expect(assembleCommand(recipe, base)).toBe( "ffmpeg -i in.mp4 -vf 'fps=(4*3)/20.5,scale=320:-1,tile=4x3' -frames:v 1 sheet.png", ); });
it("drops an optional part that is empty or switched off", () => { expect(assembleCommand(recipe, { ...base, width: on("") })).toBe( "ffmpeg -i in.mp4 -vf 'fps=(4*3)/20.5,tile=4x3' -frames:v 1 sheet.png", ); });
it("includes a part gated by a flag, escaping its quotes per shell", () => { expect( assembleCommand(recipe, { ...base, timestamps: on("") }, { shell: "powershell", }), ).toBe( "ffmpeg -i in.mp4 -vf 'fps=(4*3)/20.5,scale=320:-1,drawtext=text=''%{pts\\:hms}'':x=5:y=h-th-5,tile=4x3' -frames:v 1 sheet.png", ); expect(assembleCommand(recipe, { ...base, timestamps: on("") })).toBe( "ffmpeg -i in.mp4 -vf 'fps=(4*3)/20.5,scale=320:-1,drawtext=text='\\''%{pts\\:hms}'\\'':x=5:y=h-th-5,tile=4x3' -frames:v 1 sheet.png", ); });
it("drops the whole arg when no part survives", () => { const r = recipeOf( [{ arg: "-vf", join: ",", parts: ["scale={{w}}:-2", "{{extra}}"] }], { w: text(), extra: text() }, ); expect(assembleCommand(r, { w: on(""), extra: off("x") })).toBe("cmd"); expect(assembleCommand(r, { w: on("640"), extra: off("x") })).toBe( "cmd -vf scale=640:-2", ); });
it("displays the template without parts switched off by `when`", () => { // width is empty (so its part is dropped from the command) but still shown; // timestamps is off, so its part is hidden. const ev = new RecipeEvaluation(recipe, { ...base, width: on("") }); expect(templateText(ev)).toBe( "ffmpeg -i in.mp4 -vf fps=({{cols}}*{{rows}})/{{duration}},scale={{width}}:-1,tile={{cols}}x{{rows}} -frames:v 1 {{out}}", ); });
it("displays args still waiting for required values", () => { const ev = new RecipeEvaluation(recipe, { ...base, duration: on(""), out: on(""), }); expect(templateText(ev)).toContain("fps=({{cols}}*{{rows}})/{{duration}}"); expect(templateText(ev)).toMatch(/ \{\{out\}\}$/); });
it("attaches nested parts directly, without a space", () => { const r = recipeOf( [ { arg: "-vf", join: ",", parts: [ "scale={{w}}:-1", { arg: "tile=", join: ":", parts: ["{{c}}x{{r}}", "padding={{gap}}", "margin={{gap}}"], }, ], }, ], { w: text(), c: text(), r: text(), gap: text() }, ); const values = { w: on("320"), c: on("4"), r: on("3"), gap: on("") }; const ev = new RecipeEvaluation(r, values); expect(templateText(ev)).toBe( "cmd -vf scale={{w}}:-1,tile={{c}}x{{r}}:padding={{gap}}:margin={{gap}}", ); // and in the command, empty nested parts drop out expect(ev.command()).toBe("cmd -vf scale=320:-1,tile=4x3"); });});
describe("quoteReason", () => { it("gives no reason for a word that isn't quoted", () => { expect(quoteReason("--no-playlist", "powershell")).toBeUndefined(); expect(quoteReason("res:1080,vcodec:h264", "posix")).toBeUndefined(); });
it("explains a comma in PowerShell", () => { expect(quoteReason("res:1080,vcodec:h264", "powershell")).toBe( "has a comma, which PowerShell reads as a list", ); });
it("explains spaces before anything else", () => { expect(quoteReason("My Videos/a&b", "powershell")).toMatch(/^has a space/); });
it("names the character and what it means to this shell", () => { const url = "https://youtube.com/watch?v=x&t=42s"; expect(quoteReason(url, "powershell")).toBe( "has a &, which PowerShell doesn't allow in plain text", ); expect(quoteReason(url, "posix")).toBe( 'has a &, which the shell reads as "run this in the background"', ); expect(quoteReason("https://youtube.com/watch?v=x", "powershell")).toBe( "has a ?, which some shells read as a wildcard matching file names", ); });
it("explains PowerShell's dash-dot split", () => { expect(quoteReason("-foo.bar", "powershell")).toBe( "starts with a dash and has a dot, which PowerShell splits in two", ); });
it("gives every quoted word a reason", () => { for (const shell of ["powershell", "posix"] as const) { for (const word of ["a(b)", "é", "{x}", "a\b", "=x", "@x", "'"]) { expect(quoteReason(word, shell) === undefined).toBe( shellQuote(word, shell) === word, ); } } });});
describe("RecipeEvaluation.quotedWords", () => { it("lists each quoted word once, with its reason", () => { const r = recipeOf(["-S a,b", "{{url}}", "{{url}}"], { url: text() }); const ev = new RecipeEvaluation( r, { url: on("https://x.com/?v=1") }, { shell: "powershell" }, ); expect(ev.quotedWords()).toEqual([ { quoted: "'a,b'", reason: "has a comma, which PowerShell reads as a list" }, { quoted: "'https://x.com/?v=1'", reason: "has a ?, which some shells read as a wildcard matching file names", }, ]); });});