diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt index 36a9f4d16..7d3d8fe72 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt @@ -98,6 +98,7 @@ import org.jacodb.ets.model.EtsNegExpr import org.jacodb.ets.model.EtsNeverType import org.jacodb.ets.model.EtsNewArrayExpr import org.jacodb.ets.model.EtsNewExpr +import org.jacodb.ets.model.EtsTemplateObjectExpr import org.jacodb.ets.model.EtsNewClassExpr import org.jacodb.ets.model.EtsSpreadExpansionExpr import org.jacodb.ets.model.EtsNopStmt @@ -374,6 +375,13 @@ class EtsMethodBuilder( is NewClassExprDto -> EtsNewClassExpr(signature.toEtsClassSignature()) + is TemplateObjectExprDto -> EtsTemplateObjectExpr( + siteId = siteId, + cooked = cooked, + raw = raw, + type = type.toEtsType(), + ) + is NewArrayExprDto -> EtsNewArrayExpr( elementType = elementType.toEtsType(), size = size.toEtsEntity(), diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt index 884945a94..6e096c2c1 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt @@ -177,6 +177,15 @@ data class NewClassExprDto( get() = ClassValueTypeDto(signature) } +@Serializable +@SerialName("TemplateObjectExpr") +data class TemplateObjectExprDto( + val siteId: String, + val cooked: List, + val raw: List, + override val type: TypeDto, +) : ExprDto + @Serializable @SerialName("NewArrayExpr") data class NewArrayExprDto( diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt index 19d205b4a..4b6d7dd7d 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt @@ -23,6 +23,10 @@ import org.jacodb.ets.toArrayType interface EtsExpr : EtsEntity { interface Visitor { fun visit(expr: EtsNewExpr): R + fun visit(expr: EtsTemplateObjectExpr): R { + if (this is Default) return defaultVisit(expr) + error("Cannot handle ${expr::class.java.simpleName}: $expr") + } fun visit(expr: EtsNewClassExpr): R { if (this is Default) return defaultVisit(expr) error("Cannot handle ${expr::class.java.simpleName}: $expr") @@ -93,6 +97,7 @@ interface EtsExpr : EtsEntity { interface Default : Visitor { override fun visit(expr: EtsNewExpr): R = defaultVisit(expr) + override fun visit(expr: EtsTemplateObjectExpr): R = defaultVisit(expr) override fun visit(expr: EtsNewClassExpr): R = defaultVisit(expr) override fun visit(expr: EtsSpreadExpansionExpr): R = defaultVisit(expr) override fun visit(expr: EtsToPropertyKeyExpr): R = defaultVisit(expr) @@ -214,6 +219,26 @@ data class EtsNewClassExpr( override fun accept(visitor: EtsExpr.Visitor): R = visitor.visit(this) } +/** + * ECMAScript GetTemplateObject. Evaluation returns the same cooked array for this [siteId] + * within one execution realm. Both arrays are frozen; the cooked array's non-enumerable, + * non-writable `raw` property points to the raw array. A null cooked entry means undefined. + */ +data class EtsTemplateObjectExpr( + val siteId: String, + val cooked: List, + val raw: List, + override val type: EtsType, +) : EtsExpr { + init { + require(cooked.size == raw.size) { "Template cooked and raw arrays must have the same length" } + } + + override fun toString(): String = "templateObject($siteId)" + + override fun accept(visitor: EtsExpr.Visitor): R = visitor.visit(this) +} + data class EtsNewArrayExpr( val elementType: EtsType, val size: EtsEntity, diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/GetOperands.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/GetOperands.kt index 21dcfd2cd..af3c64a41 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/GetOperands.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/GetOperands.kt @@ -54,6 +54,7 @@ import org.jacodb.ets.model.EtsMulExpr import org.jacodb.ets.model.EtsNegExpr import org.jacodb.ets.model.EtsNewArrayExpr import org.jacodb.ets.model.EtsNewExpr +import org.jacodb.ets.model.EtsTemplateObjectExpr import org.jacodb.ets.model.EtsNewClassExpr import org.jacodb.ets.model.EtsSpreadExpansionExpr import org.jacodb.ets.model.EtsNopStmt @@ -323,4 +324,7 @@ private object EntityGetOperands : EtsEntity.Visitor> { override fun visit(expr: EtsNewClassExpr): Sequence = emptySequence() + + override fun visit(expr: EtsTemplateObjectExpr): Sequence = + emptySequence() } diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt index fe809f6a0..2a371d4e5 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt @@ -54,6 +54,7 @@ import org.jacodb.ets.model.EtsMulExpr import org.jacodb.ets.model.EtsNegExpr import org.jacodb.ets.model.EtsNewArrayExpr import org.jacodb.ets.model.EtsNewExpr +import org.jacodb.ets.model.EtsTemplateObjectExpr import org.jacodb.ets.model.EtsNewClassExpr import org.jacodb.ets.model.EtsSpreadExpansionExpr import org.jacodb.ets.model.EtsNopStmt @@ -499,4 +500,8 @@ abstract class AbstractHandler : EtsEntity.Visitor, EtsStmt.Visitor handle(expr) } + final override fun visit(expr: EtsTemplateObjectExpr) { + handle(expr) + } + } diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsTaggedTemplateTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsTaggedTemplateTest.kt new file mode 100644 index 000000000..a651888e9 --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsTaggedTemplateTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jacodb.ets.test + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.jacodb.ets.dto.AssignStmtDto +import org.jacodb.ets.dto.CallExprDto +import org.jacodb.ets.dto.EtsFileDto +import org.jacodb.ets.dto.PtrCallExprDto +import org.jacodb.ets.dto.RawValueDto +import org.jacodb.ets.dto.TemplateObjectExprDto +import org.jacodb.ets.dto.dtoModule +import org.jacodb.ets.dto.toEtsFile +import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsPtrCallExpr +import org.jacodb.ets.model.EtsTemplateObjectExpr +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.generateEtsIR +import org.jacodb.ets.utils.getOperands +import org.junit.jupiter.api.Test +import kotlin.io.path.createTempDirectory +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertTrue + + +class EtsTsTaggedTemplateTest { + private fun frontendRoundTrip(source: String): EtsFileDto { + val input = createTempDirectory("ts-calls-test").resolve("test.ts") + input.writeText(source) + + val output = generateEtsIR( + input, + isProject = false, + timeout = null, + provider = EtsIrProvider.TS_FRONTEND, + ) + val dto = EtsFileDto.loadFromJson(output.readText()) + return EtsFileDto.loadFromJson(Json { serializersModule = dtoModule }.encodeToString(dto)) + } + + private fun assignments(dto: EtsFileDto, method: String): List = + dto.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.signature.name == method }.body!!.cfg.blocks + .flatMap { it.stmts }.filterIsInstance() + + @Test + fun `tagged template objects preserve raw cooked undefined sites and method receivers`() { + val dto = frontendRoundTrip( + """ + class Tagger { + tag(parts: TemplateStringsArray, value: number): string { return parts.raw[0] + value; } + } + export function value(tagger: Tagger): string { return tagger.tag`\unicode${'$'}{1}`; } + """.trimIndent(), + ) + val stmts = assignments(dto, "value") + val creation = stmts.single { it.right is TemplateObjectExprDto } + val template = creation.right as TemplateObjectExprDto + val call = stmts.single { it.right is PtrCallExprDto }.right as PtrCallExprDto + + assertEquals(listOf(null, ""), template.cooked) + assertEquals(listOf("\\unicode", ""), template.raw) + assertTrue(template.siteId.isNotEmpty()) + assertEquals(creation.left, call.args.first()) + assertTrue(call.receiver != null) + assertTrue(stmts.none { it.right is RawValueDto }) + + val model = dto.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == "value" }.cfg.stmts.filterIsInstance() + val modelTemplate = model.single { it.rhv is EtsTemplateObjectExpr }.rhv as EtsTemplateObjectExpr + val modelCall = model.single { it.rhv is EtsPtrCallExpr }.rhv as EtsPtrCallExpr + assertEquals(template.siteId, modelTemplate.siteId) + assertEquals(template.cooked, modelTemplate.cooked) + assertEquals(template.raw, modelTemplate.raw) + assertTrue(modelTemplate.getOperands().none()) + assertTrue(modelCall.getOperands().any { it == modelCall.receiver }) + } +} diff --git a/jacodb-ets/ts-frontend/src/dto/values.ts b/jacodb-ets/ts-frontend/src/dto/values.ts index 2d275f8c9..f416cb203 100644 --- a/jacodb-ets/ts-frontend/src/dto/values.ts +++ b/jacodb-ets/ts-frontend/src/dto/values.ts @@ -43,6 +43,7 @@ export type ImmediateDto = LocalDto | ConstantDto | ClassValueRefDto; export type ExprDto = | NewExprDto + | TemplateObjectExprDto | NewClassExprDto | SpreadExpansionExprDto | ToPropertyKeyExprDto @@ -120,6 +121,14 @@ export interface NewClassExprDto { signature: ClassSignatureDto; } +export interface TemplateObjectExprDto { + readonly _: "TemplateObjectExpr"; + siteId: string; + cooked: (string | null)[]; // null represents undefined for an invalid template escape. + raw: string[]; + type: TypeDto; +} + export interface NewArrayExprDto { readonly _: "NewArrayExpr"; elementType: TypeDto; diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 3419ad33d..23dd286aa 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -275,6 +275,9 @@ export class ExprLowerer { if (ts.isTemplateExpression(node)) { return this.lowerTemplate(node); } + if (ts.isTaggedTemplateExpression(node)) { + return this.lowerTaggedTemplate(node); + } if (ts.isTypeOfExpression(node)) { return { _: "TypeOfExpr", arg: this.lowerToImmediate(node.expression) }; } @@ -1316,6 +1319,90 @@ export class ExprLowerer { return value; } + private lowerTaggedTemplate(node: ts.TaggedTemplateExpression): ValueDto { + const tag = unwrapTransparentExpression(node.tag); + const substitutions = ts.isTemplateExpression(node.template) + ? node.template.templateSpans.map((span) => span.expression) + : []; + const unsupportedBeforeTag = this.unsupportedExpressionCount; + let ptr: LocalDto | undefined; + let receiver: LocalDto | undefined; + let declaringClass = UNKNOWN_CLASS_SIGNATURE; + let name = "%tag"; + let staticCall = false; + + if (ts.isPropertyAccessExpression(tag) || ts.isElementAccessExpression(tag)) { + // Property Get and key coercion can themselves mutate the source binding. + receiver = this.snapshotToLocal(tag.expression); + const key = ts.isPropertyAccessExpression(tag) + ? constant(tag.name.text, STRING_TYPE) + : this.lowerPropertyKey(tag.argumentExpression); + ptr = this.materialize({ + _: "PropertyRef", + instance: receiver, + key, + type: this.safeTypeOf(tag), + }, this.safeTypeOf(tag)); + name = ts.isPropertyAccessExpression(tag) ? tag.name.text : "%tag"; + } else { + const resolved = this.resolveCalleeDeclaration(node); + if (ts.isIdentifier(tag) && resolved !== undefined && ts.isFunctionDeclaration(resolved) + && isProjectFile(resolved) && isScopeFunctionDeclaration(resolved)) { + declaringClass = { + name: DEFAULT_ARK_CLASS_NAME, + declaringFile: this.m.ctx.fileSignatureFor(resolved.getSourceFile()), + }; + const namespace = this.m.converter.namespaceSignatureOf(resolved); + if (namespace !== undefined) declaringClass.declaringNamespace = namespace; + name = tag.text; + staticCall = true; + } else { + ptr = this.snapshotToLocal(node.tag, substitutions); + if (ts.isIdentifier(tag)) name = tag.text; + } + } + + if (this.unsupportedExpressionCount !== unsupportedBeforeTag) { + throw new LoweringError("template tag cannot be represented in EtsIR"); + } + + const literals = ts.isTemplateExpression(node.template) + ? [node.template.head, ...node.template.templateSpans.map((span) => span.literal)] + : [node.template]; + const raw = literals.map(templateRawText); + const cooked = literals.map((literal, index) => { + // The public compiler API reports invalid escapes in an untagged token. + // Such escapes are legal in tagged templates, whose cooked entry is undefined. + const probe = ts.transpileModule(`const value = \`${raw[index]}\`;`, { reportDiagnostics: true }); + return probe.diagnostics?.some((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error) + ? null + : literal.text; + }); + const templateType: TypeDto = { + _: "ArrayType", + elementType: cooked.includes(null) + ? { _: "UnionType", types: [STRING_TYPE, UNDEFINED_TYPE] } + : STRING_TYPE, + dimensions: 1, + }; + const file = this.m.ctx.fileSignatureFor(node.getSourceFile()); + const parts = this.materialize({ + _: "TemplateObjectExpr", + siteId: JSON.stringify([file.projectName, file.fileName, node.template.getStart()]), + cooked, + raw, + type: templateType, + }, templateType); + const args = [parts, ...this.lowerArguments(substitutions)]; + const method = this.methodSignatureForCall(node, name, declaringClass); + + if (staticCall) return { _: "StaticCallExpr", method, args }; + if (ptr === undefined) throw new LoweringError("template tag has no callable value"); + return receiver === undefined + ? { _: "PtrCallExpr", ptr, method, args } + : { _: "PtrCallExpr", ptr, receiver, method, args }; + } + /** `a${x}b` -> chain of string `+` binops. */ private lowerTemplate(node: ts.TemplateExpression): ValueDto { let acc: ImmediateDto = constant(node.head.text, STRING_TYPE); @@ -1672,7 +1759,7 @@ export class ExprLowerer { return { _: "ClassType", signature: { name, declaringFile: UNKNOWN_FILE_SIGNATURE } }; } - private resolveCalleeDeclaration(node: ts.CallExpression): ts.Declaration | undefined { + private resolveCalleeDeclaration(node: ts.CallExpression | ts.TaggedTemplateExpression): ts.Declaration | undefined { try { const signature = this.m.checker.getResolvedSignature(node); return signature?.getDeclaration(); @@ -1683,7 +1770,7 @@ export class ExprLowerer { /** Method signature for a call site, resolved through the checker when possible. */ private methodSignatureForCall( - node: ts.CallExpression, + node: ts.CallExpression | ts.TaggedTemplateExpression, name: string, declaringClass: ClassSignatureDto, ): MethodSignatureDto { @@ -1759,6 +1846,14 @@ export function constant(value: string, type: TypeDto): ConstantDto { return { _: "Constant", value, type }; } +function templateRawText(literal: ts.TemplateLiteralLikeNode): string { + const text = literal.getText(); + const suffixLength = literal.kind === ts.SyntaxKind.TemplateHead || literal.kind === ts.SyntaxKind.TemplateMiddle + ? 2 + : 1; + return text.slice(1, -suffixLength).replace(/\r\n?/g, "\n"); +} + /** Preserve the source spelling only when TypeScript normalizes a non-finite literal. */ function numericConstantText(node: ts.NumericLiteral): string { return Number.isFinite(Number(node.text)) ? node.text : node.getText(); diff --git a/jacodb-ets/ts-frontend/src/validate.ts b/jacodb-ets/ts-frontend/src/validate.ts index 20f998b61..88015ceff 100644 --- a/jacodb-ets/ts-frontend/src/validate.ts +++ b/jacodb-ets/ts-frontend/src/validate.ts @@ -31,6 +31,7 @@ import { ValueDto } from "./dto/values"; const EXPR_KINDS = new Set([ "NewExpr", + "TemplateObjectExpr", "NewClassExpr", "SpreadExpansionExpr", "ToPropertyKeyExpr", @@ -383,6 +384,11 @@ function validateValue( err(`${ctx}: spread expectedCount must be a non-negative integer`); } break; + case "TemplateObjectExpr": + if (value.cooked.length !== value.raw.length) { + err(`${ctx}: template cooked and raw arrays have different lengths`); + } + break; default: break; } @@ -401,6 +407,8 @@ export function valueOperands(value: ValueDto): ValueDto[] { return [value.iterable]; case "NewClassExpr": return []; + case "TemplateObjectExpr": + return []; case "NewArrayExpr": return [value.size]; case "DeleteExpr": diff --git a/jacodb-ets/ts-frontend/test/tagged-templates.spec.ts b/jacodb-ets/ts-frontend/test/tagged-templates.spec.ts new file mode 100644 index 000000000..c4907299f --- /dev/null +++ b/jacodb-ets/ts-frontend/test/tagged-templates.spec.ts @@ -0,0 +1,102 @@ +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { serializeEtsFile } from "../src/serialize"; +import { lower, methodByName, singleBlockStmts } from "./util"; + +function runTypeScript(source: string, expression: string): unknown { + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + + return new Function("exports", `${js}\nreturn ${expression};`)({}); +} + +describe("tagged templates", () => { + it("preserves the template object and tag call through JSON", () => { + const source = "function first(parts: TemplateStringsArray): string { return parts[0]; }\n" + + "export function value(): string { return first`hello`; }"; + const { file, diagnostics } = lower(source); + const roundTrip = JSON.parse(serializeEtsFile(file)); + const stmts = singleBlockStmts(methodByName(roundTrip, "value")); + const creation = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "TemplateObjectExpr"); + const call = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr"); + + expect(creation).toMatchObject({ right: { cooked: ["hello"], raw: ["hello"] } }); + expect(call).toMatchObject({ right: { method: { name: "first" }, args: [creation?._ === "AssignStmt" ? creation.left : undefined] } }); + expect(diagnostics.messages).toEqual([]); + expect(runTypeScript(source, "exports.value()")).toBe("hello"); + }); + + it("captures a property tag and its receiver before substitutions", () => { + const source = ` + class Tagger { + marker = "receiver"; + tag(parts: TemplateStringsArray, value: string): string { return this.marker + parts.raw[0] + value; } + } + let events: string[] = []; + function holder(tagger: Tagger): Tagger { events.push("receiver"); return tagger; } + function substitution(): string { events.push("substitution"); return "value"; } + export function value(tagger: Tagger): string { return holder(tagger).tag\`\\n\${substitution()}\`; } + export function concrete(): string[] { return [value(new Tagger()), events.join(",")]; } + `; + const { file, diagnostics } = lower(source); + const stmts = singleBlockStmts(methodByName(JSON.parse(serializeEtsFile(file)), "value")); + const propertyIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef"); + const templateIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "TemplateObjectExpr"); + const substitutionIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + && stmt.right.method.name === "substitution"); + const property = stmts[propertyIndex]; + const call = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr"); + + expect(propertyIndex).toBeGreaterThanOrEqual(0); + expect(templateIndex).toBeGreaterThan(propertyIndex); + expect(substitutionIndex).toBeGreaterThan(templateIndex); + expect(stmts[templateIndex]).toMatchObject({ right: { cooked: ["\n", ""], raw: ["\\n", ""] } }); + expect(call).toMatchObject({ right: { + ptr: property?._ === "AssignStmt" ? property.left : undefined, + receiver: property?._ === "AssignStmt" && property.right._ === "PropertyRef" ? property.right.instance : undefined, + args: expect.any(Array), + } }); + expect(diagnostics.messages).toEqual([]); + expect(runTypeScript(source, "exports.concrete()")).toEqual(["receiver\\nvalue", "receiver,substitution"]); + }); + + it("keeps one stable identity per source site and preserves frozen raw semantics", () => { + const source = ` + let previous: TemplateStringsArray | undefined; + function remember(parts: TemplateStringsArray): boolean { + const same = previous === parts; + previous = parts; + return same && Object.isFrozen(parts) && Object.isFrozen(parts.raw); + } + export function sameSite(): boolean { return remember\`text\`; } + export function otherSite(): boolean { return remember\`text\`; } + `; + const first = lower(source); + const second = lower(source); + const site = (file: typeof first.file, method: string): string | undefined => { + const stmt = singleBlockStmts(methodByName(file, method)) + .find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "TemplateObjectExpr"); + return stmt?._ === "AssignStmt" && stmt.right._ === "TemplateObjectExpr" ? stmt.right.siteId : undefined; + }; + + expect(site(first.file, "sameSite")).toBeDefined(); + expect(site(first.file, "sameSite")).toBe(site(second.file, "sameSite")); + expect(site(first.file, "sameSite")).not.toBe(site(first.file, "otherSite")); + expect(first.diagnostics.messages).toEqual([]); + expect(runTypeScript(source, "[exports.sameSite(), exports.sameSite(), exports.otherSite()]")).toEqual([false, true, false]); + }); + + it("preserves invalid escapes as undefined cooked entries and literal raw text", () => { + const source = "function raw(parts: TemplateStringsArray): string { return String(parts[0]) + parts.raw[0]; }\n" + + "export function value(): string { return raw`\\unicode`; }"; + const { file, diagnostics } = lower(source); + const stmts = singleBlockStmts(methodByName(JSON.parse(serializeEtsFile(file)), "value")); + + expect(stmts).toContainEqual(expect.objectContaining({ right: expect.objectContaining({ + _: "TemplateObjectExpr", cooked: [null], raw: ["\\unicode"], + }) })); + expect(diagnostics.messages).toEqual([]); + expect(runTypeScript(source, "exports.value()")).toBe("undefined\\unicode"); + }); +});