feat: add multi-engine regex flavour support

This commit is contained in:
2026-07-27 11:43:51 +02:00
parent 7079cde15f
commit 7f3a91ad37
340 changed files with 643286 additions and 483 deletions

View File

@@ -0,0 +1,304 @@
/// <reference types="node" />
import { expect, test, type Page } from "@playwright/test";
import { readdir } from "node:fs/promises";
import path from "node:path";
interface FlavourCase {
readonly id:
"php" | "perl" | "ruby" | "cpp" | "go" | "rust" | "dotnet" | "scala";
readonly title: string;
readonly pattern: string;
readonly subject: string;
readonly expectedMatch: string;
readonly utf16Range: string;
readonly nativeRange: string;
readonly replacement: string;
readonly expectedOutput: string;
readonly capabilities: readonly string[];
readonly timeoutMs: number;
readonly expectedMatches?: number;
}
const FLAVOURS: readonly FlavourCase[] = [
{
id: "php",
title: "PHP preg / PCRE2",
pattern: "(?'word'[a-z]++)",
subject: "😀 abc",
expectedMatch: "abc",
utf16Range: "3…6",
nativeRange: "5…8 utf8-byte",
replacement: "<$1>",
expectedOutput: "😀 <abc>",
capabilities: [
"PHP preg (PCRE2)",
"PHP 8.5.8 preg / PCRE2 10.44",
"@php-wasm/web-8-5 3.1.46",
],
timeoutMs: 60_000,
},
{
id: "perl",
title: "Perl 5.28.1 via legacy WebPerl",
pattern: "(?<word>😀+)",
subject: "x😀😀 y😀",
expectedMatch: "😀😀",
utf16Range: "1…5",
nativeRange: "1…3 code-point",
replacement: "${word}-$1-$$",
expectedOutput: "x😀😀-😀😀-$ y😀-😀-$",
capabilities: [
"Perl regular expressions (legacy)",
"5.28.1",
"WebPerl 0.09-beta",
"legacy beta",
"classic worker",
],
timeoutMs: 60_000,
expectedMatches: 2,
},
{
id: "ruby",
title: "CRuby Regexp",
pattern: "(?<word>[A-Z&&[^AEIOU]]+)",
subject: "😀 BCD",
expectedMatch: "BCD",
utf16Range: "3…6",
nativeRange: "2…5 code-point",
replacement: "<\\k<word>>|\\&|\\`|\\'",
expectedOutput: "😀 <BCD>|BCD|😀 |",
capabilities: [
"CRuby Regexp via ruby.wasm",
"CRuby 4.0.0 Regexp",
"ruby.wasm 2.9.3-2.9.4",
"wasm32-wasi",
],
timeoutMs: 60_000,
},
{
id: "cpp",
title: "C++ libc++ std::regex",
pattern: "(\\w+)",
subject: "😀 one",
expectedMatch: "one",
utf16Range: "3…6",
nativeRange: "2…5 code-point",
replacement: "<$1>",
expectedOutput: "😀 <one>",
capabilities: [
"C++ std::wregex (Emscripten libc++)",
"Emscripten 6.0.4 libc++ std::wregex",
"32-bit wchar_t code-point bridge",
],
timeoutMs: 30_000,
},
{
id: "go",
title: "Go standard-library regexp",
pattern: "(?P<word>\\p{Greek}+)",
subject: "😀 λλ",
expectedMatch: "λλ",
utf16Range: "3…5",
nativeRange: "5…9 utf8-byte",
replacement: "<${word}>",
expectedOutput: "😀 <λλ>",
capabilities: ["Go standard-library regexp", "1.26.5", "go1.26.5 js/wasm"],
timeoutMs: 30_000,
},
{
id: "rust",
title: "Rust regex crate",
pattern: "(?P<word>\\p{Greek}+)",
subject: "😀 λλ",
expectedMatch: "λλ",
utf16Range: "3…5",
nativeRange: "5…9 utf8-byte",
replacement: "<${word}>",
expectedOutput: "😀 <λλ>",
capabilities: [
"Rust regex crate",
"1.13.1",
"rustc 1.97.1 wasm32-unknown-unknown",
],
timeoutMs: 30_000,
},
{
id: "dotnet",
title: ".NET System.Text.RegularExpressions",
pattern: "(?<word>\\p{L}+)",
subject: "😀 café",
expectedMatch: "café",
utf16Range: "3…7",
nativeRange: "3…7 utf16",
replacement: "<${word}>",
expectedOutput: "😀 <café>",
capabilities: [
"System.Text.RegularExpressions",
"10.0.10",
".NET browser WebAssembly",
"CultureInvariant",
],
timeoutMs: 60_000,
},
{
id: "scala",
title: "Scala/JVM compatibility profile",
pattern: "(?<word>[a-z]++)",
subject: "😀 abc",
expectedMatch: "abc",
utf16Range: "3…6",
nativeRange: "3…6 utf16",
replacement: "<$1>",
expectedOutput: "😀 <abc>",
capabilities: [
"Scala/JVM regex via TeaVM java.util.regex",
"TeaVM 0.15.0 compatibility profile",
"Scala/JVM pattern and replacement semantics",
"no Scala runtime",
],
timeoutMs: 30_000,
},
];
async function setEditor(page: Page, testId: string, value: string) {
await page.getByTestId(testId).locator(".cm-content").fill(value);
}
async function waitForReady(page: Page, timeout: number) {
await expect(page.locator(".run-status")).toHaveClass(/status-ready/u, {
timeout,
});
}
function observeRuntimeErrors(page: Page): string[] {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(`page: ${error.message}`));
page.on("console", (message) => {
const text = message.text();
if (
message.type() === "error" ||
/content security policy|\bCSP\b|refused to (?:execute|load)/iu.test(text)
) {
errors.push(`console ${message.type()}: ${text}`);
}
});
return errors;
}
for (const flavour of FLAVOURS) {
test(`${flavour.title} executes native syntax and replacement through its production worker`, async ({
page,
}) => {
test.setTimeout(flavour.timeoutMs + 60_000);
const runtimeErrors = observeRuntimeErrors(page);
const response = await page.goto("/deep/nested/regex/");
const csp = response?.headers()["content-security-policy"] ?? "";
expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'");
expect(csp).not.toContain("'unsafe-eval'");
await waitForReady(page, 15_000);
await page.getByLabel("Live").uncheck();
await page
.getByRole("combobox", { name: "Flavour" })
.selectOption(flavour.id);
await setEditor(page, "editor-regular-expression-pattern", flavour.pattern);
await setEditor(page, "editor-test-text", flavour.subject);
const run = page.getByRole("button", { name: "Run", exact: true });
await expect(run).toBeEnabled({ timeout: 15_000 });
await run.click();
await waitForReady(page, flavour.timeoutMs);
await expect(page.locator(".run-status")).toContainText(
`native offsets ${flavour.nativeRange.split(" ").at(-1)}`,
);
const matches = page.locator('.extraction-row[aria-level="1"]');
if (flavour.expectedMatches !== undefined) {
await expect(matches).toHaveCount(flavour.expectedMatches);
}
const match = matches.first();
await expect(match).toContainText(flavour.expectedMatch);
await expect(match).toContainText(`UTF-16 range: ${flavour.utf16Range}`);
await expect(match).toContainText(`native range: ${flavour.nativeRange}`);
await page
.getByRole("button", { name: "Capabilities", exact: true })
.click();
const capabilities = page.getByRole("dialog", { name: "Capabilities" });
for (const identity of flavour.capabilities) {
await expect(capabilities).toContainText(identity);
}
await capabilities
.getByRole("button", { name: "Close capabilities" })
.click();
await page.getByRole("button", { name: "Replace", exact: true }).click();
await setEditor(page, "editor-replacement-template", flavour.replacement);
await expect(run).toBeEnabled({ timeout: 15_000 });
await run.click();
await waitForReady(page, flavour.timeoutMs);
await expect(
page.getByTestId("editor-replacement-output").locator(".cm-content"),
).toHaveText(flavour.expectedOutput);
expect(runtimeErrors).toEqual([]);
});
}
test("serves every additional engine module and WebAssembly dependency with production MIME types", async ({
request,
}) => {
const fixedAssets = [
["engines/php/php-loader.mjs", "text/javascript"],
["engines/php/php.wasm", "application/wasm"],
["engines/perl/perl-runtime.worker.js", "text/javascript"],
["engines/perl/emperl.js", "text/javascript"],
["engines/perl/emperl.wasm", "application/wasm"],
["engines/ruby/ruby.wasm", "application/wasm"],
["engines/cpp/cpp-regex.mjs", "text/javascript"],
["engines/cpp/cpp-regex.wasm", "application/wasm"],
["engines/go/wasm_exec.mjs", "text/javascript"],
["engines/go/go-regex.wasm", "application/wasm"],
["engines/rust/rust-regex.mjs", "text/javascript"],
["engines/rust/rust-regex_bg.wasm", "application/wasm"],
["engines/dotnet/_framework/dotnet.js", "text/javascript"],
["engines/java/java-regex.mjs", "text/javascript"],
] as const;
for (const [asset, expectedMime] of fixedAssets) {
const response = await request.get(`./${asset}`);
expect(response.ok(), asset).toBe(true);
expect(response.headers()["content-type"], asset).toContain(expectedMime);
if (expectedMime === "application/wasm") {
expect((await response.body()).subarray(0, 4), asset).toEqual(
Buffer.from([0x00, 0x61, 0x73, 0x6d]),
);
}
}
const dotnetFramework = path.resolve(
"public",
"engines",
"dotnet",
"_framework",
);
const hashedDotnetDependencies = (await readdir(dotnetFramework))
.filter((name) => /\.[a-z0-9]{10}\.(?:js|wasm)$/u.test(name))
.sort();
expect(hashedDotnetDependencies.length).toBeGreaterThan(2);
expect(hashedDotnetDependencies.some((name) => name.endsWith(".js"))).toBe(
true,
);
expect(hashedDotnetDependencies.some((name) => name.endsWith(".wasm"))).toBe(
true,
);
for (const asset of hashedDotnetDependencies) {
const response = await request.get(`./engines/dotnet/_framework/${asset}`);
expect(response.ok(), asset).toBe(true);
expect(response.headers()["content-type"], asset).toContain(
asset.endsWith(".wasm") ? "application/wasm" : "text/javascript",
);
}
});