305 lines
9.4 KiB
TypeScript
305 lines
9.4 KiB
TypeScript
/// <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",
|
|
);
|
|
}
|
|
});
|