Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646import { KEYS, KEY_NAMES } from "../shared/keys";
/** * Reads a script (docs/design/script-format.md). Pure: text in, a script or a list of line-numbered errors out. Never throws. * It reads the whole grammar; which commands the runner can run today is decided in supported.ts, not here. */export interface ParseError { code: string; line: number; message: string;}
export type Target = Record<string, string>;
interface CommandBase { line: number; /** The script the command was written in, when it is not the one being run (an included script). */ file?: string; timeoutMs?: number; as?: string; /** The profile that does this command (a session the script declares with `needs session`); absent: the default, the first one. */ on?: string;}export interface StartCommand extends CommandBase { verb: "start"; url: string }export interface GotoCommand extends CommandBase { verb: "goto"; url: string }export interface ClickCommand extends CommandBase { verb: "click"; target: Target }export interface DoubleClickCommand extends CommandBase { verb: "doubleClick"; target: Target }export interface CheckCommand extends CommandBase { verb: "check" | "uncheck"; target: Target }export interface TypeCommand extends CommandBase { verb: "type"; target: Target; text: string }export interface SelectCommand extends CommandBase { verb: "select"; target: Target; option: string }export interface PressCommand extends CommandBase { verb: "press"; key: string }export interface SleepCommand extends CommandBase { verb: "sleep"; ms: number }export interface CaptureCommand extends CommandBase { verb: "capture"; name: string; target: Target }export interface SetCommand extends CommandBase { verb: "set"; name: string; value: string }export interface SwitchTabCommand extends CommandBase { verb: "switchTab"; tab: { kind: "url"; contains: string } | { kind: "new" } }export interface CloseTabCommand extends CommandBase { verb: "closeTab" }export interface DragCommand extends CommandBase { verb: "drag"; target: Target; to: Target }export interface ScreenshotCommand extends CommandBase { verb: "screenshot"; name: string }export interface CallCommand extends CommandBase { verb: "call"; name: string; args: string[] }export interface RunCommand extends CommandBase { verb: "run"; path: string }
/** How verify compares text: exactly (the default), ignoring case (`text-i:`), or as a part of the text ignoring case (`text~i:`). */export type TextMatch = "ignoreCase" | "containsIgnoreCase";export interface VerifyTextCommand extends CommandBase { verb: "verify"; kind: "text"; value: string; within?: string; match?: TextMatch }export type VerifyCondition = | { kind: "exists"; target: Target } | { kind: "text"; value: string; match?: TextMatch } | { kind: "targetText"; target: Target; value: string; match?: TextMatch } | { kind: "value"; target: Target; value: string } | { kind: "state"; target: Target; state: "enabled" | "disabled" | "checked" } | { kind: "count"; target: Target; count: number } | { kind: "url"; contains: string } | { kind: "title"; value: string } | { kind: "numberChange"; target: Target; from: string; equals: number } | { kind: "attr"; target: Target; attr: string; op: "equals" | "contains"; value: string } | { kind: "download"; name: string; nameMatch?: "contains"; sizeMin?: number };export type VerifyCommand = CommandBase & { verb: "verify" | "verifyNot"; within?: string } & VerifyCondition;
export type WaitCondition = | { kind: "visible"; target: Target } | { kind: "gone"; target: Target } | { kind: "textChanges"; target: Target } | { kind: "url"; contains: string } | { kind: "networkIdle" };export type WaitCommand = CommandBase & { verb: "wait"; within?: string } & WaitCondition;
export type Command = | StartCommand | GotoCommand | ClickCommand | DoubleClickCommand | CheckCommand | TypeCommand | SelectCommand | PressCommand | SleepCommand | CaptureCommand | SetCommand | SwitchTabCommand | CloseTabCommand | DragCommand | ScreenshotCommand | CallCommand | RunCommand | VerifyCommand | WaitCommand;
export interface Step { name: string; line: number; commands: Command[];}
export interface Script { name: string; covers: string[]; /** The test publishes something public (a post), so an unattended schedule counts it against a daily cap. */ posts?: boolean; /** The test spends money or other value (a purchase, a transfer, a tip). A job needs a spends allowance for it, and the device must have spending switched on. */ spends?: boolean; start?: string; startLine?: number; needsSession?: string; needsLine?: number; /** More sessions the script uses, by `needs session` lines after the first; commands pick one with `on:NAME`. */ alsoNeeds?: Array<{ name: string; line: number }>; timeoutMs?: number; steps: Step[];}
/** On failure, `partial` is whatever could be read (commands that parsed), so a caller can report more problems in the same pass. */export type ParseResult = { ok: true; script: Script } | { ok: false; errors: ParseError[]; partial: Script };
export const formatParseError = (file: string, e: ParseError): string => `${file}:${e.line}: ${e.message}`;
export const COMMAND_VERBS = ["start", "goto", "click", "doubleClick", "type", "press", "select", "check", "uncheck", "verify", "verifyNot", "wait", "sleep", "capture", "set", "switchTab", "closeTab", "drag", "screenshot", "call", "run"];const HEADERS = new Set(["covers", "start", "needs", "timeout", "posts", "spends"]);const TARGET_KEYS = ["text", "role", "name", "label", "placeholder", "testid", "css", "xpath", "index"];const TARGET_KEY_SET = new Set([...TARGET_KEYS, "text~"]);const MODIFIER_KEYS = new Set(["timeout", "within", "as", "on"]);const PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;const VERIFY_EXTRA_KEYS = ["url~", "title", "value", "count", "number", "from", "equals", "text-i", "text~i", "attr", "contains", "size-min", "name~"];const STATES = ["enabled", "disabled", "checked"] as const;const DURATION_MESSAGE = "timeout needs a duration such as 10s, 500ms or 1m";const KEY_AT = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*(?:~i|~)?:(?!\/\/)/;const VARIABLE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
interface PairToken { kind: "pair"; key: string; value: string; nested?: { key: string; value: string };}type Token = { kind: "string"; value: string } | PairToken | { kind: "word"; value: string };
/** Splits one line into tokens, or reports an unterminated string. */function tokenize(line: string): Token[] | "unterminated" { const tokens: Token[] = []; let i = 0; const readString = (): string | undefined => { let out = ""; i++; // the opening quote while (i < line.length) { const c = line[i]!; if (c === "\\" && i + 1 < line.length) { const n = line[i + 1]!; out += n === "n" ? "\n" : n === '"' || n === "\\" ? n : c + n; i += 2; } else if (c === '"') { i++; return out; } else { out += c; i++; } } return undefined; }; const readValue = (): string | undefined => { if (line[i] === '"') return readString(); const start = i; while (i < line.length && !/\s/.test(line[i]!)) i++; return line.slice(start, i); }; while (i < line.length) { if (/\s/.test(line[i]!)) { i++; continue; } if (line[i] === '"') { const s = readString(); if (s === undefined) return "unterminated"; tokens.push({ kind: "string", value: s }); continue; } const key = KEY_AT.exec(line.slice(i)); if (key) { i += key[0].length; const name = key[0].slice(0, -1); const inner = name === "to" ? KEY_AT.exec(line.slice(i)) : null; if (inner) { i += inner[0].length; const value = readValue(); if (value === undefined) return "unterminated"; tokens.push({ kind: "pair", key: name, value: "", nested: { key: inner[0].slice(0, -1), value } }); continue; } const value = readValue(); if (value === undefined) return "unterminated"; tokens.push({ kind: "pair", key: name, value }); continue; } const start = i; while (i < line.length && !/\s/.test(line[i]!)) i++; tokens.push({ kind: "word", value: line.slice(start, i) }); } return tokens;}
/** Cuts a # comment, ignoring any # inside a string. */function stripComment(line: string): string { let inString = false; for (let i = 0; i < line.length; i++) { const c = line[i]!; if (inString && c === "\\") i++; else if (c === '"') inString = !inString; else if (c === "#" && !inString) return line.slice(0, i); } return line;}
export function parseDuration(text: string): number | undefined { const m = /^(\d+)(ms|s|m)$/.exec(text); if (!m) return undefined; return Number(m[1]) * (m[2] === "ms" ? 1 : m[2] === "s" ? 1000 : 60000);}
function editDistance(a: string, b: string): number { const row = Array.from({ length: b.length + 1 }, (_, j) => j); for (let i = 1; i <= a.length; i++) { let prev = row[0]!; row[0] = i; for (let j = 1; j <= b.length; j++) { const tmp = row[j]!; row[j] = Math.min(row[j]! + 1, row[j - 1]! + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1)); prev = tmp; } } return row[b.length]!;}
const suggest = (verb: string): string | undefined => COMMAND_VERBS.find((v) => editDistance(verb.toLowerCase(), v.toLowerCase()) <= 2 && v.toLowerCase() !== verb.toLowerCase());
type Report = (code: string, line: number, message: string) => void;
export interface ParseOptions { /** Variables already set where this script is included, so ${NAME} of those is not E007. */ variables?: readonly string[]; /** Profiles declared where this script is included, so `on:NAME` of those is allowed. */ profiles?: readonly string[]; /** Stamped on every command, so a report can say which file a line belongs to. */ file?: string; /** * The checks that are built in where the script will run (the extension has no scripts/checks folder to load from). When given, `call NAME` must name one of them (E011). * When absent, any name is accepted here and the runner looks it up in scripts/checks when the call runs. */ checks?: readonly string[];}
export function parseScript(text: string, options: ParseOptions = {}): ParseResult { const errors: ParseError[] = []; const err: Report = (code, line, message) => void errors.push({ code, line, message });
let name: string | undefined; const covers: string[] = []; let posts = false; let spends = false; let start: string | undefined; let startLine: number | undefined; let needsSession: string | undefined; let needsLine: number | undefined; const alsoNeeds: Array<{ name: string; line: number }> = []; let timeoutMs: number | undefined; const steps: Step[] = []; let current: Step | undefined; let testLine = 1; const defined = new Set<string>(options.variables ?? []);
// A command that failed to parse was still written, so it must not also be reported as "no commands". let attempted = 0; const closeStep = () => { if (current && attempted === 0) err("E010", current.line, "a step needs at least one command"); };
const lines = text.split(/\r?\n/); lines.forEach((raw, index) => { const lineNo = index + 1; const line = stripComment(raw).trim(); if (line === "") return; const verb = /^\S+/.exec(line)![0];
if (verb === "test") { const tokens = tokenize(line); if (tokens === "unterminated") { err("E004", lineNo, "unterminated string"); if (name === undefined) name = ""; // the test was written; do not also report a missing test line return; } if (name !== undefined) return err("E010", lineNo, "only one test per file"); const given = tokens[1]; if (!given || given.kind !== "string" || given.value === "") return err("E010", lineNo, 'test needs a name: test "what is being checked"'); name = given.value; testLine = lineNo; return; } if (name === undefined) { if (errors.every((e) => e.code !== "E010" || e.line !== 1)) err("E010", 1, 'a script must begin with: test "name"'); return; }
if (verb === "step") { closeStep(); const tokens = tokenize(line); const given = tokens === "unterminated" ? undefined : tokens[1]; if (tokens === "unterminated") err("E004", lineNo, "unterminated string"); else if (!given || given.kind !== "string" || given.value === "") err("E001", lineNo, 'a step needs a name: step "what you want to happen"'); else if (given.value.trim().toLowerCase() === "todo name") err("E001", lineNo, 'this step still has the placeholder name from record mode: say what it is for, step "what you want to happen"'); current = { name: given && given.kind === "string" ? given.value : "", line: lineNo, commands: [] }; attempted = 0; steps.push(current); return; }
if (HEADERS.has(verb) && !(verb === "start" && current)) { if (current) return err("E010", lineNo, `${verb} must come before the first step`); const rest = line.slice(verb.length).trim(); if (verb === "start") { if (!isHttpUrl(rest)) return err("E010", lineNo, "start needs an http or https URL"); start = rest; startLine = lineNo; return; } const tokens = tokenize(rest); if (tokens === "unterminated") return err("E004", lineNo, "unterminated string"); const words = tokens.map((t) => (t.kind === "word" ? t.value : undefined)); if (verb === "covers") { if (words.length === 0 || words.some((w) => w === undefined || !/^[A-Za-z]+-\d+$/.test(w))) return err("E010", lineNo, "covers needs ids like J-09"); covers.push(...(words as string[])); } else if (verb === "posts") { if (words.length !== 0) return err("E010", lineNo, "posts takes nothing after it: it says the test publishes something public"); posts = true; } else if (verb === "spends") { if (words.length !== 0) return err("E010", lineNo, "spends takes nothing after it: it says the test spends money or other value"); spends = true; } else if (verb === "needs") { if (words.length !== 2 || words[0] !== "session" || words[1] === undefined) return err("E010", lineNo, "needs must be: needs session NAME"); const declared = [needsSession, ...alsoNeeds.map((n) => n.name)]; if (declared.includes(words[1])) return err("E010", lineNo, `needs session ${words[1]} is written twice`); if (needsSession === undefined) { needsSession = words[1]; needsLine = lineNo; } else alsoNeeds.push({ name: words[1], line: lineNo }); } else { const ms = words.length === 1 && words[0] !== undefined ? parseDuration(words[0]) : undefined; if (ms === undefined) return err("E010", lineNo, DURATION_MESSAGE); timeoutMs = ms; } return; }
if (!current) return err("E010", lineNo, COMMAND_VERBS.includes(verb) ? `${verb} must be inside a step` : `unexpected "${verb}"`); attempted++; const command = parseCommand(verb, line.slice(verb.length), lineNo, err, defined, [...(options.profiles ?? []), ...(needsSession !== undefined ? [needsSession] : []), ...alsoNeeds.map((n) => n.name)], options.checks); if (command) current.commands.push(options.file === undefined ? command : ({ ...command, file: options.file } as Command)); });
closeStep(); if (name !== undefined && steps.length === 0) err("E010", testLine, "a test needs at least one step"); if (name === undefined && lines.every((l) => stripComment(l).trim() === "")) err("E010", 1, 'a script must begin with: test "name"');
const script: Script = { name: name ?? "", covers, ...(posts ? { posts: true as const } : {}), ...(spends ? { spends: true as const } : {}), steps, ...(start !== undefined ? { start, startLine: startLine as number } : {}), ...(needsSession !== undefined ? { needsSession, needsLine: needsLine as number } : {}), ...(alsoNeeds.length > 0 ? { alsoNeeds } : {}), ...(timeoutMs !== undefined ? { timeoutMs } : {}) }; if (errors.length > 0) return { ok: false, errors: errors.sort((a, b) => a.line - b.line), partial: script }; return { ok: true, script };}
const isHttpUrl = (text: string): boolean => /^https?:\/\/\S+$/.test(text) && URL.canParse(text);
/** The target keys of a command's pairs, or an error for an unknown key, a repeated key or a bad index. */function collectTarget(pairs: PairToken[], line: number, err: Report, extra: ReadonlySet<string>): { target: Target; rest: PairToken[]; bad: boolean } { const target: Target = {}; const rest: PairToken[] = []; let bad = false; const seen = new Set<string>(); for (const p of pairs) { if (seen.has(p.key)) { err("E003", line, TARGET_KEY_SET.has(p.key) ? `${p.key} is written twice in one target` : `${p.key}: is written twice`); bad = true; } seen.add(p.key); if (TARGET_KEY_SET.has(p.key)) { if (p.key === "index" && !/^[1-9]\d*$/.test(p.value)) { err("E003", line, "index needs a whole number from 1, for example index:2"); bad = true; } target[p.key] = p.value; } else if (extra.has(p.key)) rest.push(p); else { err("E005", line, `unknown target key "${p.key}" (${TARGET_KEYS.join(", ")})`); bad = true; } } return { target, rest, bad };}
const hasTarget = (t: Target): boolean => Object.keys(t).length > 0;
function parseCommand(verb: string, rest: string, line: number, err: Report, defined: Set<string>, profiles: string[], checks: readonly string[] | undefined): Command | undefined { if (!COMMAND_VERBS.includes(verb)) { const hint = suggest(verb); err("E002", line, hint ? `unknown command "${verb}" (did you mean "${hint}"?)` : `unknown command "${verb}"`); return undefined; } const tokens = tokenize(rest); if (tokens === "unterminated") { err("E004", line, "unterminated string"); return undefined; }
const positional = tokens.filter((t): t is Exclude<Token, PairToken> => t.kind !== "pair"); const allPairs = tokens.filter((t): t is PairToken => t.kind === "pair"); const modifiers: Record<string, string> = {}; for (const p of allPairs) if (MODIFIER_KEYS.has(p.key)) modifiers[p.key] = p.value; const pairs = allPairs.filter((p) => !MODIFIER_KEYS.has(p.key));
const fail = (code: string, message: string): undefined => { err(code, line, message); return undefined; }; // words after the ones a command takes const stray = (taken: number): boolean => { const extra = positional[taken]; if (extra === undefined) return false; err("E010", line, `unexpected "${extra.value}"`); return true; };
const extraKeys: Record<string, string[]> = { verify: VERIFY_EXTRA_KEYS, verifyNot: VERIFY_EXTRA_KEYS, wait: ["url~"], switchTab: ["url~"], drag: ["to"] }; const targetVerbs = ["click", "doubleClick", "check", "uncheck", "type", "select", "capture", "verify", "verifyNot", "wait", "drag"]; const allowed: ReadonlySet<string> = new Set(extraKeys[verb] ?? []);
let target: Target = {}; let rest2: PairToken[] = pairs; if (targetVerbs.includes(verb)) { const c = collectTarget(pairs, line, err, allowed); if (c.bad) return undefined; target = c.target; rest2 = c.rest; } else { for (const p of pairs) { if (!allowed.has(p.key)) { err("E005", line, `unknown target key "${p.key}" (${TARGET_KEYS.join(", ")})`); return undefined; } } }
let timeoutMs: number | undefined; if (modifiers.timeout !== undefined) { timeoutMs = parseDuration(modifiers.timeout); if (timeoutMs === undefined) return fail("E010", DURATION_MESSAGE); } if (modifiers.within !== undefined && verb !== "verify" && verb !== "verifyNot" && verb !== "wait") return fail("E003", "within: is only for verify and wait"); if (modifiers.on !== undefined) { if (!PROFILE_NAME.test(modifiers.on) || !profiles.includes(modifiers.on)) { return fail("E003", `on:${modifiers.on} names a profile this script does not declare: add "needs session ${modifiers.on.replace(/[^A-Za-z0-9_-]/g, "")}" at the top (declared: ${profiles.length > 0 ? profiles.join(", ") : "none"})`); } } const common = { line, ...(timeoutMs === undefined ? {} : { timeoutMs }), ...(modifiers.as === undefined ? {} : { as: modifiers.as }), ...(modifiers.on === undefined ? {} : { on: modifiers.on }) }; const withIn = modifiers.within === undefined ? {} : { within: modifiers.within }; const pairValue = (key: string): string | undefined => rest2.find((p) => p.key === key)?.value; const text = (t: Token | undefined): string | undefined => (t && t.kind !== "pair" ? t.value : undefined);
const command = build(); if (!command) return undefined;
// variables: every ${NAME} in a string of the command must already be set const missing: string[] = []; const strings = tokens.flatMap((t) => (t.kind === "pair" ? [t.value, t.nested?.value ?? ""] : t.kind === "string" ? [t.value] : [])); for (const s of strings) for (const m of s.matchAll(VARIABLE)) if (!defined.has(m[1]!) && !missing.includes(m[1]!)) missing.push(m[1]!); if (missing.length > 0) { for (const v of missing) err("E007", line, `\${${v}} is used before it is set (capture or set it first)`); return undefined; } if (command.verb === "set" || command.verb === "capture") defined.add(command.name); return command;
function build(): Command | undefined { switch (verb) { case "start": case "goto": { const url = positional[0]?.value; if (url === undefined || !isHttpUrl(url) || positional.length > 1) return fail("E003", `${verb} needs a URL, for example ${verb} https://example.com/`); return { verb, url, ...common }; } case "click": case "doubleClick": case "check": case "uncheck": { if (stray(0)) return undefined; if (!hasTarget(target)) return fail("E003", `${verb} needs a target, for example text:"Send"`); return { verb, target, ...common } as Command; } case "type": { if (!hasTarget(target)) return fail("E003", 'type needs a target, for example type label:"Amount" "1"'); const given = positional[0]; if (given === undefined || given.kind !== "string") return fail("E003", 'type needs text after the target, for example type label:"Amount" "1"'); if (stray(1)) return undefined; return { verb, target, text: given.value, ...common }; } case "select": { if (!hasTarget(target)) return fail("E003", 'select needs a target, for example select label:"Color" "Blue"'); const given = positional[0]; if (given === undefined || given.kind !== "string") return fail("E003", 'select needs an option after the target, for example select label:"Color" "Blue"'); if (stray(1)) return undefined; return { verb, target, option: given.value, ...common }; } case "press": { const key = text(positional[0]); if (key === undefined) return fail("E003", "press needs a key, for example press Enter"); if (!(key in KEYS) && [...key].length !== 1) return fail("E003", `unknown key "${key}" (${KEY_NAMES.join(", ")}, or one character)`); if (stray(1)) return undefined; return { verb, key, ...common }; } case "sleep": { const ms = positional[0]?.kind === "word" ? parseDuration(positional[0].value) : undefined; if (ms === undefined) return fail("E003", "sleep needs a duration such as 2s"); if (stray(1)) return undefined; return { verb, ms, ...common }; } case "capture": { const given = positional[0]; if (given === undefined || given.kind !== "word" || !hasTarget(target)) return fail("E003", 'capture needs a name and a target, for example capture before css:".balance"'); if (stray(1)) return undefined; return { verb, name: given.value, target, ...common }; } case "set": { const given = positional[0]; const value = text(positional[1]); if (given === undefined || given.kind !== "word" || value === undefined || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(given.value)) return fail("E003", 'set needs a name and a value, for example set total "5"'); if (stray(2)) return undefined; return { verb, name: given.value, value, ...common }; } case "switchTab": { const contains = pairValue("url~"); if (contains !== undefined && positional.length === 0) return { verb, tab: { kind: "url", contains }, ...common }; if (contains === undefined && positional[0]?.kind === "word" && positional[0].value === "new") return stray(1) ? undefined : { verb, tab: { kind: "new" }, ...common }; return fail("E003", 'switchTab needs url~:"/part" or new'); } case "closeTab": return stray(0) ? undefined : { verb, ...common }; case "drag": { const to = pairs.find((p) => p.key === "to")?.nested; if (!hasTarget(target) || to === undefined || !TARGET_KEY_SET.has(to.key)) return fail("E003", 'drag needs a target and to:, for example drag text:"Card" to:text:"Done"'); if (stray(0)) return undefined; return { verb, target, to: { [to.key]: to.value }, ...common }; } case "screenshot": { const given = text(positional[0]); if (given === undefined) return fail("E003", 'screenshot needs a name, for example screenshot "after login"'); if (stray(1)) return undefined; return { verb, name: given, ...common }; } case "call": { const given = positional[0]; if (given === undefined || given.kind !== "word") return fail("E003", "call needs a check name, for example call balanceMatches"); if (checks !== undefined && !checks.includes(given.value)) { return fail("E011", `unknown check "${given.value}": ${checks.length === 0 ? "no checks are built in where this script runs" : `the built-in checks are ${checks.join(", ")}`}`); } return { verb, name: given.value, args: positional.slice(1).map((t) => t.value), ...common }; } case "run": { const given = text(positional[0]); if (given === undefined || given === "") return fail("E003", "run needs a script path, for example run shared/login.aite"); if (stray(1)) return undefined; return { verb, path: given, ...common }; } case "verify": case "verifyNot": return buildVerify(); default: return buildWait(); } }
function buildVerify(): Command | undefined { const v = verb as "verify" | "verifyNot"; const states = positional.filter((t) => t.kind === "word" && (STATES as readonly string[]).includes(t.value)); const others = positional.filter((t) => !states.includes(t)); const isDownload = positional[0]?.kind === "word" && positional[0].value === "download"; if (others[0] && !isDownload && stray(positional.indexOf(others[0]))) return undefined; const combo = () => fail("E003", `this combination of ${v} is not in the script format`); const keys = rest2.map((p) => p.key); if (!hasTarget(target) && keys.length === 0 && states.length === 0 && !isDownload) return fail("E003", `${v} needs something to check, for example text:"Saved"`); if (states.length > 1) return fail("E003", `${v} can check only one of enabled, disabled, checked`); const only = (...allowedKeys: string[]) => keys.every((k) => allowedKeys.includes(k)) && !hasTarget(target) && states.length === 0; const looseKeys = ["text-i", "text~i"].filter((k) => keys.includes(k)); if (looseKeys.length > 0) { const key = looseKeys[0]!; // the loose text is the only thing being checked: no exact text beside it, no other condition if (looseKeys.length > 1 || target.text !== undefined || states.length > 0 || keys.some((k) => k !== key)) return combo(); const match: TextMatch = key === "text-i" ? "ignoreCase" : "containsIgnoreCase"; return hasTarget(target) ? { verb: v, kind: "targetText", target, value: pairValue(key)!, match, ...withIn, ...common } : { verb: v, kind: "text", value: pairValue(key)!, match, ...withIn, ...common }; } if (isDownload) { if (stray(1)) return undefined; if (modifiers.within !== undefined) return fail("E003", "within: does not apply to a download"); const exact = target.name; const part = pairValue("name~"); const sizeText = pairValue("size-min"); if ((exact === undefined) === (part === undefined)) return fail("E003", 'verify download needs the file name, for example verify download name:"report.zip" (or name~:"report" for a part of it)'); if (Object.keys(target).some((k) => k !== "name") || states.length > 0 || keys.some((k) => k !== "name~" && k !== "size-min")) return combo(); if (sizeText !== undefined && !/^\d+$/.test(sizeText)) return fail("E003", "size-min needs a whole number of bytes, for example size-min:1000"); return { verb: v, kind: "download", name: (exact ?? part)!, ...(part !== undefined ? { nameMatch: "contains" as const } : {}), ...(sizeText !== undefined ? { sizeMin: Number(sizeText) } : {}), ...common }; } if (keys.includes("attr")) { const name = pairValue("attr")!; if (!/^[A-Za-z_][A-Za-z0-9_:.-]*$/.test(name)) return fail("E003", 'attr needs an attribute name, for example attr:href'); const op = keys.includes("equals") ? "equals" : keys.includes("contains") ? "contains" : undefined; if (op === undefined || (keys.includes("equals") && keys.includes("contains"))) return fail("E003", 'verify attr: needs one of equals:"..." or contains:"...", for example verify text:"Gift" attr:href contains:"/gift/"'); if (!hasTarget(target) || states.length > 0 || keys.some((k) => k !== "attr" && k !== op)) return combo(); return { verb: v, kind: "attr", target, attr: name, op, value: pairValue(op)!, ...withIn, ...common }; } if (keys.includes("contains")) return fail("E003", "contains: goes with attr:, for example attr:href contains:\"/gift/\""); if (keys.includes("size-min") || keys.includes("name~")) return fail("E003", "size-min: and name~: go with download, for example verify download name:\"report.zip\" size-min:1000"); if (keys.includes("url~")) return only("url~") ? { verb: v, kind: "url", contains: pairValue("url~")!, ...withIn, ...common } : combo(); if (keys.includes("title")) return only("title") ? { verb: v, kind: "title", value: pairValue("title")!, ...withIn, ...common } : combo(); if (keys.includes("number")) { const from = pairValue("from"); const equals = pairValue("equals"); const n = equals === undefined || equals === "" ? NaN : Number(equals); if (pairValue("number") !== "changed-by" || from === undefined || Number.isNaN(n)) return fail("E003", "number:changed-by needs from:NAME and equals:NUMBER"); if (!hasTarget(target) || states.length > 0 || keys.some((k) => !["number", "from", "equals"].includes(k))) return combo(); return { verb: v, kind: "numberChange", target, from, equals: n, ...withIn, ...common }; } if (keys.includes("from") || keys.includes("equals")) return fail("E003", "number:changed-by needs from:NAME and equals:NUMBER"); const kindCount = (keys.includes("value") ? 1 : 0) + (keys.includes("count") ? 1 : 0) + states.length; const expectedText = hasTarget(target) && Object.keys(target).length > 1 && target.text !== undefined ? target.text : undefined; const bare = expectedText === undefined ? target : Object.fromEntries(Object.entries(target).filter(([k]) => k !== "text")); if (kindCount + (expectedText === undefined ? 0 : 1) > 1) return combo(); if (!hasTarget(bare) && kindCount > 0) return combo(); if (keys.includes("value")) return { verb: v, kind: "value", target: bare, value: pairValue("value")!, ...withIn, ...common }; if (keys.includes("count")) { const count = pairValue("count")!; if (!/^\d+$/.test(count)) return fail("E003", "count needs a whole number, for example count:3"); return { verb: v, kind: "count", target: bare, count: Number(count), ...withIn, ...common }; } if (states.length === 1) return { verb: v, kind: "state", target: bare, state: states[0]!.value as (typeof STATES)[number], ...withIn, ...common }; if (expectedText !== undefined) return { verb: v, kind: "targetText", target: bare, value: expectedText, ...withIn, ...common }; if (Object.keys(target).length === 1 && target.text !== undefined) return { verb: v, kind: "text", value: target.text, ...withIn, ...common }; return { verb: v, kind: "exists", target: bare, ...withIn, ...common }; }
function buildWait(): Command | undefined { const words = positional.map((t) => t.value); const combo = () => fail("E003", "this combination of wait is not in the script format"); const keys = rest2.map((p) => p.key); const known = ["gone", "text-changes", "network-idle"]; const odd = positional.find((t) => t.kind !== "word" || !known.includes(t.value)); if (odd) { stray(positional.indexOf(odd)); return undefined; } if (words.includes("network-idle")) return words.length === 1 && !hasTarget(target) && keys.length === 0 ? { verb: "wait", kind: "networkIdle", ...withIn, ...common } : combo(); if (keys.includes("url~")) return !hasTarget(target) && keys.length === 1 && words.length === 0 ? { verb: "wait", kind: "url", contains: pairValue("url~")!, ...withIn, ...common } : combo(); if (!hasTarget(target)) return fail("E003", 'wait needs something to wait for, for example wait text:"Saved"'); if (words.length > 1) return combo(); if (words[0] === "gone") return { verb: "wait", kind: "gone", target, ...withIn, ...common }; if (words[0] === "text-changes") return { verb: "wait", kind: "textChanges", target, ...withIn, ...common }; return { verb: "wait", kind: "visible", target, ...withIn, ...common }; }}