Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366// Table-driven tests for parseScript, written from docs/design/script-format.md only.// Every row carries an `oracle`: the spec sentence or example that decides the expected value.import { describe, expect, it } from "vitest";import { parseScript } from "../../runner/parser";
type ErrExpect = { code: string; line: number; message: unknown };type Expectation = | { script: unknown } | { errors: ErrExpect[] } | { total: true } | { rejects: string };interface Row { name: string; oracle: string; text: string; expect: Expectation;}
interface Result { ok: boolean; script?: unknown; errors?: { code: string; line: number; message: string }[];}
const L = (...lines: string[]): string => lines.join("\n") + "\n";const wrap = (...cmds: string[]): string => L('test "t"', 'step "s"', ...cmds.map((c) => " " + c));const tree = (...commands: object[]) => ({ name: "t", covers: [], steps: [{ name: "s", line: 2, commands }],});const has = (fragment: string) => expect.stringContaining(fragment);const anyMsg = expect.any(String);
const rows: Row[] = [];const v = (name: string, oracle: string, text: string, script: unknown) => rows.push({ name, oracle, text, expect: { script } });const e = (name: string, oracle: string, text: string, ...errors: ErrExpect[]) => rows.push({ name, oracle, text, expect: { errors } });const tot = (name: string, oracle: string, text: string) => rows.push({ name, oracle, text, expect: { total: true } });const rj = (name: string, oracle: string, text: string, code: string) => rows.push({ name, oracle, text, expect: { rejects: code } });/** one command on line 3 of the standard wrapper */const c1 = (name: string, oracle: string, cmd: string, command: object) => v(name, oracle, wrap(cmd), tree({ ...command, line: 3 }));
// ---------------------------------------------------------------- full exampleconst FULL = L( 'test "send 1 MALLBUCKS to a handle"', " covers J-09 J-13", " start https://mallbucks.malldao.xyz/", " needs session test-qian", "", ' step "remember the starting balance"', ' capture before css:".balance"', ' step "find the recipient"', ' click text:"Send"', ' type placeholder:"Search" "alex"', ' click text~:"@distributed.camp"', ' step "review shows the right amount"', ' type label:"Amount" "1"', ' click role:button name:"Send"', ' verify text:"1 MALLBUCKS" within:"Review send"', ' step "balance goes down by one within 5 seconds"', ' verify css:".balance" number:changed-by from:before equals:-1 timeout:5s',);const FULL_TREE = { name: "send 1 MALLBUCKS to a handle", covers: ["J-09", "J-13"], start: "https://mallbucks.malldao.xyz/", startLine: 3, needsSession: "test-qian", needsLine: 4, steps: [ { name: "remember the starting balance", line: 6, commands: [ { verb: "capture", line: 7, name: "before", target: { css: ".balance" } }, ] }, { name: "find the recipient", line: 8, commands: [ { verb: "click", line: 9, target: { text: "Send" } }, { verb: "type", line: 10, target: { placeholder: "Search" }, text: "alex" }, { verb: "click", line: 11, target: { "text~": "@distributed.camp" } }, ] }, { name: "review shows the right amount", line: 12, commands: [ { verb: "type", line: 13, target: { label: "Amount" }, text: "1" }, { verb: "click", line: 14, target: { role: "button", name: "Send" } }, { verb: "verify", line: 15, kind: "text", value: "1 MALLBUCKS", within: "Review send" }, ] }, { name: "balance goes down by one within 5 seconds", line: 16, commands: [ { verb: "verify", line: 17, kind: "numberChange", target: { css: ".balance" }, from: "before", equals: -1, timeoutMs: 5000 }, ] }, ],};const EX = "Example script at the end of the Example section";v("full example script", EX, FULL, FULL_TREE);v("full example with CRLF line endings", "Lines are trimmed; CRLF is a line ending, same tree as " + EX, FULL.replace(/\n/g, "\r\n"), FULL_TREE);v("full example with tabs instead of indentation", "indentation is cosmetic", FULL.replace(/^ +/gm, "\t"), FULL_TREE);v("full example with no indentation", "indentation is cosmetic", FULL.replace(/^ +/gm, ""), FULL_TREE);
// ---------------------------------------------------------------- headersconst HEADER_STEP = ['step "s"', " click text:\"A\""];const hdr = (...h: string[]) => L('test "t"', ...h, ...HEADER_STEP);const stepAt = (line: number) => [{ name: "s", line, commands: [{ verb: "click", line: line + 1, target: { text: "A" } }] }];v("minimal script has covers [] and no other headers", "A header that was not written is absent; covers is [] when absent", hdr(), { name: "t", covers: [], steps: stepAt(2) });v("covers with one id", 'header := "covers" ID+', hdr("covers J-09"), { name: "t", covers: ["J-09"], steps: stepAt(3) });v("covers with several ids", "covers J-09 J-13", hdr("covers J-09 J-13 AB-7"), { name: "t", covers: ["J-09", "J-13", "AB-7"], steps: stepAt(3) });v("start header", "start/startLine on the script, not a command", hdr("start https://a.example/x"), { name: "t", covers: [], start: "https://a.example/x", startLine: 2, steps: stepAt(3) });v("start header accepts http", "start needs an http or https URL", hdr("start http://localhost:8080/p"), { name: "t", covers: [], start: "http://localhost:8080/p", startLine: 2, steps: stepAt(3) });v("needs session header", 'needs session NAME; needsSession/needsLine', hdr("needs session test-qian"), { name: "t", covers: [], needsSession: "test-qian", needsLine: 2, steps: stepAt(3) });v("timeout header in seconds", "A timeout header is timeoutMs on the script", hdr("timeout 10s"), { name: "t", covers: [], timeoutMs: 10000, steps: stepAt(3) });v("timeout header in ms", "DURATION := NUMBER (ms|s|m)", hdr("timeout 500ms"), { name: "t", covers: [], timeoutMs: 500, steps: stepAt(3) });v("timeout header in minutes", "DURATION := NUMBER (ms|s|m)", hdr("timeout 1m"), { name: "t", covers: [], timeoutMs: 60000, steps: stepAt(3) });v("all headers together", "Example section header block", hdr("covers J-01", "start https://x.example/", "needs session s1", "timeout 2s"), { name: "t", covers: ["J-01"], start: "https://x.example/", startLine: 3, needsSession: "s1", needsLine: 4, timeoutMs: 2000, steps: stepAt(6),});v("test name keeps a hash inside the string", "# starts a comment (not inside a string)", L('test "a # b"', ...HEADER_STEP), { name: "a # b", covers: [], steps: stepAt(2) });
// ---------------------------------------------------------------- verbsc1("start as a command", "start: open the URL in the test tab (header form also allowed)", "start https://a.example/", { verb: "start", url: "https://a.example/" });c1("goto", "Verb table: start, goto -> url", "goto https://a.example/p?x=1", { verb: "goto", url: "https://a.example/p?x=1" });c1("click", "Verb table: click -> target", 'click text:"Send"', { verb: "click", target: { text: "Send" } });c1("doubleClick", "Verb table: doubleClick -> target", 'doubleClick text:"Send"', { verb: "doubleClick", target: { text: "Send" } });c1("check", "Verb table: check -> target", 'check label:"Agree"', { verb: "check", target: { label: "Agree" } });c1("uncheck", "Verb table: uncheck -> target", 'uncheck label:"Agree"', { verb: "uncheck", target: { label: "Agree" } });c1("type", "type TARGET \"text\" -> target, text", 'type label:"Amount" "1"', { verb: "type", target: { label: "Amount" }, text: "1" });c1("press", "press KEY -> key", "press Enter", { verb: "press", key: "Enter" });c1("press ArrowDown", "press KEY: Enter Tab Escape ArrowDown", "press ArrowDown", { verb: "press", key: "ArrowDown" });c1("select", 'select TARGET "option text" -> target, option', 'select label:"Country" "United Kingdom"', { verb: "select", target: { label: "Country" }, option: "United Kingdom" });c1("sleep seconds", "`sleep 2s` is { verb: \"sleep\", ms: 2000 }", "sleep 2s", { verb: "sleep", ms: 2000 });c1("sleep milliseconds", "DURATION ms", "sleep 250ms", { verb: "sleep", ms: 250 });c1("sleep minutes", "DURATION m", "sleep 1m", { verb: "sleep", ms: 60000 });c1("capture", "capture NAME TARGET -> name, target", 'capture before css:".balance"', { verb: "capture", name: "before", target: { css: ".balance" } });c1("set", 'set NAME "value" -> name, value', 'set total "5"', { verb: "set", name: "total", value: "5" });c1("switchTab by url", 'tab: { kind: "url", contains }', 'switchTab url~:"/pay"', { verb: "switchTab", tab: { kind: "url", contains: "/pay" } });c1("switchTab new", 'tab: { kind: "new" }', "switchTab new", { verb: "switchTab", tab: { kind: "new" } });c1("closeTab", "closeTab -> (none)", "closeTab", { verb: "closeTab" });c1("drag", 'drag text:"Card" to:text:"Done" (destination straight after to:)', 'drag text:"Card" to:text:"Done"', { verb: "drag", target: { text: "Card" }, to: { text: "Done" } });c1("screenshot", 'screenshot "name" -> name', 'screenshot "after send"', { verb: "screenshot", name: "after send" });c1("call with no args", "call NAME arg* -> name, args: string[]", "call checkTotals", { verb: "call", name: "checkTotals", args: [] });c1("call with mixed args", "args: strings, numbers and names, as written, without quotes", 'call checkTotals 5 "hello world" foo', { verb: "call", name: "checkTotals", args: ["5", "hello world", "foo"] });c1("run", "run path/to/other.aite -> path", "run path/to/other.aite", { verb: "run", path: "path/to/other.aite" });
// ---------------------------------------------------------------- targetsc1("target role and name", "role:button name:\"Send\" is { role: \"button\", name: \"Send\" }", 'click role:button name:"Send"', { verb: "click", target: { role: "button", name: "Send" } });c1("target contains key", 'text~:"Sen" is { "text~": "Sen" }', 'click text~:"Sen"', { verb: "click", target: { "text~": "Sen" } });c1("target index is a string", 'index:2 is { index: "2" }', 'click text:"Send" index:2', { verb: "click", target: { text: "Send", index: "2" } });c1("target bare value", "VALUE := STRING | BARE; BARE = letters, digits, - _ . /", "click testid:send-btn", { verb: "click", target: { testid: "send-btn" } });c1("target xpath and css keys", "Targets table", 'click xpath:"//button[2]"', { verb: "click", target: { xpath: "//button[2]" } });c1("css value with hash is not a comment", "# starts a comment to end of line (not inside a string)", 'click css:"#main .btn"', { verb: "click", target: { css: "#main .btn" } });c1("target value escape", "STRING escapes \\\" resolved", 'click css:"a[href=\\"x\\"]"', { verb: "click", target: { css: 'a[href="x"]' } });c1("placeholder key", "Targets table: placeholder", 'click placeholder:"Search"', { verb: "click", target: { placeholder: "Search" } });
// ---------------------------------------------------------------- modifiersc1("command timeout in seconds", "timeoutMs only when the command wrote timeout:, in ms", 'click text:"A" timeout:5s', { verb: "click", target: { text: "A" }, timeoutMs: 5000 });c1("command timeout in ms", "DURATION ms", 'click text:"A" timeout:500ms', { verb: "click", target: { text: "A" }, timeoutMs: 500 });c1("command timeout in minutes", "DURATION m", 'click text:"A" timeout:1m', { verb: "click", target: { text: "A" }, timeoutMs: 60000 });c1("as modifier", "as:NAME is accepted after any command and stored as `as`", 'click text:"A" as:btn', { verb: "click", target: { text: "A" }, as: "btn" });
// ---------------------------------------------------------------- verify / verifyNotconst vf = (name: string, oracle: string, cmd: string, fields: object, pre?: string) => { const cmds = pre ? [pre, cmd] : [cmd]; const verb = cmd.startsWith("verifyNot") ? "verifyNot" : "verify"; const commands = pre ? [{ verb: "capture", line: 3, name: "before", target: { css: ".balance" } }, { verb, line: 4, ...fields }] : [{ verb, line: 3, ...fields }]; v(name, oracle, wrap(...cmds), tree(...commands));};for (const verb of ["verify", "verifyNot"]) { const tag = verb === "verify" ? "verify" : "verifyNot"; const same = verb === "verifyNot" ? " (verifyNot has the same tree with verb verifyNot)" : ""; vf(`${tag} exists`, "verify TARGET -> exists, target" + same, `${verb} role:button name:"Send"`, { kind: "exists", target: { role: "button", name: "Send" } }); vf(`${tag} text only key`, "verify text:\"X\" (text is the only key) -> text, value" + same, `${verb} text:"1 MALLBUCKS"`, { kind: "text", value: "1 MALLBUCKS" }); vf(`${tag} targetText`, "verify TARGET text:\"X\" (TARGET has some other key) -> targetText" + same, `${verb} css:".balance" text:"1 MALLBUCKS"`, { kind: "targetText", target: { css: ".balance" }, value: "1 MALLBUCKS" }); vf(`${tag} value`, "verify TARGET value:\"X\" -> value" + same, `${verb} label:"Name" value:"alex"`, { kind: "value", target: { label: "Name" }, value: "alex" }); vf(`${tag} state enabled`, "verify TARGET enabled -> state" + same, `${verb} css:"#go" enabled`, { kind: "state", target: { css: "#go" }, state: "enabled" }); vf(`${tag} state disabled`, "verify TARGET disabled -> state" + same, `${verb} css:"#go" disabled`, { kind: "state", target: { css: "#go" }, state: "disabled" }); vf(`${tag} state checked`, "verify TARGET checked -> state" + same, `${verb} label:"Agree" checked`, { kind: "state", target: { label: "Agree" }, state: "checked" }); vf(`${tag} count is a number`, "verify TARGET count:3 -> count (a number)" + same, `${verb} css:".row" count:3`, { kind: "count", target: { css: ".row" }, count: 3 }); vf(`${tag} url`, 'verify url~:"/gift/" -> url, contains' + same, `${verb} url~:"/gift/"`, { kind: "url", contains: "/gift/" }); vf(`${tag} title`, 'verify title:"X" -> title, value' + same, `${verb} title:"Mallbucks"`, { kind: "title", value: "Mallbucks" }); vf(`${tag} numberChange`, "verify TARGET number:changed-by from:NAME equals:-1 -> numberChange, equals a number" + same, `${verb} css:".balance" number:changed-by from:before equals:-1`, { kind: "numberChange", target: { css: ".balance" }, from: "before", equals: -1 }, "capture before css:\".balance\"");}vf("verify with within", 'within:"..." is the string on any verify or wait', 'verify text:"1 MALLBUCKS" within:"Review send"', { kind: "text", value: "1 MALLBUCKS", within: "Review send" });vf("verify with timeout", "Any verify may add timeout:5s -> timeoutMs", 'verify text:"X" timeout:5s', { kind: "text", value: "X", timeoutMs: 5000 });vf("verifyNot with within and timeout", "Any verify may add within and timeout; same forms for verifyNot", 'verifyNot css:".err" within:"Form" timeout:2s', { kind: "exists", target: { css: ".err" }, within: "Form", timeoutMs: 2000 });vf("verify numberChange positive equals", "equals (a number)", 'verify css:".b" number:changed-by from:before equals:3', { kind: "numberChange", target: { css: ".b" }, from: "before", equals: 3 }, 'capture before css:".balance"');
// ---------------------------------------------------------------- waitc1("wait visible", "wait TARGET is visible (target)", 'wait text:"Done"', { verb: "wait", kind: "visible", target: { text: "Done" } });c1("wait gone", "wait TARGET gone is gone (target)", 'wait text:"Loading" gone', { verb: "wait", kind: "gone", target: { text: "Loading" } });c1("wait text-changes", "wait TARGET text-changes is textChanges (target)", 'wait css:".b" text-changes', { verb: "wait", kind: "textChanges", target: { css: ".b" } });c1("wait url", 'wait url~:"/claim" is url (contains)', 'wait url~:"/claim"', { verb: "wait", kind: "url", contains: "/claim" });c1("wait network-idle with timeout", "wait network-idle is networkIdle; timeout -> timeoutMs", "wait network-idle timeout:10s", { verb: "wait", kind: "networkIdle", timeoutMs: 10000 });c1("wait with within", "within is the string on any verify or wait", 'wait text:"Done" within:"Dialog"', { verb: "wait", kind: "visible", target: { text: "Done" }, within: "Dialog" });
// ---------------------------------------------------------------- escapesc1("escaped quote", 'STRING allows \\" and the escape is resolved', 'type label:"A" "say \\"hi\\""', { verb: "type", target: { label: "A" }, text: 'say "hi"' });c1("escaped backslash", "STRING allows \\\\", 'type label:"A" "back\\\\slash"', { verb: "type", target: { label: "A" }, text: "back\\slash" });c1("escaped newline", "STRING allows \\n", 'type label:"A" "a\\nb"', { verb: "type", target: { label: "A" }, text: "a\nb" });c1("dollar without brace is plain text", "Only ${NAME} is a reference", 'type label:"A" "cost $5"', { verb: "type", target: { label: "A" }, text: "cost $5" });
// ---------------------------------------------------------------- comments, blanks, whitespace, structurec1("trailing comment", "# starts a comment to end of line", 'click text:"A" # note', { verb: "click", target: { text: "A" } });c1("hash inside a string is kept", "(not inside a string)", 'click text:"A#1"', { verb: "click", target: { text: "A#1" } });c1("hash inside a type string then real comment", "(not inside a string)", 'type label:"A" "# not a comment" # real', { verb: "type", target: { label: "A" }, text: "# not a comment" });v("comment and blank lines count in line numbers", "Line numbers are 1-based and count every line, blank and comment lines included", L("# top comment", "", 'test "t"', "", "# c", 'step "s"', "", " # c", ' click text:"A"'), { name: "t", covers: [], steps: [{ name: "s", line: 6, commands: [{ verb: "click", line: 9, target: { text: "A" } }] }] });v("blank lines with spaces are ignored", "Blank lines are ignored", 'test "t"\n \n\t\nstep "s"\n click text:"A"\n', { name: "t", covers: [], steps: [{ name: "s", line: 4, commands: [{ verb: "click", line: 5, target: { text: "A" } }] }] });v("no trailing newline", "Lines are trimmed", 'test "t"\nstep "s"\n click text:"A"', tree({ verb: "click", line: 3, target: { text: "A" } }));v("trailing spaces are trimmed", "Lines are trimmed", 'test "t" \nstep "s" \n click text:"A" \n', tree({ verb: "click", line: 3, target: { text: "A" } }));v("several commands and steps carry their own lines", "line on a command is the line of its verb", L('test "t"', 'step "a"', ' click text:"A"', ' click text:"B"', 'step "b"', ' press Tab'), { name: "t", covers: [], steps: [ { name: "a", line: 2, commands: [{ verb: "click", line: 3, target: { text: "A" } }, { verb: "click", line: 4, target: { text: "B" } }] }, { name: "b", line: 5, commands: [{ verb: "press", line: 6, key: "Tab" }] } ] });v("very long valid line", "It never throws, whatever the text", wrap('click text:"' + "a".repeat(100000) + '"'), tree({ verb: "click", line: 3, target: { text: "a".repeat(100000) } }));
// ---------------------------------------------------------------- variablesconst V_OK = "A reference is an error only when no earlier set/capture NAME defines it";v("set then use in same step", V_OK, wrap('set total "5"', 'type label:"A" "${total}"'.replace(/^/, "")), tree({ verb: "set", line: 3, name: "total", value: "5" }, { verb: "type", line: 4, target: { label: "A" }, text: "${total}" }));v("set in earlier step used later", V_OK, L('test "t"', 'step "a"', ' set total "5"', 'step "b"', ' type label:"A" "${total}"'), { name: "t", covers: [], steps: [ { name: "a", line: 2, commands: [{ verb: "set", line: 3, name: "total", value: "5" }] }, { name: "b", line: 4, commands: [{ verb: "type", line: 5, target: { label: "A" }, text: "${total}" }] } ] });v("capture then use in verify", V_OK, wrap('capture before css:".b"', 'verify text:"${before}"'), tree({ verb: "capture", line: 3, name: "before", target: { css: ".b" } }, { verb: "verify", line: 4, kind: "text", value: "${before}" }));v("set value may reference an earlier variable", V_OK, wrap('set a "1"', 'set b "${a}"'), tree({ verb: "set", line: 3, name: "a", value: "1" }, { verb: "set", line: 4, name: "b", value: "${a}" }));const E7 = (n: string) => `\${${n}} is used before it is set (capture or set it first)`;e("E007 use before any set", "E007 example message", wrap('type label:"A" "${total}"'), { code: "E007", line: 3, message: E7("total") });e("E007 defined later in the same step", "no earlier set NAME", wrap('type label:"A" "${x}"', 'set x "1"'), { code: "E007", line: 3, message: E7("x") });e("E007 defined in a later step", "in any step before it or earlier in the same step", L('test "t"', 'step "a"', ' click text:"${x}"', 'step "b"', ' set x "1"'), { code: "E007", line: 3, message: E7("x") });e("E007 in a target value", "inside a string, on the line of the string", wrap('click text:"${missing}"'), { code: "E007", line: 3, message: E7("missing") });e("E007 in a set value", "inside a string", wrap('set b "${a}"'), { code: "E007", line: 3, message: E7("a") });e("E007 only the undefined one in a mixed string", "reference is an error when no earlier set defines NAME", wrap('set a "1"', 'type label:"A" "${a} ${b}"'), { code: "E007", line: 4, message: E7("b") });
// ---------------------------------------------------------------- errorse("E001 step without a name", "E001 example", L('test "t"', "step", ' click text:"A"'), { code: "E001", line: 2, message: 'a step needs a name: step "what you want to happen"' });e("E001 step with an unquoted name", "the STRING is the intent and is required", L('test "t"', "step foo", ' click text:"A"'), { code: "E001", line: 2, message: 'a step needs a name: step "what you want to happen"' });e("E001 line counts blanks and comments", "Line numbers count every line", L("# c", "", 'test "t"', "", "step", ' click text:"A"'), { code: "E001", line: 5, message: 'a step needs a name: step "what you want to happen"' });e("E002 with did-you-mean", "E002 example", wrap("clik text:\"A\""), { code: "E002", line: 3, message: 'unknown command "clik" (did you mean "click"?)' });e("E002 hint for doubleClick", "suggests a known command within two edits", wrap('dobleClick text:"A"'), { code: "E002", line: 3, message: has('(did you mean "doubleClick"?)') });e("E002 hint for transposition", "within two edits", wrap('verfiy text:"A"'), { code: "E002", line: 3, message: has('(did you mean "verify"?)') });e("E002 hint for check", "within two edits", wrap('chek label:"A"'), { code: "E002", line: 3, message: has('(did you mean "check"?)') });e("E002 without a hint", "otherwise the message has no hint", wrap("frobnicate"), { code: "E002", line: 3, message: expect.stringMatching(/^unknown command "frobnicate"(?![\s\S]*did you mean)/) });e("E002 three edits away has no hint", "within two edits, otherwise no hint", wrap('clickety text:"A"'), { code: "E002", line: 3, message: expect.stringMatching(/^unknown command "clickety"(?![\s\S]*did you mean)/) });e("E002 line after blank and comment lines", "Line numbers count every line", L('test "t"', 'step "s"', "", " # c", "", ' clik text:"A"'), { code: "E002", line: 6, message: has('unknown command "clik"') });e("E003 click without target", "E003 example", wrap("click"), { code: "E003", line: 3, message: 'click needs a target, for example text:"Send"' });e("E003 click with only a modifier", "a command missing what it needs", wrap("click timeout:5s"), { code: "E003", line: 3, message: has("click needs a target") });e("E003 type without text", "type without text", wrap('type label:"A"'), { code: "E003", line: 3, message: anyMsg });e("E003 press without key", "press without a key", wrap("press"), { code: "E003", line: 3, message: anyMsg });e("E003 sleep without duration", "sleep without a duration", wrap("sleep"), { code: "E003", line: 3, message: anyMsg });e("E003 goto without URL", "goto without a URL", wrap("goto"), { code: "E003", line: 3, message: anyMsg });e("E003 verify with nothing", "verify with nothing to check", wrap("verify"), { code: "E003", line: 3, message: anyMsg });e("E003 verify combination not in table", "a verify combination that is not in the table", wrap('verify css:".x" count:3 value:"a"'), { code: "E003", line: 3, message: anyMsg });e("E003 verify url with text", "a verify combination that is not in the table", wrap('verify url~:"/a" text:"b"'), { code: "E003", line: 3, message: anyMsg });e("E003 index zero", "index: that is not a whole number of 1 or more", wrap('click text:"A" index:0'), { code: "E003", line: 3, message: anyMsg });e("E003 index not a number", "index: that is not a whole number", wrap('click text:"A" index:abc'), { code: "E003", line: 3, message: anyMsg });e("E003 index negative", "index: that is not a whole number of 1 or more", wrap('click text:"A" index:-1'), { code: "E003", line: 3, message: anyMsg });e("E003 index fraction", "index: that is not a whole number", wrap('click text:"A" index:1.5'), { code: "E003", line: 3, message: anyMsg });e("E003 duplicate target key", "The same key twice in one target is an error (E003)", wrap('click text:"A" text:"B"'), { code: "E003", line: 3, message: anyMsg });e("E004 unterminated string in command", "E004 example", wrap('click text:"abc'), { code: "E004", line: 3, message: "unterminated string" });e("E004 unterminated step name", "STRING must be closed", L('test "t"', 'step "abc', ' click text:"A"'), { code: "E004", line: 2, message: "unterminated string" });e("E004 closing quote escaped", 'STRING: \\" is an escape, not a terminator', wrap('type label:"A" "abc\\"'), { code: "E004", line: 3, message: "unterminated string" });e("E005 unknown target key", "E005 example", wrap('click txt:"Send"'), { code: "E005", line: 3, message: 'unknown target key "txt" (text, role, name, label, placeholder, testid, css, xpath, index)' });e("E005 in type", "E005 applies to any target", wrap('type txt:"a" "b"'), { code: "E005", line: 3, message: has('unknown target key "txt"') });e("E005 line after comments", "Line numbers count every line", L('test "t"', 'step "s"', "# c", "", ' click bogus:"x"'), { code: "E005", line: 5, message: has('unknown target key "bogus"') });
// E010const E10 = "E010 message list";e("E010 not starting with test", E10, L('step "s"', ' click text:"A"'), { code: "E010", line: 1, message: has('a script must begin with: test "name"') });rj("E010 test without a name", E10, L("test", 'step "s"', ' click text:"A"'), "E010");e("E010 two tests", E10, L('test "a"', 'step "s"', ' click text:"A"', 'test "b"'), { code: "E010", line: 4, message: has("only one test per file") });e("E010 no steps", E10, L('test "t"'), { code: "E010", line: 1, message: has("a test needs at least one step") });e("E010 headers but no steps", E10, L('test "t"', "covers J-01"), { code: "E010", line: 1, message: has("a test needs at least one step") });e("E010 empty step", E10, L('test "t"', 'step "empty"'), { code: "E010", line: 2, message: has("a step needs at least one command") });e("E010 empty step before another step", E10, L('test "t"', 'step "a"', 'step "b"', ' click text:"A"'), { code: "E010", line: 2, message: has("a step needs at least one command") });e("E010 covers after a step", E10, L('test "t"', 'step "s"', ' click text:"A"', "covers J-01"), { code: "E010", line: 4, message: has("covers must come before the first step") });e("E010 start not http", E10, L('test "t"', "start ftp://x.example/", 'step "s"', ' click text:"A"'), { code: "E010", line: 2, message: has("start needs an http or https URL") });e("E010 start not a URL", E10, L('test "t"', "start notaurl", 'step "s"', ' click text:"A"'), { code: "E010", line: 2, message: has("start needs an http or https URL") });e("E010 start without value", E10, L('test "t"', "start", 'step "s"', ' click text:"A"'), { code: "E010", line: 2, message: has("start needs an http or https URL") });e("E010 timeout not a duration", E10, L('test "t"', "timeout fast", 'step "s"', ' click text:"A"'), { code: "E010", line: 2, message: has("timeout needs a duration such as 10s, 500ms or 1m") });e("E010 timeout without unit", E10, L('test "t"', "timeout 10", 'step "s"', ' click text:"A"'), { code: "E010", line: 2, message: has("timeout needs a duration such as 10s, 500ms or 1m") });e("E010 covers with a bad id", E10, L('test "t"', "covers foo", 'step "s"', ' click text:"A"'), { code: "E010", line: 2, message: has("covers needs ids like J-09") });e("E010 covers with no ids", E10, L('test "t"', "covers", 'step "s"', ' click text:"A"'), { code: "E010", line: 2, message: has("covers needs ids like J-09") });e("E010 covers with one bad id among good", E10, L('test "t"', "covers J-09 J9", 'step "s"', ' click text:"A"'), { code: "E010", line: 2, message: has("covers needs ids like J-09") });e("E010 needs not session", E10, L('test "t"', "needs foo bar", 'step "s"', ' click text:"A"'), { code: "E010", line: 2, message: has("needs must be: needs session NAME") });e("E010 needs session without name", E10, L('test "t"', "needs session", 'step "s"', ' click text:"A"'), { code: "E010", line: 2, message: has("needs must be: needs session NAME") });e("E010 unexpected word in header position", E10, L('test "t"', "Send", 'step "s"', ' click text:"A"'), { code: "E010", line: 2, message: has('unexpected "Send"') });
// multiple errorse("several errors sorted by line", "Errors are sorted by line", L('test "t"', 'step "s"', ' clik text:"A"', " click", "", ' click txt:"A"'), { code: "E002", line: 3, message: has("unknown command") }, { code: "E003", line: 4, message: has("click needs a target") }, { code: "E005", line: 6, message: has("unknown target key") });e("E007 on an earlier line sorts before a later E002", "Errors are sorted by line", wrap('type label:"A" "${nope}"', 'clik text:"A"'), { code: "E007", line: 3, message: E7("nope") }, { code: "E002", line: 4, message: has("unknown command") });e("errors under CRLF keep correct lines", "Line numbers are 1-based; CRLF is a line ending", wrap("", "clik text:\"A\"").replace(/\n/g, "\r\n"), { code: "E002", line: 4, message: has("unknown command") });
// totalityrj("empty string", "A file := test, so empty fails; never throws", "", "E010");rj("only comments", "A file := test; comments are ignored", L("# a", "# b"), "E010");rj("only blank lines", "A file := test; blank lines are ignored", "\n\n\n", "E010");tot("garbage punctuation", "It never throws, whatever the text", "!!@#$%^&*()_+{}|:<>?");tot("binary-ish text", "It never throws, whatever the text", "\u0000\u0001\u0002�\u0007test \"x\"\u0000");tot("lone surrogate", "It never throws, whatever the text", "\ud800 test \"\udc00\"");tot("lone quote", "It never throws, whatever the text", '"');tot("lone backslash", "It never throws, whatever the text", "\\");tot("just the word test", "It never throws, whatever the text", "test");tot("only carriage returns", "It never throws, whatever the text", "\r\r\r");tot("very long garbage line", "It never throws, whatever the text", "x".repeat(200000));tot("very long unterminated string", "It never throws, whatever the text", wrap('click text:"' + "a".repeat(200000)));tot("very many lines", "It never throws, whatever the text", L('test "t"', 'step "s"', ...Array.from({ length: 5000 }, () => " nonsense")));tot("unicode text", "UTF-8 files", L('test "tést ✓"', 'step "日本語"', ' click text:"é"'));
// ---------------------------------------------------------------- runnerfunction noUndefined(x: unknown, path = "$"): void { if (x === undefined) throw new Error(`undefined value at ${path}`); if (x && typeof x === "object") { for (const [k, val] of Object.entries(x as Record<string, unknown>)) noUndefined(val, `${path}.${k}`); }}
describe("P2-1.b and e: parseScript spec table", () => { it("has at least 40 cases", () => { expect(rows.length).toBeGreaterThanOrEqual(40); });
it.each(rows.map((r) => [r.name, r] as const))("%s", (_name, row) => { const result = parseScript(row.text) as unknown as Result; const ex = row.expect; if ("script" in ex) { expect(result.ok, JSON.stringify(result.errors)).toBe(true); expect(result.script).toEqual(ex.script); noUndefined(result.script); } else if ("errors" in ex) { expect(result.ok).toBe(false); expect(result.errors).toEqual(ex.errors); const lines = (result.errors ?? []).map((x) => x.line); expect([...lines].sort((a, b) => a - b)).toEqual(lines); } else if ("rejects" in ex) { expect(result.ok).toBe(false); expect(result.errors?.length).toBeGreaterThan(0); expect(result.errors?.[0]?.code).toBe(ex.rejects); } else { expect(typeof result.ok).toBe("boolean"); if (result.ok) { expect(result.script).toBeDefined(); } else { expect(result.errors?.length).toBeGreaterThan(0); for (const err of result.errors ?? []) { expect(err.code).toMatch(/^E\d{3}$/); expect(Number.isInteger(err.line)).toBe(true); expect(err.line).toBeGreaterThanOrEqual(1); expect(typeof err.message).toBe("string"); } } } });});