test: parse housekeeping import boundaries
This commit is contained in:
1 parent
0b46a94d57
commit
b6bf5e69ca
1 file changed
+188
-279
@@ -1,4 +1,5 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
import { posix, resolve } from "node:path";
|
import { posix, resolve } from "node:path";
|
||||||
import { createElement, type ReactNode } from "react";
|
import { createElement, type ReactNode } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
@@ -99,281 +100,147 @@ const forbiddenModuleRoots = [
|
|||||||
"src/app/mod",
|
"src/app/mod",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function decodeJavaScriptStringEscapes(value: string): string {
|
interface BabelNode {
|
||||||
let decoded = "";
|
type: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
for (let index = 0; index < value.length; index += 1) {
|
interface BabelParser {
|
||||||
const character = value[index];
|
parse(
|
||||||
if (character !== "\\") {
|
source: string,
|
||||||
decoded += character;
|
options: {
|
||||||
continue;
|
createImportExpressions: boolean;
|
||||||
}
|
plugins: readonly ["typescript", "jsx"];
|
||||||
|
sourceType: "module";
|
||||||
|
},
|
||||||
|
): BabelNode;
|
||||||
|
}
|
||||||
|
|
||||||
const escaped = value[index + 1];
|
interface ModuleAccessScan {
|
||||||
if (escaped === undefined) {
|
specifiers: readonly string[];
|
||||||
decoded += "\\";
|
violations: readonly string[];
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
if (escaped === "\n") {
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (escaped === "\r") {
|
|
||||||
index += value[index + 2] === "\n" ? 2 : 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (escaped === "x") {
|
|
||||||
const hexadecimal = value.slice(index + 2, index + 4);
|
|
||||||
if (/^[0-9A-Fa-f]{2}$/.test(hexadecimal)) {
|
|
||||||
decoded += String.fromCharCode(Number.parseInt(hexadecimal, 16));
|
|
||||||
index += 3;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (escaped === "u") {
|
|
||||||
if (value[index + 2] === "{") {
|
|
||||||
const closingBrace = value.indexOf("}", index + 3);
|
|
||||||
const hexadecimal = value.slice(index + 3, closingBrace);
|
|
||||||
if (
|
|
||||||
closingBrace >= 0 &&
|
|
||||||
/^[0-9A-Fa-f]{1,6}$/.test(hexadecimal) &&
|
|
||||||
Number.parseInt(hexadecimal, 16) <= 0x10ffff
|
|
||||||
) {
|
|
||||||
decoded += String.fromCodePoint(Number.parseInt(hexadecimal, 16));
|
|
||||||
index = closingBrace;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const hexadecimal = value.slice(index + 2, index + 6);
|
|
||||||
if (/^[0-9A-Fa-f]{4}$/.test(hexadecimal)) {
|
|
||||||
decoded += String.fromCharCode(Number.parseInt(hexadecimal, 16));
|
|
||||||
index += 5;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const standardEscapes: Readonly<Record<string, string>> = {
|
const projectRequire = createRequire(import.meta.url);
|
||||||
"0": "\0",
|
const requireFromVitest = createRequire(
|
||||||
b: "\b",
|
projectRequire.resolve("vitest/package.json"),
|
||||||
f: "\f",
|
);
|
||||||
n: "\n",
|
const babelParser = requireFromVitest("@babel/parser") as BabelParser;
|
||||||
r: "\r",
|
|
||||||
t: "\t",
|
const expressionWrapperTypes = new Set([
|
||||||
v: "\v",
|
"ParenthesizedExpression",
|
||||||
"\\": "\\",
|
"TSAsExpression",
|
||||||
"/": "/",
|
"TSInstantiationExpression",
|
||||||
'"': '"',
|
"TSNonNullExpression",
|
||||||
"'": "'",
|
"TSSatisfiesExpression",
|
||||||
"`": "`",
|
"TSTypeAssertion",
|
||||||
};
|
"TypeCastExpression",
|
||||||
decoded += standardEscapes[escaped] ?? escaped;
|
]);
|
||||||
index += 1;
|
|
||||||
|
function isBabelNode(value: unknown): value is BabelNode {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"type" in value &&
|
||||||
|
typeof value.type === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapModuleArgument(node: unknown): BabelNode | null {
|
||||||
|
let current = isBabelNode(node) ? node : null;
|
||||||
|
while (current && expressionWrapperTypes.has(current.type)) {
|
||||||
|
current = isBabelNode(current.expression) ? current.expression : null;
|
||||||
}
|
}
|
||||||
|
return current;
|
||||||
return decoded;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SourceToken {
|
function readLiteralModuleSpecifier(node: unknown): string | null {
|
||||||
kind: "word" | "string" | "punctuation";
|
const literal = unwrapModuleArgument(node);
|
||||||
value: string;
|
if (!literal) return null;
|
||||||
}
|
if (literal.type === "StringLiteral" && typeof literal.value === "string") {
|
||||||
|
return literal.value;
|
||||||
interface TokenizeResult {
|
|
||||||
index: number;
|
|
||||||
tokens: readonly SourceToken[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function scanQuotedString(
|
|
||||||
source: string,
|
|
||||||
start: number,
|
|
||||||
quote: '"' | "'",
|
|
||||||
): { index: number; value: string } {
|
|
||||||
let index = start + 1;
|
|
||||||
let rawValue = "";
|
|
||||||
|
|
||||||
while (index < source.length) {
|
|
||||||
const character = source[index];
|
|
||||||
if (character === "\\") {
|
|
||||||
rawValue += character;
|
|
||||||
const escaped = source[index + 1];
|
|
||||||
if (escaped !== undefined) {
|
|
||||||
rawValue += escaped;
|
|
||||||
index += 2;
|
|
||||||
if (escaped === "\r" && source[index] === "\n") {
|
|
||||||
rawValue += "\n";
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (character === quote) {
|
|
||||||
return {
|
|
||||||
index: index + 1,
|
|
||||||
value: decodeJavaScriptStringEscapes(rawValue),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
rawValue += character;
|
|
||||||
index += 1;
|
|
||||||
}
|
}
|
||||||
|
if (literal.type !== "TemplateLiteral") return null;
|
||||||
|
|
||||||
return { index, value: decodeJavaScriptStringEscapes(rawValue) };
|
const expressions = Array.isArray(literal.expressions)
|
||||||
}
|
? literal.expressions
|
||||||
|
: [];
|
||||||
|
const quasis = Array.isArray(literal.quasis) ? literal.quasis : [];
|
||||||
|
if (expressions.length !== 0 || quasis.length !== 1) return null;
|
||||||
|
|
||||||
function scanTemplateLiteral(source: string, start: number): TokenizeResult {
|
const quasi = isBabelNode(quasis[0]) ? quasis[0] : null;
|
||||||
const tokens: SourceToken[] = [];
|
const value = quasi?.value;
|
||||||
let index = start + 1;
|
if (
|
||||||
let rawValue = "";
|
typeof value === "object" &&
|
||||||
let interpolated = false;
|
value !== null &&
|
||||||
|
"cooked" in value &&
|
||||||
while (index < source.length) {
|
typeof value.cooked === "string"
|
||||||
const character = source[index];
|
) {
|
||||||
if (character === "\\") {
|
return value.cooked;
|
||||||
rawValue += character;
|
|
||||||
const escaped = source[index + 1];
|
|
||||||
if (escaped !== undefined) {
|
|
||||||
rawValue += escaped;
|
|
||||||
index += 2;
|
|
||||||
if (escaped === "\r" && source[index] === "\n") {
|
|
||||||
rawValue += "\n";
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (character === "`") {
|
|
||||||
if (!interpolated) {
|
|
||||||
tokens.push({
|
|
||||||
kind: "string",
|
|
||||||
value: decodeJavaScriptStringEscapes(rawValue),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { index: index + 1, tokens };
|
|
||||||
}
|
|
||||||
if (character === "$" && source[index + 1] === "{") {
|
|
||||||
interpolated = true;
|
|
||||||
const expression = tokenizeCode(source, index + 2, true);
|
|
||||||
tokens.push(...expression.tokens);
|
|
||||||
index = expression.index;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
rawValue += character;
|
|
||||||
index += 1;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
return { index, tokens };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tokenizeCode(
|
function scanModuleAccesses(source: string): ModuleAccessScan {
|
||||||
source: string,
|
const root = babelParser.parse(source, {
|
||||||
start = 0,
|
createImportExpressions: true,
|
||||||
stopAtClosingBrace = false,
|
plugins: ["typescript", "jsx"],
|
||||||
): TokenizeResult {
|
sourceType: "module",
|
||||||
const tokens: SourceToken[] = [];
|
});
|
||||||
let braceDepth = stopAtClosingBrace ? 1 : 0;
|
|
||||||
let index = start;
|
|
||||||
|
|
||||||
while (index < source.length) {
|
|
||||||
const character = source[index];
|
|
||||||
const nextCharacter = source[index + 1];
|
|
||||||
|
|
||||||
if (/\s/.test(character)) {
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (character === "/" && nextCharacter === "/") {
|
|
||||||
const lineEnd = source.indexOf("\n", index + 2);
|
|
||||||
index = lineEnd === -1 ? source.length : lineEnd + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (character === "/" && nextCharacter === "*") {
|
|
||||||
const commentEnd = source.indexOf("*/", index + 2);
|
|
||||||
index = commentEnd === -1 ? source.length : commentEnd + 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (character === '"' || character === "'") {
|
|
||||||
const string = scanQuotedString(source, index, character);
|
|
||||||
tokens.push({ kind: "string", value: string.value });
|
|
||||||
index = string.index;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (character === "`") {
|
|
||||||
const template = scanTemplateLiteral(source, index);
|
|
||||||
tokens.push(...template.tokens);
|
|
||||||
index = template.index;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (/[A-Za-z_$]/.test(character)) {
|
|
||||||
const wordStart = index;
|
|
||||||
index += 1;
|
|
||||||
while (index < source.length && /[A-Za-z0-9_$]/.test(source[index])) {
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
tokens.push({ kind: "word", value: source.slice(wordStart, index) });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (stopAtClosingBrace && character === "{") {
|
|
||||||
braceDepth += 1;
|
|
||||||
}
|
|
||||||
if (stopAtClosingBrace && character === "}") {
|
|
||||||
braceDepth -= 1;
|
|
||||||
if (braceDepth === 0) return { index: index + 1, tokens };
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens.push({ kind: "punctuation", value: character });
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { index, tokens };
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenizeModuleSource(source: string): readonly SourceToken[] {
|
|
||||||
return tokenizeCode(source).tokens;
|
|
||||||
}
|
|
||||||
function extractModuleSpecifiers(source: string): readonly string[] {
|
|
||||||
const tokens = tokenizeModuleSource(source);
|
|
||||||
const specifiers: string[] = [];
|
const specifiers: string[] = [];
|
||||||
|
const violations: string[] = [];
|
||||||
|
|
||||||
for (let index = 0; index < tokens.length; index += 1) {
|
function recordArgument(argument: unknown, kind: "import" | "require") {
|
||||||
const token = tokens[index];
|
const specifier = readLiteralModuleSpecifier(argument);
|
||||||
if (
|
if (specifier === null) {
|
||||||
token.kind !== "word" ||
|
violations.push(`<non-literal ${kind}>`);
|
||||||
(token.value !== "import" && token.value !== "export")
|
return;
|
||||||
|
}
|
||||||
|
specifiers.push(specifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visit(value: unknown): void {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) visit(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isBabelNode(value)) return;
|
||||||
|
|
||||||
|
if (value.type === "ImportDeclaration") {
|
||||||
|
const specifier = readLiteralModuleSpecifier(value.source);
|
||||||
|
if (specifier !== null) specifiers.push(specifier);
|
||||||
|
} else if (
|
||||||
|
(value.type === "ExportNamedDeclaration" ||
|
||||||
|
value.type === "ExportAllDeclaration") &&
|
||||||
|
value.source !== null
|
||||||
) {
|
) {
|
||||||
continue;
|
const specifier = readLiteralModuleSpecifier(value.source);
|
||||||
}
|
if (specifier !== null) specifiers.push(specifier);
|
||||||
|
} else if (value.type === "ImportExpression") {
|
||||||
const next = tokens[index + 1];
|
recordArgument(value.source, "import");
|
||||||
if (token.value === "import" && next?.value === "(") {
|
} else if (value.type === "TSImportType") {
|
||||||
const argument = tokens[index + 2];
|
recordArgument(value.argument, "import");
|
||||||
if (argument?.kind === "string") specifiers.push(argument.value);
|
} else if (value.type === "CallExpression") {
|
||||||
continue;
|
const arguments_ = Array.isArray(value.arguments) ? value.arguments : [];
|
||||||
}
|
if (isBabelNode(value.callee) && value.callee.type === "Import") {
|
||||||
if (token.value === "import" && next?.kind === "string") {
|
recordArgument(arguments_[0], "import");
|
||||||
specifiers.push(next.value);
|
} else if (
|
||||||
continue;
|
isBabelNode(value.callee) &&
|
||||||
}
|
value.callee.type === "Identifier" &&
|
||||||
|
value.callee.name === "require"
|
||||||
for (let cursor = index + 1; cursor < tokens.length; cursor += 1) {
|
) {
|
||||||
const candidate = tokens[cursor];
|
recordArgument(arguments_[0], "require");
|
||||||
if (candidate.value === ";") break;
|
|
||||||
if (candidate.kind === "word" && candidate.value === "from") {
|
|
||||||
const moduleSpecifier = tokens[cursor + 1];
|
|
||||||
if (moduleSpecifier?.kind === "string") {
|
|
||||||
specifiers.push(moduleSpecifier.value);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
if (key !== "type") visit(child);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return specifiers;
|
visit(root);
|
||||||
|
return { specifiers, violations };
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLocalSpecifier(
|
function normalizeLocalSpecifier(
|
||||||
routeFile: string,
|
routeFile: string,
|
||||||
specifier: string,
|
specifier: string,
|
||||||
@@ -388,11 +255,12 @@ function normalizeLocalSpecifier(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findForbiddenRouteImports(
|
function findRouteImportBoundaryViolations(
|
||||||
source: string,
|
source: string,
|
||||||
routeFile: string,
|
routeFile: string,
|
||||||
): readonly string[] {
|
): readonly string[] {
|
||||||
return extractModuleSpecifiers(source)
|
const scan = scanModuleAccesses(source);
|
||||||
|
const forbiddenPaths = scan.specifiers
|
||||||
.map((specifier) => normalizeLocalSpecifier(routeFile, specifier))
|
.map((specifier) => normalizeLocalSpecifier(routeFile, specifier))
|
||||||
.filter((path): path is string => path !== null)
|
.filter((path): path is string => path !== null)
|
||||||
.filter((path) =>
|
.filter((path) =>
|
||||||
@@ -400,6 +268,7 @@ function findForbiddenRouteImports(
|
|||||||
(root) => path === root || path.startsWith(`${root}/`),
|
(root) => path === root || path.startsWith(`${root}/`),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return [...scan.violations, ...forbiddenPaths];
|
||||||
}
|
}
|
||||||
|
|
||||||
function capabilityContext(
|
function capabilityContext(
|
||||||
@@ -612,7 +481,7 @@ describe("preview route import boundary", () => {
|
|||||||
for (const path of routeFiles) {
|
for (const path of routeFiles) {
|
||||||
const source = readFileSync(resolve(process.cwd(), path), "utf8");
|
const source = readFileSync(resolve(process.cwd(), path), "utf8");
|
||||||
|
|
||||||
expect(findForbiddenRouteImports(source, path), path).toEqual([]);
|
expect(findRouteImportBoundaryViolations(source, path), path).toEqual([]);
|
||||||
expect(source, path).not.toMatch(/AdminSidebarNav|AdminHubChrome/);
|
expect(source, path).not.toMatch(/AdminSidebarNav|AdminHubChrome/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -620,6 +489,48 @@ describe("preview route import boundary", () => {
|
|||||||
const interpolationOpen = "$" + "{";
|
const interpolationOpen = "$" + "{";
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
[
|
||||||
|
"U+2028 line-continuation database import",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'import db from "../\\' + "\u2028" + '../lib/db";',
|
||||||
|
"src/lib/db",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"U+2029 line-continuation database import",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'import db from "../\\' + "\u2029" + '../lib/db";',
|
||||||
|
"src/lib/db",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"parenthesized dynamic action import",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'import(("../../actions/users"))',
|
||||||
|
"src/actions/users",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"regex-brace template-expression action import",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
`const x = \`${interpolationOpen}/}/.test(value) ? import("../../actions/users") : null}\`;`,
|
||||||
|
"src/actions/users",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"CommonJS database require",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'require("../../lib/db")',
|
||||||
|
"src/lib/db",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"template-literal dynamic action import",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
"import(`../../actions/users`)",
|
||||||
|
"src/actions/users",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"TypeScript-asserted dynamic action import",
|
||||||
|
"src/app/admin-next/page.tsx",
|
||||||
|
'import(("../../actions/users" as string))',
|
||||||
|
"src/actions/users",
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"template-expression dynamic action import",
|
"template-expression dynamic action import",
|
||||||
"src/app/admin-next/page.tsx",
|
"src/app/admin-next/page.tsx",
|
||||||
@@ -717,11 +628,28 @@ describe("preview route import boundary", () => {
|
|||||||
"src/app/mod/users/page",
|
"src/app/mod/users/page",
|
||||||
],
|
],
|
||||||
] as const)("detects %s", (_name, routeFile, source, expectedPath) => {
|
] as const)("detects %s", (_name, routeFile, source, expectedPath) => {
|
||||||
expect(findForbiddenRouteImports(source, routeFile)).toContain(
|
expect(findRouteImportBoundaryViolations(source, routeFile)).toContain(
|
||||||
expectedPath,
|
expectedPath,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"dynamic import",
|
||||||
|
"const path = '../../actions/users'; import(path)",
|
||||||
|
"<non-literal import>",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"CommonJS require",
|
||||||
|
"const path = '../../lib/db'; require(path)",
|
||||||
|
"<non-literal require>",
|
||||||
|
],
|
||||||
|
] as const)("fails closed for non-literal %s", (_name, source, violation) => {
|
||||||
|
expect(
|
||||||
|
findRouteImportBoundaryViolations(source, "src/app/admin-next/page.tsx"),
|
||||||
|
).toContain(violation);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not reject substring lookalikes, comments, or ordinary strings", () => {
|
it("does not reject substring lookalikes, comments, or ordinary strings", () => {
|
||||||
const source = [
|
const source = [
|
||||||
'import database from "@/lib/database";',
|
'import database from "@/lib/database";',
|
||||||
@@ -733,26 +661,7 @@ describe("preview route import boundary", () => {
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
findForbiddenRouteImports(source, "src/app/admin-next/page.tsx"),
|
findRouteImportBoundaryViolations(source, "src/app/admin-next/page.tsx"),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("decodes JavaScript string-literal escapes", () => {
|
|
||||||
expect(decodeJavaScriptStringEscapes(String.raw`\u0041`)).toBe("A");
|
|
||||||
expect(decodeJavaScriptStringEscapes(String.raw`\u{1f600}`)).toBe("😀");
|
|
||||||
expect(decodeJavaScriptStringEscapes(String.raw`\x2f`)).toBe("/");
|
|
||||||
expect(decodeJavaScriptStringEscapes(String.raw`\/`)).toBe("/");
|
|
||||||
expect(decodeJavaScriptStringEscapes(String.raw`\\`)).toBe("\\");
|
|
||||||
expect(decodeJavaScriptStringEscapes(String.raw`\"`)).toBe('"');
|
|
||||||
expect(decodeJavaScriptStringEscapes(String.raw`\'`)).toBe("'");
|
|
||||||
expect(decodeJavaScriptStringEscapes(String.raw`\b\f\n\r\t\v\0`)).toBe(
|
|
||||||
"\b\f\n\r\t\v\0",
|
|
||||||
);
|
|
||||||
expect(decodeJavaScriptStringEscapes(String.raw`\q`)).toBe("q");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("decodes CRLF and LF string-literal line continuations", () => {
|
|
||||||
expect(decodeJavaScriptStringEscapes("\\" + "\r\n")).toBe("");
|
|
||||||
expect(decodeJavaScriptStringEscapes("\\" + "\n")).toBe("");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user