mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-04 19:20:28 +09:00
## Summary Fixes the reproducible MCP regressions reported today: - validate font upload metadata locally instead of surfacing an HTTP 500 - replace only the addressed child when `insertIndex` is supplied - support cross-parent duplication - bind data-variable assignments in actions - reject string-valued `style` bindings - include descriptions for every advertised MCP tool - preserve resource data-source exposure on full updates - render mixed expression/element children in the Builder canvas - preserve explicit outer Collection references in nested scopes - reject unsupported `update-page-settings` fields instead of dropping them - reject unavailable asset-query variables and log server error details Closes #6390 Closes #6391 Closes #6392 Closes #6393 Closes #6394 Closes #6395 Closes #6398 Closes #6399 Closes #6401 Closes #6404 Closes #6405 ## Verification - 484 project-build tests - 62 CLI tests - 35 Builder browser tests - 25 SDK tests - project-build, Builder, CLI, and SDK type checks - root lint and generated runtime contracts
1086 lines
32 KiB
TypeScript
1086 lines
32 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import { parseExpressionAt } from "acorn";
|
|
import {
|
|
type Diagnostic,
|
|
getExpressionValueKind,
|
|
isLiteralExpression,
|
|
isValidExpression,
|
|
lintExpression,
|
|
transpileExpression,
|
|
getExpressionIdentifiers,
|
|
generateObjectExpression,
|
|
generateJsonExpression,
|
|
parseJsonExpression,
|
|
parseExpressionObject,
|
|
parseArrayExpression,
|
|
parseDirectPathExpression,
|
|
parseStaticMemberPath,
|
|
parseStringLiteralExpression,
|
|
allowedArrayMethods,
|
|
allowedStringMethods,
|
|
} from "./index";
|
|
|
|
test("validates complete expressions", () => {
|
|
expect(isValidExpression("post.title ?? 'Untitled'")).toBe(true);
|
|
expect(isValidExpression("post.title trailing")).toBe(false);
|
|
expect(isValidExpression("{")).toBe(false);
|
|
});
|
|
|
|
describe("static member paths", () => {
|
|
test.each([
|
|
["system.params.slug", ["system", "params", "slug"]],
|
|
['system.params["slug"]', ["system", "params", "slug"]],
|
|
["system?.params?.slug", ["system", "params", "slug"]],
|
|
['system?.params?.["slug"]', ["system", "params", "slug"]],
|
|
["items[0].title", ["items", "0", "title"]],
|
|
["system.params[slug]", undefined],
|
|
["system.params.slug ?? fallback", undefined],
|
|
["system.params.slug trailing", undefined],
|
|
["system.params.", undefined],
|
|
])("parses %s", (source, expected) => {
|
|
expect(parseStaticMemberPath(source)).toEqual(expected);
|
|
});
|
|
});
|
|
|
|
describe("direct path expressions", () => {
|
|
test.each([
|
|
"document.frontmatter.author?.name",
|
|
"document?.frontmatter?.author?.name",
|
|
'document.frontmatter?.["author"].name',
|
|
"(document.frontmatter?.author).name",
|
|
])("preserves the writable location of %s", (expression) => {
|
|
expect(parseDirectPathExpression(expression)).toEqual({
|
|
type: "direct-path",
|
|
expression,
|
|
path: ["document", "frontmatter", "author", "name"],
|
|
});
|
|
});
|
|
|
|
test("distinguishes a writable location from a derived expression", () => {
|
|
expect(parseDirectPathExpression("document.frontmatter.title")).toEqual({
|
|
type: "direct-path",
|
|
expression: "document.frontmatter.title",
|
|
path: ["document", "frontmatter", "title"],
|
|
});
|
|
expect(
|
|
parseDirectPathExpression('document.frontmatter.title + "!"')
|
|
).toBeUndefined();
|
|
expect(
|
|
parseDirectPathExpression("document.frontmatter[fieldName]")
|
|
).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("string literal expressions", () => {
|
|
test.each([
|
|
['"assets"', "assets"],
|
|
["'assets'", undefined],
|
|
[' "assets"', undefined],
|
|
['"assets" trailing', undefined],
|
|
["assets", undefined],
|
|
])("parses %s", (source, expected) => {
|
|
expect(parseStringLiteralExpression(source)).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe("lint expression", () => {
|
|
const error = (from: number, to: number, message: string): Diagnostic => ({
|
|
from,
|
|
to,
|
|
severity: "error",
|
|
message,
|
|
});
|
|
|
|
const warn = (from: number, to: number, message: string): Diagnostic => ({
|
|
from,
|
|
to,
|
|
severity: "warning",
|
|
message,
|
|
});
|
|
|
|
test("forbid empty expression", () => {
|
|
expect(lintExpression({ expression: `` })).toEqual([
|
|
error(0, 0, `Expression cannot be empty`),
|
|
]);
|
|
expect(lintExpression({ expression: ` ` })).toEqual([
|
|
error(0, 0, `Expression cannot be empty`),
|
|
]);
|
|
});
|
|
|
|
test("output parse error as diagnostic", () => {
|
|
expect(lintExpression({ expression: `a + ` })).toEqual([
|
|
error(4, 4, "Unexpected token"),
|
|
]);
|
|
expect(lintExpression({ expression: `"string" + a)` })).toEqual([
|
|
error(13, 13, "Unexpected token"),
|
|
]);
|
|
});
|
|
|
|
test("restrict expression syntax", () => {
|
|
expect(lintExpression({ expression: `var a = 1` })).toEqual([
|
|
error(0, 0, "Unexpected token"),
|
|
]);
|
|
});
|
|
|
|
test("lint whole expression instead of only first valid part", () => {
|
|
expect(
|
|
lintExpression({ expression: `a""`, availableVariables: new Set(["a"]) })
|
|
).toEqual([error(1, 1, `Unexpected token`)]);
|
|
expect(
|
|
lintExpression({
|
|
expression: `/movie/{{CollectionItem['title']}}\n`,
|
|
availableVariables: new Set([""]),
|
|
})
|
|
).toEqual([error(7, 7, `Unexpected token`)]);
|
|
});
|
|
|
|
test("supports accessing variable fields", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: `a.b.c`,
|
|
availableVariables: new Set(["a"]),
|
|
})
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("supports literals", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: `"" + 0 + true + [] + {}`,
|
|
})
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("supports ternary operator", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: `true ? false : true`,
|
|
})
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("supports template literals", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: "`my ${1} template ${'2'}`",
|
|
})
|
|
).toEqual([]);
|
|
expect(
|
|
lintExpression({
|
|
expression: "`my template",
|
|
})
|
|
).toEqual([error(1, 1, "Unterminated template")]);
|
|
});
|
|
|
|
test("supports parentheses", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: "(1 + 1) * 2",
|
|
})
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("forbid assignment until enabled", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: ` a = 1`,
|
|
availableVariables: new Set(["a"]),
|
|
})
|
|
).toEqual([error(1, 6, "Assignment is not supported in this context")]);
|
|
expect(
|
|
lintExpression({
|
|
expression: ` a = 1`,
|
|
allowAssignment: true,
|
|
availableVariables: new Set(["a"]),
|
|
})
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("lint member assignment targets", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: ` a[b].c = d`,
|
|
allowAssignment: true,
|
|
availableVariables: new Set(["a", "b", "d"]),
|
|
})
|
|
).toEqual([]);
|
|
expect(
|
|
lintExpression({
|
|
expression: ` a.b = c`,
|
|
allowAssignment: true,
|
|
availableVariables: new Set(["c"]),
|
|
})
|
|
).toEqual([warn(1, 2, `"a" is not defined in the scope`)]);
|
|
});
|
|
|
|
test("lint assignment operators and targets", () => {
|
|
const assignmentExpressions = [
|
|
` a = b`,
|
|
` a += b`,
|
|
` a -= b`,
|
|
` a *= b`,
|
|
` a /= b`,
|
|
` a %= b`,
|
|
` a **= b`,
|
|
` a ||= b`,
|
|
` a &&= b`,
|
|
` a ??= b`,
|
|
` a.b = c`,
|
|
` a[b] = c`,
|
|
` a[b].c = d`,
|
|
` a.b[c.d].e ??= f.g`,
|
|
];
|
|
|
|
for (const expression of assignmentExpressions) {
|
|
expect(
|
|
lintExpression({
|
|
expression,
|
|
allowAssignment: true,
|
|
availableVariables: new Set(["a", "b", "c", "d", "f"]),
|
|
})
|
|
).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test("forbid destructuring assignment", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: ` ({ a } = b)`,
|
|
allowAssignment: true,
|
|
availableVariables: new Set(["b"]),
|
|
})
|
|
).toEqual([error(2, 7, "Destructuring assignment is not supported")]);
|
|
expect(
|
|
lintExpression({
|
|
expression: ` [a] = b`,
|
|
allowAssignment: true,
|
|
availableVariables: new Set(["b"]),
|
|
})
|
|
).toEqual([error(1, 4, "Destructuring assignment is not supported")]);
|
|
expect(
|
|
lintExpression({
|
|
expression: ` ({ nested: a } = b)`,
|
|
allowAssignment: true,
|
|
availableVariables: new Set(["b"]),
|
|
})
|
|
).toEqual([error(2, 15, "Destructuring assignment is not supported")]);
|
|
});
|
|
|
|
test("forbid unavailable variables", () => {
|
|
expect(
|
|
lintExpression({ expression: ` a = b + 1`, allowAssignment: true })
|
|
).toEqual([
|
|
warn(5, 6, `"b" is not defined in the scope`),
|
|
warn(1, 2, `"a" is not defined in the scope`),
|
|
]);
|
|
expect(
|
|
lintExpression({
|
|
expression: ` a = b + 1`,
|
|
allowAssignment: true,
|
|
availableVariables: new Set(["a", "b"]),
|
|
})
|
|
).toEqual([]);
|
|
});
|
|
|
|
test(`forbid "this" keyword`, () => {
|
|
expect(lintExpression({ expression: ` this.name` })).toEqual([
|
|
error(1, 5, `"this" keyword is not supported`),
|
|
]);
|
|
});
|
|
|
|
test("forbid functions", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: ` function(){} + (() => {}) + fn()`,
|
|
availableVariables: new Set(["fn"]),
|
|
})
|
|
).toEqual([
|
|
error(1, 13, "Functions are not supported"),
|
|
error(17, 25, "Functions are not supported"),
|
|
error(29, 33, `"fn" function is not supported`),
|
|
]);
|
|
});
|
|
|
|
test("forbid increment and decrement", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: ` ++i + --j`,
|
|
availableVariables: new Set(["j", "i"]),
|
|
})
|
|
).toEqual([
|
|
error(1, 4, "Increment and decrement are not supported"),
|
|
error(7, 10, "Increment and decrement are not supported"),
|
|
]);
|
|
});
|
|
|
|
test("forbid sequence expression", () => {
|
|
expect(lintExpression({ expression: ` 1, 2, 3` })).toEqual([
|
|
error(1, 8, "Only single expression is supported"),
|
|
]);
|
|
});
|
|
|
|
test(`forbid "yield" keyword`, () => {
|
|
expect(lintExpression({ expression: ` yield 1` })).toEqual([
|
|
error(1, 1, `The keyword 'yield' is reserved`),
|
|
]);
|
|
});
|
|
|
|
test("forbid tagged template", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: " tag`hello`",
|
|
availableVariables: new Set(["tag"]),
|
|
})
|
|
).toEqual([error(1, 11, "Tagged template is not supported")]);
|
|
});
|
|
|
|
test("forbid classes", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: ` class {} + new MyClass()`,
|
|
availableVariables: new Set(["MyClass"]),
|
|
})
|
|
).toEqual([
|
|
error(1, 9, "Classes are not supported"),
|
|
error(12, 25, "Classes are not supported"),
|
|
]);
|
|
});
|
|
|
|
test("forbid imports", () => {
|
|
expect(
|
|
lintExpression({ expression: ` import("") + import.meta.url` })
|
|
).toEqual([
|
|
error(1, 11, "Imports are not supported"),
|
|
error(14, 25, "Imports are not supported"),
|
|
]);
|
|
});
|
|
|
|
test(`forbid "await" keyword`, () => {
|
|
expect(lintExpression({ expression: ` await 1` })).toEqual([
|
|
error(1, 8, `"await" keyword is not supported`),
|
|
]);
|
|
});
|
|
|
|
test.each([
|
|
"toLowerCase",
|
|
"replace",
|
|
"split",
|
|
"at",
|
|
"slice",
|
|
"endsWith",
|
|
"includes",
|
|
"startsWith",
|
|
"toString",
|
|
"toUpperCase",
|
|
"toLocaleLowerCase",
|
|
"toLocaleUpperCase",
|
|
])("allow safe string method: %s", (method) => {
|
|
expect(
|
|
lintExpression({
|
|
expression: `title.${method}()`,
|
|
availableVariables: new Set(["title"]),
|
|
})
|
|
).toEqual([]);
|
|
});
|
|
|
|
test.each(["at", "includes", "join", "slice", "toString"])(
|
|
"allow safe array method: %s",
|
|
(method) => {
|
|
expect(
|
|
lintExpression({
|
|
expression: `arr.${method}()`,
|
|
availableVariables: new Set(["arr"]),
|
|
})
|
|
).toEqual([]);
|
|
}
|
|
);
|
|
|
|
test("allow supported methods on known native-compatible receivers", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: `title.split("").toString()`,
|
|
availableVariables: new Set(["title"]),
|
|
variableValues: new Map([["title", "Test"]]),
|
|
})
|
|
).toEqual([]);
|
|
expect(
|
|
lintExpression({
|
|
expression: `arr.join("").split("").toString()`,
|
|
availableVariables: new Set(["arr"]),
|
|
variableValues: new Map([["arr", ["T", "e", "s", "t"]]]),
|
|
})
|
|
).toEqual([]);
|
|
expect(
|
|
lintExpression({
|
|
expression: `count.toString() + flag.toString() + object.toString()`,
|
|
availableVariables: new Set(["count", "flag", "object"]),
|
|
variableValues: new Map<string, unknown>([
|
|
["count", 2],
|
|
["flag", true],
|
|
["object", {}],
|
|
]),
|
|
})
|
|
).toEqual([]);
|
|
expect(
|
|
lintExpression({
|
|
expression: `title.replace("e", "a").toLowerCase()`,
|
|
availableVariables: new Set(["title"]),
|
|
})
|
|
).toEqual([]);
|
|
expect(
|
|
lintExpression({
|
|
expression: `arr.join("").toLowerCase()`,
|
|
availableVariables: new Set(["arr"]),
|
|
})
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("forbid supported method names on known native-incompatible receivers", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: `arr.split()`,
|
|
availableVariables: new Set(["arr"]),
|
|
variableValues: new Map([["arr", []]]),
|
|
}).map((diagnostic) => diagnostic.message)
|
|
).toEqual([`"split" function is not supported`]);
|
|
expect(
|
|
lintExpression({
|
|
expression: `title.join()`,
|
|
availableVariables: new Set(["title"]),
|
|
variableValues: new Map([["title", "Test"]]),
|
|
}).map((diagnostic) => diagnostic.message)
|
|
).toEqual([`"join" function is not supported`]);
|
|
expect(
|
|
lintExpression({
|
|
expression: `[1, 2, 3].toLowerCase()`,
|
|
}).map((diagnostic) => diagnostic.message)
|
|
).toEqual([`"toLowerCase" function is not supported`]);
|
|
expect(
|
|
lintExpression({
|
|
expression: `"Test".join()`,
|
|
}).map((diagnostic) => diagnostic.message)
|
|
).toEqual([`"join" function is not supported`]);
|
|
expect(
|
|
lintExpression({
|
|
expression: `title.split("").toLowerCase()`,
|
|
availableVariables: new Set(["title"]),
|
|
}).map((diagnostic) => diagnostic.message)
|
|
).toEqual([`"toLowerCase" function is not supported`]);
|
|
expect(
|
|
lintExpression({
|
|
expression: `arr.join("").join()`,
|
|
availableVariables: new Set(["arr"]),
|
|
}).map((diagnostic) => diagnostic.message)
|
|
).toEqual([`"join" function is not supported`]);
|
|
expect(
|
|
lintExpression({
|
|
expression: `null.toString() + undefined.toString()`,
|
|
}).map((diagnostic) => diagnostic.message)
|
|
).toEqual([
|
|
`"toString" function is not supported`,
|
|
`"undefined" is not defined in the scope`,
|
|
`"toString" function is not supported`,
|
|
]);
|
|
});
|
|
|
|
test("allow chained string methods", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: `title.toLowerCase().replace(" ", "-").split("-")`,
|
|
availableVariables: new Set(["title"]),
|
|
})
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("forbid unsafe method calls", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: `arr.pop()`,
|
|
availableVariables: new Set(["arr"]),
|
|
})
|
|
).toEqual([error(0, 9, `"pop" function is not supported`)]);
|
|
expect(
|
|
lintExpression({
|
|
expression: `obj.push(1)`,
|
|
availableVariables: new Set(["obj"]),
|
|
})
|
|
).toEqual([error(0, 11, `"push" function is not supported`)]);
|
|
});
|
|
|
|
test("forbid standalone function calls", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: `func()`,
|
|
availableVariables: new Set(["func"]),
|
|
})
|
|
).toEqual([error(0, 6, `"func" function is not supported`)]);
|
|
});
|
|
|
|
test("visits supported unary, logical, and optional-chain syntax", () => {
|
|
expect(
|
|
lintExpression({
|
|
expression: `!value || record?.enabled`,
|
|
availableVariables: new Set(["value", "record"]),
|
|
})
|
|
).toEqual([]);
|
|
});
|
|
|
|
test("rejects calls without a named callee", () => {
|
|
expect(
|
|
lintExpression({ expression: `(function () {})()` }).map(
|
|
({ message }) => message
|
|
)
|
|
).toEqual(["Functions are not supported", "Functions are not supported"]);
|
|
});
|
|
});
|
|
|
|
describe("expression value kinds", () => {
|
|
test.each([
|
|
["[]", "array"],
|
|
["1n", "bigint"],
|
|
["true", "boolean"],
|
|
["undefined", "nullish"],
|
|
["null", "nullish"],
|
|
["1", "number"],
|
|
["{}", "object"],
|
|
['"text"', "string"],
|
|
["`text`", "string"],
|
|
['"text" + value', "string"],
|
|
['"text" /* trailing comment */', "string"],
|
|
["value +", "unknown"],
|
|
['"text" trailing', "unknown"],
|
|
] as const)("classifies %s as %s", (expression, kind) => {
|
|
expect(getExpressionValueKind({ expression })).toBe(kind);
|
|
});
|
|
|
|
test("uses map and record variable values", () => {
|
|
expect(
|
|
getExpressionValueKind({
|
|
expression: "value",
|
|
variableValues: new Map([["value", [1, 2]]]),
|
|
})
|
|
).toBe("array");
|
|
expect(
|
|
getExpressionValueKind({
|
|
expression: "value",
|
|
variableValues: { value: "text" },
|
|
})
|
|
).toBe("string");
|
|
expect(
|
|
getExpressionValueKind({
|
|
expression: "missing",
|
|
variableValues: new Map(),
|
|
})
|
|
).toBe("unknown");
|
|
expect(
|
|
getExpressionValueKind({
|
|
expression: "missing",
|
|
variableValues: {},
|
|
})
|
|
).toBe("unknown");
|
|
expect(
|
|
getExpressionValueKind({
|
|
expression: "callback",
|
|
variableValues: { callback: () => undefined },
|
|
})
|
|
).toBe("unknown");
|
|
});
|
|
|
|
test("infers supported method return kinds", () => {
|
|
expect(getExpressionValueKind({ expression: '"text".split("")' })).toBe(
|
|
"array"
|
|
);
|
|
expect(getExpressionValueKind({ expression: "[].join(',')" })).toBe(
|
|
"string"
|
|
);
|
|
expect(
|
|
getExpressionValueKind({ expression: "unknownValue.split('')" })
|
|
).toBe("array");
|
|
expect(
|
|
getExpressionValueKind({ expression: "unknownValue.join(',')" })
|
|
).toBe("string");
|
|
expect(getExpressionValueKind({ expression: "unknownValue.at(0)" })).toBe(
|
|
"unknown"
|
|
);
|
|
expect(getExpressionValueKind({ expression: "unknownValue.slice()" })).toBe(
|
|
"unknown"
|
|
);
|
|
expect(getExpressionValueKind({ expression: "true.toString()" })).toBe(
|
|
"string"
|
|
);
|
|
expect(getExpressionValueKind({ expression: "true.slice()" })).toBe(
|
|
"unknown"
|
|
);
|
|
expect(getExpressionValueKind({ expression: "value?.name" })).toBe(
|
|
"unknown"
|
|
);
|
|
});
|
|
});
|
|
|
|
test("check simple literals", () => {
|
|
expect(isLiteralExpression(`""`)).toEqual(true);
|
|
expect(isLiteralExpression(`''`)).toEqual(true);
|
|
expect(isLiteralExpression(`0`)).toEqual(true);
|
|
expect(isLiteralExpression(`true`)).toEqual(true);
|
|
expect(isLiteralExpression(`[]`)).toEqual(true);
|
|
expect(isLiteralExpression(`{}`)).toEqual(true);
|
|
expect(isLiteralExpression(`undefined`)).toEqual(true);
|
|
expect(isLiteralExpression(`"" + ""`)).toEqual(false);
|
|
expect(isLiteralExpression(`{}.field`)).toEqual(false);
|
|
expect(isLiteralExpression(`variable`)).toEqual(false);
|
|
expect(isLiteralExpression(`1 trailing`)).toEqual(false);
|
|
expect(isLiteralExpression(``)).toEqual(false);
|
|
});
|
|
|
|
test("check complex objects and arrays", () => {
|
|
expect(isLiteralExpression(`[1, 2, 3]`)).toEqual(true);
|
|
expect(isLiteralExpression(`[1, 2, variable]`)).toEqual(false);
|
|
expect(isLiteralExpression(`[...variable]`)).toEqual(false);
|
|
expect(isLiteralExpression(`{ param: 0 }`)).toEqual(true);
|
|
expect(isLiteralExpression(`{ "param": 0 }`)).toEqual(true);
|
|
expect(isLiteralExpression(`{ param: variable }`)).toEqual(false);
|
|
expect(isLiteralExpression(`{ ["param"]: 0 }`)).toEqual(true);
|
|
expect(isLiteralExpression(`{ [variable]: 0 }`)).toEqual(false);
|
|
expect(isLiteralExpression(`{ ...variable }`)).toEqual(false);
|
|
});
|
|
|
|
describe("get expression identifiers", () => {
|
|
test("find all identifiers", () => {
|
|
expect(getExpressionIdentifiers("a = b * c.d")).toEqual(
|
|
new Set(["a", "b", "c"])
|
|
);
|
|
});
|
|
|
|
test("find identifiers in assignment targets", () => {
|
|
expect(getExpressionIdentifiers("a[b].c = d.e")).toEqual(
|
|
new Set(["a", "b", "d"])
|
|
);
|
|
expect(getExpressionIdentifiers("a.b[c.d].e ??= f.g")).toEqual(
|
|
new Set(["a", "c", "f"])
|
|
);
|
|
});
|
|
|
|
test("deduplicate identifiers", () => {
|
|
expect(getExpressionIdentifiers("a = a + b")).toEqual(new Set(["a", "b"]));
|
|
});
|
|
|
|
test("not fail when invalid syntax", () => {
|
|
expect(getExpressionIdentifiers("")).toEqual(new Set());
|
|
expect(getExpressionIdentifiers("a = a +")).toEqual(new Set());
|
|
expect(getExpressionIdentifiers("a trailing")).toEqual(new Set());
|
|
});
|
|
});
|
|
|
|
describe("transpile expression", () => {
|
|
const expectTranspiledExpressionToMatchNative = (expression: string) => {
|
|
const nativeResult = new Function(`return (${expression})`)();
|
|
const transpiledResult = new Function(
|
|
`return (${transpileExpression({ expression, executable: true })})`
|
|
)();
|
|
expect(transpiledResult).toEqual(nativeResult);
|
|
};
|
|
|
|
test("preserve spaces and parentheses", () => {
|
|
expect(
|
|
transpileExpression({ expression: " 1 + (2 + 3) ", executable: true })
|
|
).toEqual(" 1 + (2 + 3) ");
|
|
});
|
|
|
|
test("add optional chaining with dot syntax", () => {
|
|
expect(
|
|
transpileExpression({ expression: "a.b . c", executable: true })
|
|
).toEqual("a?.b ?. c");
|
|
});
|
|
|
|
test("add optional chaining with computed", () => {
|
|
expect(
|
|
transpileExpression({ expression: "a['b'] [c]", executable: true })
|
|
).toEqual("a?.['b'] ?.[c]");
|
|
});
|
|
|
|
test("skip optional chaining", () => {
|
|
expect(
|
|
transpileExpression({ expression: "a?.['b']?.c", executable: true })
|
|
).toEqual("a?.['b']?.c");
|
|
});
|
|
|
|
test("skip optional chaining in assignment targets", () => {
|
|
expect(
|
|
transpileExpression({ expression: "a.b = c.d", executable: true })
|
|
).toEqual("a.b = c?.d");
|
|
expect(
|
|
transpileExpression({ expression: "a[b.c].d = e.f", executable: true })
|
|
).toEqual("a[b?.c].d = e?.f");
|
|
});
|
|
|
|
test("skip optional chaining in assignment targets for all assignment operators", () => {
|
|
const operators = [
|
|
"=",
|
|
"+=",
|
|
"-=",
|
|
"*=",
|
|
"/=",
|
|
"%=",
|
|
"**=",
|
|
"||=",
|
|
"&&=",
|
|
"??=",
|
|
];
|
|
|
|
for (const operator of operators) {
|
|
const expression = `a.b ${operator} c.d`;
|
|
const transpiled = transpileExpression({ expression, executable: true });
|
|
expect(transpiled).toEqual(`a.b ${operator} c?.d`);
|
|
expect(() =>
|
|
parseExpressionAt(transpiled, 0, { ecmaVersion: "latest" })
|
|
).not.toThrow();
|
|
}
|
|
});
|
|
|
|
test("transpile executable assignment expressions", () => {
|
|
const cases = [
|
|
["a.b = c.d", "a.b = c?.d"],
|
|
["a[b] = c.d", "a[b] = c?.d"],
|
|
["a[b.c] = d.e", "a[b?.c] = d?.e"],
|
|
["a[b.c].d = e.f", "a[b?.c].d = e?.f"],
|
|
["a.b[c.d].e ??= f.g", "a.b[c?.d].e ??= f?.g"],
|
|
] as const;
|
|
|
|
for (const [expression, expected] of cases) {
|
|
const transpiled = transpileExpression({ expression, executable: true });
|
|
expect(transpiled).toEqual(expected);
|
|
expect(() =>
|
|
parseExpressionAt(transpiled, 0, { ecmaVersion: "latest" })
|
|
).not.toThrow();
|
|
}
|
|
});
|
|
|
|
test("replace variable", () => {
|
|
expect(
|
|
transpileExpression({
|
|
expression: "(a + c) * b",
|
|
replaceVariable: (identifier) => identifier + "_1",
|
|
})
|
|
).toEqual("(a_1 + c_1) * b_1");
|
|
expect(
|
|
transpileExpression({
|
|
expression: "a = c",
|
|
replaceVariable: (identifier) => identifier + "_1",
|
|
})
|
|
).toEqual("a_1 = c_1");
|
|
});
|
|
|
|
test("skip identifiers in nested member expressions", () => {
|
|
const identifiers: string[] = [];
|
|
const transpiled = transpileExpression({
|
|
expression: "a.b + c.d * e[f]",
|
|
replaceVariable: (identifier) => {
|
|
identifiers.push(identifier);
|
|
return identifier + "_1";
|
|
},
|
|
});
|
|
expect(identifiers).toEqual(["a", "c", "e", "f"]);
|
|
expect(transpiled).toEqual("a_1.b + c_1.d * e_1[f_1]");
|
|
});
|
|
|
|
test("inform identifier is an assignee", () => {
|
|
const replaceVariable = (identifier: string, assignee: boolean) => {
|
|
const suffix = assignee ? "_assignee" : "_assigner";
|
|
return identifier + suffix;
|
|
};
|
|
|
|
const cases = [
|
|
["a = b", "a_assignee = b_assigner"],
|
|
["a += b", "a_assignee += b_assigner"],
|
|
["a ||= b", "a_assignee ||= b_assigner"],
|
|
["a ??= b", "a_assignee ??= b_assigner"],
|
|
["a.b = c", "a_assignee.b = c_assigner"],
|
|
["a[b] = c", "a_assignee[b_assigner] = c_assigner"],
|
|
["a[b].c = d", "a_assignee[b_assigner].c = d_assigner"],
|
|
["a.b[c.d].e ??= f.g", "a_assignee.b[c_assigner.d].e ??= f_assigner.g"],
|
|
] as const;
|
|
|
|
for (const [expression, expected] of cases) {
|
|
expect(transpileExpression({ expression, replaceVariable })).toEqual(
|
|
expected
|
|
);
|
|
}
|
|
});
|
|
|
|
test("transpile object literal without changes", () => {
|
|
expect(
|
|
transpileExpression({
|
|
expression: `{ ...name }`,
|
|
})
|
|
).toEqual(`{ ...name }`);
|
|
});
|
|
|
|
test("transpiles a parenthesized object expression", () => {
|
|
expect(
|
|
transpileExpression({
|
|
expression: "({ value: $ws$system.origin })",
|
|
replaceVariable: (identifier) =>
|
|
identifier === "$ws$system" ? "system" : identifier,
|
|
})
|
|
).toEqual("({ value: system.origin })");
|
|
});
|
|
|
|
test("output more readable syntax error", () => {
|
|
let errorString = "";
|
|
try {
|
|
transpileExpression({ expression: `` });
|
|
} catch (error) {
|
|
errorString = (error as Error).message;
|
|
}
|
|
expect(errorString).toEqual(`Unexpected token (1:0) in ""`);
|
|
});
|
|
|
|
test("rejects content after the expression", () => {
|
|
expect(() => transpileExpression({ expression: "value trailing" })).toThrow(
|
|
'Unexpected content after expression in "value trailing"'
|
|
);
|
|
});
|
|
|
|
test("transpile string methods with optional chaining", () => {
|
|
expect(
|
|
transpileExpression({
|
|
expression: "title.toLowerCase()",
|
|
executable: true,
|
|
})
|
|
).toEqual("title?.toLowerCase?.()");
|
|
expect(
|
|
transpileExpression({
|
|
expression: "user.name.replace(' ', '-')",
|
|
executable: true,
|
|
})
|
|
).toEqual("user?.name?.replace?.(' ', '-')");
|
|
expect(
|
|
transpileExpression({
|
|
expression: "data.title.split('-')",
|
|
executable: true,
|
|
})
|
|
).toEqual("data?.title?.split?.('-')");
|
|
});
|
|
|
|
test("preserve native behavior for all supported string methods", () => {
|
|
const expressionsByMethod = {
|
|
at: `"Hello".at(1)`,
|
|
endsWith: `"Hello".endsWith("lo")`,
|
|
includes: `"Hello".includes("ell")`,
|
|
replace: `"Hello".replace("l", "x")`,
|
|
slice: `"Hello".slice(1, 4)`,
|
|
split: `"Hello".split("l")`,
|
|
startsWith: `"Hello".startsWith("He")`,
|
|
toLocaleLowerCase: `"Hello".toLocaleLowerCase()`,
|
|
toLocaleUpperCase: `"Hello".toLocaleUpperCase()`,
|
|
toLowerCase: `"Hello".toLowerCase()`,
|
|
toString: `"Hello".toString()`,
|
|
toUpperCase: `"Hello".toUpperCase()`,
|
|
};
|
|
|
|
expect(new Set(Object.keys(expressionsByMethod))).toEqual(
|
|
allowedStringMethods
|
|
);
|
|
for (const expression of Object.values(expressionsByMethod)) {
|
|
expectTranspiledExpressionToMatchNative(expression);
|
|
}
|
|
expectTranspiledExpressionToMatchNative(`"Test".split(2).toString()`);
|
|
});
|
|
|
|
test("preserve native behavior for all supported array methods", () => {
|
|
const expressionsByMethod = {
|
|
at: `["a", "b", "c"].at(1)`,
|
|
includes: `["a", "b", "c"].includes("b")`,
|
|
join: `["a", "b", "c"].join("-")`,
|
|
slice: `["a", "b", "c"].slice(1, 3)`,
|
|
toString: `["a", "b", "c"].toString()`,
|
|
};
|
|
|
|
expect(new Set(Object.keys(expressionsByMethod))).toEqual(
|
|
allowedArrayMethods
|
|
);
|
|
for (const expression of Object.values(expressionsByMethod)) {
|
|
expectTranspiledExpressionToMatchNative(expression);
|
|
}
|
|
});
|
|
|
|
test("transpile chained string methods with optional chaining", () => {
|
|
expect(
|
|
transpileExpression({
|
|
expression: "title.toLowerCase().replace(/\\s+/g, '-')",
|
|
executable: true,
|
|
})
|
|
).toEqual("title?.toLowerCase?.()?.replace?.(/\\s+/g, '-')");
|
|
expect(
|
|
transpileExpression({
|
|
expression: "user.name.toLowerCase().replace(' ', '-').split('-')",
|
|
executable: true,
|
|
})
|
|
).toEqual("user?.name?.toLowerCase?.()?.replace?.(' ', '-')?.split?.('-')");
|
|
});
|
|
|
|
test("transpile array methods with optional chaining", () => {
|
|
expect(
|
|
transpileExpression({
|
|
expression: "items.map(item => item.id)",
|
|
executable: true,
|
|
})
|
|
).toEqual("items?.map?.(item => item?.id)");
|
|
});
|
|
|
|
test("transpile nested method calls with optional chaining", () => {
|
|
expect(
|
|
transpileExpression({
|
|
expression: "obj.method().prop.anotherMethod()",
|
|
executable: true,
|
|
})
|
|
).toEqual("obj?.method?.()?.prop?.anotherMethod?.()");
|
|
});
|
|
|
|
test("preserve existing optional chaining", () => {
|
|
expect(
|
|
transpileExpression({
|
|
expression: "obj?.method?.()",
|
|
executable: true,
|
|
})
|
|
).toEqual("obj?.method?.()");
|
|
expect(
|
|
transpileExpression({
|
|
expression: "obj?.prop?.method?.()",
|
|
executable: true,
|
|
})
|
|
).toEqual("obj?.prop?.method?.()");
|
|
});
|
|
});
|
|
|
|
describe("object expression transformations", () => {
|
|
test("parse complete object expression", () => {
|
|
expect(
|
|
parseExpressionObject(
|
|
"{ value: system.params.slug, limit: pageSize ?? 20 }"
|
|
)
|
|
).toEqual(
|
|
new Map([
|
|
["value", "system.params.slug"],
|
|
["limit", "pageSize ?? 20"],
|
|
])
|
|
);
|
|
expect(parseExpressionObject("({ value: 1 })")).toEqual(
|
|
new Map([["value", "1"]])
|
|
);
|
|
expect(parseExpressionObject("(({ value: 1 }))")).toEqual(
|
|
new Map([["value", "1"]])
|
|
);
|
|
expect(parseExpressionObject("{} trailing")).toBeUndefined();
|
|
expect(parseExpressionObject("({}) trailing")).toBeUndefined();
|
|
expect(parseExpressionObject("{")).toBeUndefined();
|
|
expect(parseExpressionObject("0")).toBeUndefined();
|
|
expect(parseExpressionObject("{ value: 1, ...other }")).toBeUndefined();
|
|
expect(parseExpressionObject("{ [value]: 1 }")).toBeUndefined();
|
|
expect(parseExpressionObject('{ "quoted-key": 1 }')).toEqual(
|
|
new Map([["quoted-key", "1"]])
|
|
);
|
|
expect(parseExpressionObject("{ 1: true }")).toBeUndefined();
|
|
expect(parseExpressionObject("{ value: 1, value: 2 }")).toBeUndefined();
|
|
});
|
|
|
|
test("parse array expression", () => {
|
|
expect(parseArrayExpression(`[0, "", $c + 1]`)).toEqual([
|
|
"0",
|
|
'""',
|
|
"$c + 1",
|
|
]);
|
|
expect(parseArrayExpression("0")).toBeUndefined();
|
|
expect(parseArrayExpression("[...items]")).toBeUndefined();
|
|
expect(parseArrayExpression("[")).toBeUndefined();
|
|
expect(parseArrayExpression("[1] trailing")).toBeUndefined();
|
|
});
|
|
|
|
test("format object expression", () => {
|
|
expect(
|
|
generateObjectExpression(
|
|
new Map([
|
|
["a", `0`],
|
|
["b-c", `""`],
|
|
["d", `$d`],
|
|
["default", `true`],
|
|
["$value", `null`],
|
|
["invalid key", `false`],
|
|
])
|
|
)
|
|
).toMatchInlineSnapshot(`
|
|
"{
|
|
a: 0,
|
|
"b-c": "",
|
|
d: $d,
|
|
default: true,
|
|
$value: null,
|
|
"invalid key": false,
|
|
}"
|
|
`);
|
|
});
|
|
|
|
test("format empty object expression", () => {
|
|
expect(generateObjectExpression(new Map())).toMatchInlineSnapshot(`
|
|
"{
|
|
}"
|
|
`);
|
|
});
|
|
|
|
test("format JSON as readable JavaScript expression source", () => {
|
|
expect(
|
|
generateJsonExpression({
|
|
query: {
|
|
sort: [{ field: ["properties", "date"], direction: "desc" }],
|
|
"file-content": null,
|
|
},
|
|
})
|
|
).toMatchInlineSnapshot(`
|
|
"{
|
|
query: {
|
|
sort: [{
|
|
field: [\"properties\", \"date\"],
|
|
direction: \"desc\",
|
|
}],
|
|
\"file-content\": null,
|
|
},
|
|
}"
|
|
`);
|
|
expect(() => generateJsonExpression(undefined)).toThrow();
|
|
expect(() => generateJsonExpression(new Date())).toThrow();
|
|
const source = generateJsonExpression({
|
|
enabled: true,
|
|
count: -2,
|
|
values: [null, "value"],
|
|
});
|
|
expect(parseJsonExpression(source)).toEqual({
|
|
enabled: true,
|
|
count: -2,
|
|
values: [null, "value"],
|
|
});
|
|
expect(parseJsonExpression("{ value: dynamic }")).toBeUndefined();
|
|
expect(parseJsonExpression("{ value: 1, value: 2 }")).toBeUndefined();
|
|
expect(parseJsonExpression("{ value: -1e999 }")).toBeUndefined();
|
|
expect(parseJsonExpression('{ "quoted": true }')).toEqual({
|
|
quoted: true,
|
|
});
|
|
expect(parseJsonExpression(undefined)).toBeUndefined();
|
|
expect(parseJsonExpression("true trailing")).toBeUndefined();
|
|
expect(parseJsonExpression("{")).toBeUndefined();
|
|
for (const invalid of [
|
|
"/pattern/",
|
|
"1n",
|
|
"[dynamic]",
|
|
"[,]",
|
|
"[...values]",
|
|
"{ ...value }",
|
|
"{ [key]: 1 }",
|
|
"{ method() {} }",
|
|
"{ value }",
|
|
"{ 1: true }",
|
|
]) {
|
|
expect(parseJsonExpression(invalid), invalid).toBeUndefined();
|
|
}
|
|
});
|
|
});
|