feat: add multi-engine regex flavour support
This commit is contained in:
304
tests/browser/additional-flavours.spec.ts
Normal file
304
tests/browser/additional-flavours.spec.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
});
|
||||
172
tests/conformance/cpp.conformance.test.ts
Normal file
172
tests/conformance/cpp.conformance.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
CppEngineAdapter,
|
||||
CPP_ENGINE_VERSION,
|
||||
CPP_FLAVOUR_VERSION,
|
||||
} from "../../src/regex/execution/adapters/cpp/CppEngineAdapter";
|
||||
import type { CppRegexModuleFactory } from "../../src/regex/execution/adapters/cpp/cpp-module";
|
||||
import type {
|
||||
RegexExecutionRequest,
|
||||
RegexReplacementRequest,
|
||||
} from "../../src/regex/model/match";
|
||||
|
||||
const pack = path.resolve("public/engines/cpp");
|
||||
let adapter: CppEngineAdapter;
|
||||
|
||||
function request(
|
||||
pattern: string,
|
||||
subject: string,
|
||||
overrides: Partial<RegexExecutionRequest> = {},
|
||||
): RegexExecutionRequest {
|
||||
return {
|
||||
flavour: "cpp",
|
||||
flavourVersion: CPP_FLAVOUR_VERSION,
|
||||
pattern,
|
||||
flags: ["g"],
|
||||
options: { grammar: "ECMAScript" },
|
||||
subject,
|
||||
captureMetadata: [],
|
||||
scanAll: false,
|
||||
maximumMatches: 100,
|
||||
maximumCaptureRows: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function replacement(
|
||||
pattern: string,
|
||||
subject: string,
|
||||
replacementText: string,
|
||||
overrides: Partial<RegexReplacementRequest> = {},
|
||||
): RegexReplacementRequest {
|
||||
return {
|
||||
...request(pattern, subject),
|
||||
replacement: replacementText,
|
||||
maximumOutputBytes: 1_024,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const imported = (await import(
|
||||
`${pathToFileURL(path.join(pack, "cpp-regex.mjs")).href}?conformance`
|
||||
)) as { default: CppRegexModuleFactory };
|
||||
const module = await imported.default({
|
||||
locateFile: (name) => path.join(pack, name),
|
||||
print: () => undefined,
|
||||
printErr: () => undefined,
|
||||
});
|
||||
adapter = new CppEngineAdapter(module, "Node.js conformance");
|
||||
await adapter.load();
|
||||
});
|
||||
|
||||
describe("C++ Emscripten libc++ std::wregex conformance", () => {
|
||||
it("loads the exact libc++ and compiler identity", async () => {
|
||||
await expect(adapter.load()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
flavour: "cpp",
|
||||
engineName: "C++ std::wregex (Emscripten libc++)",
|
||||
engineVersion: CPP_ENGINE_VERSION,
|
||||
runtimeVersion: expect.stringContaining(
|
||||
"32-bit wchar_t code-point bridge",
|
||||
),
|
||||
offsetUnit: "code-point",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns code-point-native ranges without losing editor UTF-16 offsets", async () => {
|
||||
const result = await adapter.execute(request("(\\w+)", "😀 one two"));
|
||||
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(
|
||||
result.matches.map(({ nativeRange, range, value }) => ({
|
||||
nativeRange,
|
||||
range,
|
||||
value,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
nativeRange: { start: 2, end: 5, unit: "code-point" },
|
||||
range: { startUtf16: 3, endUtf16: 6 },
|
||||
value: "one",
|
||||
},
|
||||
{
|
||||
nativeRange: { start: 6, end: 9, unit: "code-point" },
|
||||
range: { startUtf16: 7, endUtf16: 10 },
|
||||
value: "two",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses native std::regex replacement and grammar selection", async () => {
|
||||
const replaced = await adapter.replace(
|
||||
replacement("(\\w+)", "one two", "<$1>"),
|
||||
);
|
||||
expect(replaced.execution.accepted).toBe(true);
|
||||
expect(replaced.output).toBe("<one> <two>");
|
||||
|
||||
const basic = await adapter.execute(
|
||||
request("\\(ab\\)", "xxabyy", {
|
||||
options: { grammar: "basic" },
|
||||
}),
|
||||
);
|
||||
expect(basic.matches[0]?.value).toBe("ab");
|
||||
});
|
||||
|
||||
it("bounds capture amplification and preserves the untouched partial suffix", async () => {
|
||||
const bounded = await adapter.replace(
|
||||
replacement("(a+)", "a".repeat(256 * 1024), "$1".repeat(4_096), {
|
||||
maximumOutputBytes: 5,
|
||||
}),
|
||||
);
|
||||
expect(bounded.execution.accepted).toBe(true);
|
||||
expect(bounded.output).toBe("aaaaa");
|
||||
expect(bounded.outputBytes).toBe(5);
|
||||
expect(bounded.outputTruncated).toBe(true);
|
||||
|
||||
const partial = await adapter.replace(
|
||||
replacement("a", "aaa", "x", { maximumMatches: 1 }),
|
||||
);
|
||||
expect(partial.execution.matches).toHaveLength(1);
|
||||
expect(partial.execution.truncated).toBe(true);
|
||||
expect(partial.output).toBe("xaa");
|
||||
expect(partial.outputTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it("reports syntax that libc++ std::regex does not accept", async () => {
|
||||
const result = await adapter.execute(request("(?<name>a)", "a"));
|
||||
expect(result.accepted).toBe(false);
|
||||
expect(result.diagnostics[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
source: "execution-engine",
|
||||
severity: "error",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ships a fixed bridge module without dynamic source evaluation", async () => {
|
||||
const module = await readFile(path.join(pack, "cpp-regex.mjs"), "utf8");
|
||||
expect(module).not.toContain("new Function");
|
||||
expect(module).not.toMatch(/\beval\s*\(/u);
|
||||
|
||||
const metadata = JSON.parse(
|
||||
await readFile(path.join(pack, "engine-metadata.json"), "utf8"),
|
||||
) as {
|
||||
bridge: { dynamicExecutionDisabled: boolean };
|
||||
toolchain: { version: string; commitSha1: string };
|
||||
};
|
||||
expect(metadata.bridge.dynamicExecutionDisabled).toBe(true);
|
||||
expect(metadata.toolchain).toEqual(
|
||||
expect.objectContaining({
|
||||
version: "6.0.4",
|
||||
commitSha1: "fe5be6afdff43ad58860d821fcc8572a23f92d19",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
260
tests/conformance/go.conformance.test.ts
Normal file
260
tests/conformance/go.conformance.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
GO_ENGINE_VERSION,
|
||||
GO_FLAVOUR_VERSION,
|
||||
GoEngineAdapter,
|
||||
} from "../../src/regex/execution/adapters/go/GoEngineAdapter";
|
||||
import { GoWasmBridge } from "../../src/regex/execution/adapters/go/GoWasmBridge";
|
||||
import type {
|
||||
RegexExecutionRequest,
|
||||
RegexReplacementRequest,
|
||||
} from "../../src/regex/model/match";
|
||||
|
||||
const pack = path.resolve("public/engines/go");
|
||||
let adapter: GoEngineAdapter;
|
||||
|
||||
function request(
|
||||
pattern: string,
|
||||
subject: string,
|
||||
overrides: Partial<RegexExecutionRequest> = {},
|
||||
): RegexExecutionRequest {
|
||||
return {
|
||||
flavour: "go",
|
||||
flavourVersion: GO_FLAVOUR_VERSION,
|
||||
pattern,
|
||||
flags: ["g"],
|
||||
subject,
|
||||
captureMetadata: [],
|
||||
scanAll: false,
|
||||
maximumMatches: 100,
|
||||
maximumCaptureRows: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function replacement(
|
||||
pattern: string,
|
||||
subject: string,
|
||||
replacementText: string,
|
||||
overrides: Partial<RegexReplacementRequest> = {},
|
||||
): RegexReplacementRequest {
|
||||
return {
|
||||
...request(pattern, subject),
|
||||
replacement: replacementText,
|
||||
maximumOutputBytes: 1_024,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const wasm = await WebAssembly.compile(
|
||||
await readFile(path.join(pack, "go-regex.wasm")),
|
||||
);
|
||||
const bridge = new GoWasmBridge(
|
||||
pathToFileURL(path.join(pack, "wasm_exec.mjs")),
|
||||
wasm,
|
||||
);
|
||||
adapter = new GoEngineAdapter(bridge, "Node.js conformance");
|
||||
await adapter.load();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(() => {
|
||||
adapter?.terminate();
|
||||
});
|
||||
|
||||
describe("Go 1.26.5 standard-library regexp conformance", () => {
|
||||
it("loads the exact runtime and bridge self-test identity", async () => {
|
||||
await expect(adapter.load()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
flavour: "go",
|
||||
engineName: "Go standard-library regexp",
|
||||
engineVersion: GO_ENGINE_VERSION,
|
||||
runtimeVersion: expect.stringContaining("go1.26.5 js/wasm"),
|
||||
offsetUnit: "utf8-byte",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns native UTF-8 byte offsets and native named captures", async () => {
|
||||
const result = await adapter.execute(
|
||||
request("(?P<emoji>😀+)([A-Z]+)?", "x😀😀AB 😀"),
|
||||
);
|
||||
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(
|
||||
result.matches.map(({ nativeRange, range }) => ({ nativeRange, range })),
|
||||
).toEqual([
|
||||
{
|
||||
nativeRange: { start: 1, end: 11, unit: "utf8-byte" },
|
||||
range: { startUtf16: 1, endUtf16: 7 },
|
||||
},
|
||||
{
|
||||
nativeRange: { start: 12, end: 16, unit: "utf8-byte" },
|
||||
range: { startUtf16: 8, endUtf16: 10 },
|
||||
},
|
||||
]);
|
||||
expect(result.matches[0]?.captures).toEqual([
|
||||
expect.objectContaining({
|
||||
groupNumber: 1,
|
||||
groupName: "emoji",
|
||||
value: "😀😀",
|
||||
nativeRange: { start: 1, end: 9, unit: "utf8-byte" },
|
||||
}),
|
||||
expect.objectContaining({ groupNumber: 2, value: "AB" }),
|
||||
]);
|
||||
expect(result.matches[1]?.captures[1]).toEqual({
|
||||
groupNumber: 2,
|
||||
status: "did-not-participate",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Go's native empty-match suppression and RE2 boundary", async () => {
|
||||
const empty = await adapter.execute(request("a*", "baaab"));
|
||||
expect(empty.matches.map(({ nativeRange }) => nativeRange)).toEqual([
|
||||
{ start: 0, end: 0, unit: "utf8-byte" },
|
||||
{ start: 1, end: 4, unit: "utf8-byte" },
|
||||
{ start: 5, end: 5, unit: "utf8-byte" },
|
||||
]);
|
||||
|
||||
const unsupported = await adapter.execute(request("(?<=a)b", "ab"));
|
||||
expect(unsupported.accepted).toBe(false);
|
||||
expect(unsupported.diagnostics[0]).toEqual(
|
||||
expect.objectContaining({ code: "compile-error" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps i, m, s and U to native Go inline flags", async () => {
|
||||
const cases = [
|
||||
["a", "A", ["i"]],
|
||||
["^b$", "a\nb\nc", ["m"]],
|
||||
["a.b", "a\nb", ["s"]],
|
||||
["a+?", "aaa", ["U"]],
|
||||
] as const;
|
||||
for (const [pattern, subject, flags] of cases) {
|
||||
const result = await adapter.execute(
|
||||
request(pattern, subject, { flags: [...flags] }),
|
||||
);
|
||||
expect(result.matches, `${flags.join("")}: ${pattern}`).toHaveLength(1);
|
||||
}
|
||||
const ungreedy = await adapter.execute(
|
||||
request("a+", "aaa", { flags: ["U"] }),
|
||||
);
|
||||
expect(ungreedy.matches[0]?.value).toBe("a");
|
||||
});
|
||||
|
||||
it("uses native ExpandString replacement name and longest-name rules", async () => {
|
||||
const result = await adapter.replace(
|
||||
replacement("(?P<word>[a-z]+)-(\\d+)", "a-1 b-22", "${word}[$2]/$1/$$"),
|
||||
);
|
||||
expect(result.execution.accepted).toBe(true);
|
||||
expect(result.output).toBe("a[1]/a/$ b[22]/b/$");
|
||||
|
||||
const longest = await adapter.replace(replacement("(a)", "a", "$1x|${1}x"));
|
||||
expect(longest.output).toBe("|ax");
|
||||
});
|
||||
|
||||
it("bounds capture amplification and preserves the untouched partial suffix", async () => {
|
||||
const bounded = await adapter.replace(
|
||||
replacement("(a+)", "a".repeat(256 * 1024), "$1".repeat(4_096), {
|
||||
maximumOutputBytes: 5,
|
||||
}),
|
||||
);
|
||||
expect(bounded.execution.accepted).toBe(true);
|
||||
expect(bounded.output).toBe("aaaaa");
|
||||
expect(bounded.outputBytes).toBe(5);
|
||||
expect(bounded.outputTruncated).toBe(true);
|
||||
|
||||
const partial = await adapter.replace(
|
||||
replacement("a", "aaa", "x", { maximumMatches: 1 }),
|
||||
);
|
||||
expect(partial.execution.matches).toHaveLength(1);
|
||||
expect(partial.execution.truncated).toBe(true);
|
||||
expect(partial.output).toBe("xaa");
|
||||
expect(partial.outputTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces match/capture/output bounds without splitting UTF-8", async () => {
|
||||
const limited = await adapter.execute(
|
||||
request("a", "aaa", { maximumMatches: 1 }),
|
||||
);
|
||||
expect(limited.matches).toHaveLength(1);
|
||||
expect(limited.truncated).toBe(true);
|
||||
|
||||
const rows = await adapter.execute(
|
||||
request("(a)(b)", "ab", { maximumCaptureRows: 1 }),
|
||||
);
|
||||
expect(rows.matches).toHaveLength(0);
|
||||
expect(rows.truncated).toBe(true);
|
||||
|
||||
const output = await adapter.replace(
|
||||
replacement(".", "abc", "😀", { maximumOutputBytes: 5 }),
|
||||
);
|
||||
expect(output.output).toBe("😀");
|
||||
expect(output.outputBytes).toBe(4);
|
||||
expect(output.outputTruncated).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects lone UTF-16 surrogates before entering Go", async () => {
|
||||
await expect(adapter.execute(request(".", "\ud800"))).rejects.toThrow(
|
||||
/unpaired UTF-16 surrogate/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts control-heavy subjects within the decoded 16 MiB contract", async () => {
|
||||
const subject = "\u0000".repeat(3 * 1024 * 1024);
|
||||
const result = await adapter.execute(
|
||||
request("^\\x00+$", subject, {
|
||||
flags: [],
|
||||
maximumMatches: 1,
|
||||
maximumCaptureRows: 1,
|
||||
}),
|
||||
);
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(result.matches[0]?.nativeRange).toEqual({
|
||||
start: 0,
|
||||
end: subject.length,
|
||||
unit: "utf8-byte",
|
||||
});
|
||||
});
|
||||
|
||||
it("ships verified metadata, licence and checksums", async () => {
|
||||
const metadata = JSON.parse(
|
||||
await readFile(path.join(pack, "engine-metadata.json"), "utf8"),
|
||||
) as {
|
||||
engineVersion: string;
|
||||
source: { tag: string; commitSha1: string; license: string };
|
||||
toolchain: { archiveSha256: string };
|
||||
};
|
||||
expect(metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
engineVersion: "1.26.5",
|
||||
source: expect.objectContaining({
|
||||
tag: "go1.26.5",
|
||||
commitSha1: "c19862e5f8415b4f24b189d065ed739517c548ba",
|
||||
license: "BSD-3-Clause",
|
||||
}),
|
||||
toolchain: expect.objectContaining({
|
||||
archiveSha256:
|
||||
"5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const checksums = await readFile(path.join(pack, "SHA256SUMS"), "utf8");
|
||||
for (const line of checksums.trim().split("\n")) {
|
||||
const [expected, name] = line.split(/ {2}/u);
|
||||
const value = await readFile(path.join(pack, name ?? ""));
|
||||
expect(createHash("sha256").update(value).digest("hex"), name).toBe(
|
||||
expected,
|
||||
);
|
||||
}
|
||||
expect(await readFile(path.join(pack, "LICENSE-Go.txt"), "utf8")).toContain(
|
||||
"Redistribution and use",
|
||||
);
|
||||
});
|
||||
});
|
||||
356
tests/conformance/php.conformance.test.ts
Normal file
356
tests/conformance/php.conformance.test.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
PHP_ENGINE_VERSION,
|
||||
PHP_FLAVOUR_VERSION,
|
||||
PhpEngineAdapter,
|
||||
} from "../../src/regex/execution/adapters/php/PhpEngineAdapter";
|
||||
import {
|
||||
PHP,
|
||||
loadPHPRuntime,
|
||||
type PhpLoaderModule,
|
||||
} from "../../src/regex/execution/adapters/php/php-runtime";
|
||||
import { PhpWasmBridge } from "../../src/regex/execution/adapters/php/PhpWasmBridge";
|
||||
import type {
|
||||
RegexExecutionRequest,
|
||||
RegexReplacementRequest,
|
||||
} from "../../src/regex/model/match";
|
||||
|
||||
const pack = path.resolve("public/engines/php");
|
||||
const mutableGlobal = globalThis as unknown as Record<string, unknown>;
|
||||
let previousRequire: unknown;
|
||||
let hadRequire = false;
|
||||
let php: InstanceType<typeof PHP>;
|
||||
let bridge: PhpWasmBridge;
|
||||
let adapter: PhpEngineAdapter;
|
||||
|
||||
function request(
|
||||
pattern: string,
|
||||
subject: string,
|
||||
overrides: Partial<RegexExecutionRequest> = {},
|
||||
): RegexExecutionRequest {
|
||||
return {
|
||||
flavour: "php",
|
||||
flavourVersion: PHP_FLAVOUR_VERSION,
|
||||
pattern,
|
||||
flags: ["g", "u"],
|
||||
subject,
|
||||
captureMetadata: [],
|
||||
scanAll: false,
|
||||
maximumMatches: 100,
|
||||
maximumCaptureRows: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function replacement(
|
||||
pattern: string,
|
||||
subject: string,
|
||||
replacementText: string,
|
||||
overrides: Partial<RegexReplacementRequest> = {},
|
||||
): RegexReplacementRequest {
|
||||
return {
|
||||
...request(pattern, subject),
|
||||
replacement: replacementText,
|
||||
maximumOutputBytes: 1_024,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
hadRequire = Object.hasOwn(mutableGlobal, "require");
|
||||
previousRequire = mutableGlobal.require;
|
||||
mutableGlobal.require = createRequire(import.meta.url);
|
||||
const loader = (await import(
|
||||
`${pathToFileURL(path.join(pack, "php-loader.mjs")).href}?conformance`
|
||||
)) as PhpLoaderModule;
|
||||
const wasmBinary = await readFile(path.join(pack, "php.wasm"));
|
||||
const runtimeId = await loadPHPRuntime(loader, {
|
||||
wasmBinary,
|
||||
phpWasmAsyncMode: "asyncify",
|
||||
debug: false,
|
||||
print: () => undefined,
|
||||
printErr: () => undefined,
|
||||
});
|
||||
php = new PHP(runtimeId);
|
||||
bridge = new PhpWasmBridge(php);
|
||||
adapter = new PhpEngineAdapter(bridge, "Node.js conformance");
|
||||
await adapter.load();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(() => {
|
||||
adapter?.terminate();
|
||||
try {
|
||||
php?.exit(0);
|
||||
} catch {
|
||||
// Emscripten may signal its normal exit as an exception.
|
||||
}
|
||||
if (hadRequire) mutableGlobal.require = previousRequire;
|
||||
else delete mutableGlobal.require;
|
||||
});
|
||||
|
||||
describe("PHP 8.5.8 preg / PCRE2 10.44 conformance", () => {
|
||||
it("loads the exact Asyncify runtime and native self-test identity", async () => {
|
||||
await expect(adapter.load()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
flavour: "php",
|
||||
engineName: "PHP preg (PCRE2)",
|
||||
engineVersion: PHP_ENGINE_VERSION,
|
||||
runtimeVersion: expect.stringContaining(
|
||||
"@php-wasm/web-8-5 3.1.46 · Asyncify",
|
||||
),
|
||||
offsetUnit: "utf8-byte",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns PREG_OFFSET_CAPTURE byte ranges and named/optional captures", async () => {
|
||||
const result = await adapter.execute(
|
||||
request("(?<emoji>😀+)([A-Z]+)?", "x😀😀AB 😀"),
|
||||
);
|
||||
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(
|
||||
result.matches.map(({ nativeRange, range }) => ({ nativeRange, range })),
|
||||
).toEqual([
|
||||
{
|
||||
nativeRange: { start: 1, end: 11, unit: "utf8-byte" },
|
||||
range: { startUtf16: 1, endUtf16: 7 },
|
||||
},
|
||||
{
|
||||
nativeRange: { start: 12, end: 16, unit: "utf8-byte" },
|
||||
range: { startUtf16: 8, endUtf16: 10 },
|
||||
},
|
||||
]);
|
||||
expect(result.matches[0]?.captures).toEqual([
|
||||
expect.objectContaining({
|
||||
groupNumber: 1,
|
||||
groupName: "emoji",
|
||||
value: "😀😀",
|
||||
nativeRange: { start: 1, end: 9, unit: "utf8-byte" },
|
||||
}),
|
||||
expect.objectContaining({ groupNumber: 2, value: "AB" }),
|
||||
]);
|
||||
expect(result.matches[1]?.captures[1]).toEqual({
|
||||
groupNumber: 2,
|
||||
status: "did-not-participate",
|
||||
});
|
||||
});
|
||||
|
||||
it("reproduces preg_match_all empty-match retry and bounded search", async () => {
|
||||
const global = await adapter.execute(request("(?:|a)", "a"));
|
||||
expect(
|
||||
global.matches.map(({ value, nativeRange }) => [value, nativeRange]),
|
||||
).toEqual([
|
||||
["", { start: 0, end: 0, unit: "utf8-byte" }],
|
||||
["a", { start: 0, end: 1, unit: "utf8-byte" }],
|
||||
["", { start: 1, end: 1, unit: "utf8-byte" }],
|
||||
]);
|
||||
|
||||
const search = await adapter.execute(
|
||||
request("a", "a a", { flags: ["u"], scanAll: false }),
|
||||
);
|
||||
expect(search.matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("passes PHP preg modifiers to PCRE2 without reinterpretation", async () => {
|
||||
const cases = [
|
||||
["a", "A", ["i", "u"], "A"],
|
||||
["^b$", "a\nb\nc", ["m", "u"], "b"],
|
||||
["a.b", "a\nb", ["s", "u"], "a\nb"],
|
||||
["a # note\n b", "ab", ["x", "u"], "ab"],
|
||||
["a+", "aaa", ["U", "u"], "a"],
|
||||
] as const;
|
||||
for (const [pattern, subject, flags, expected] of cases) {
|
||||
const result = await adapter.execute(
|
||||
request(pattern, subject, { flags: [...flags] }),
|
||||
);
|
||||
expect(result.matches[0]?.value, flags.join("")).toBe(expected);
|
||||
}
|
||||
|
||||
const anchored = await adapter.execute(
|
||||
request("b", "ab", { flags: ["A", "u"] }),
|
||||
);
|
||||
expect(anchored.matches).toHaveLength(0);
|
||||
const dollarEndOnly = await adapter.execute(
|
||||
request("a$", "a\n", { flags: ["D", "u"] }),
|
||||
);
|
||||
expect(dollarEndOnly.matches).toHaveLength(0);
|
||||
const noAutoCapture = await adapter.execute(
|
||||
request("(a)(?<letter>b)", "ab", { flags: ["n", "u"] }),
|
||||
);
|
||||
expect(noAutoCapture.matches[0]?.captures).toEqual([
|
||||
expect.objectContaining({
|
||||
groupNumber: 1,
|
||||
groupName: "letter",
|
||||
value: "b",
|
||||
}),
|
||||
]);
|
||||
const duplicateNames = await adapter.execute(
|
||||
request("(?<part>a)(?<part>b)", "ab", { flags: ["J", "u"] }),
|
||||
);
|
||||
expect(duplicateNames.matches[0]?.captures).toEqual([
|
||||
expect.objectContaining({
|
||||
groupNumber: 1,
|
||||
groupName: "part",
|
||||
value: "a",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
groupNumber: 2,
|
||||
groupName: "part",
|
||||
value: "b",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("selects or escapes a delimiter without changing pattern semantics", async () => {
|
||||
const result = await adapter.execute(
|
||||
request("[~#%!@;:=,`/]+", "x~#%!@;:=,`/y"),
|
||||
);
|
||||
expect(result.matches[0]?.value).toBe("~#%!@;:=,`/");
|
||||
|
||||
const alreadyEscaped = await adapter.execute(request("\\~+", "~~~"));
|
||||
expect(alreadyEscaped.matches[0]?.value).toBe("~~~");
|
||||
});
|
||||
|
||||
it("matches PHP 8.5.8 preg_replace numbered and escape rules", async () => {
|
||||
const global = await adapter.replace(
|
||||
replacement("([a-z]+)-(\\d+)", "a-1 b-22", "$2:$1/${1}"),
|
||||
);
|
||||
expect(global.execution.accepted).toBe(true);
|
||||
expect(global.output).toBe("1:a/a 22:b/b");
|
||||
|
||||
const first = await adapter.replace(
|
||||
replacement("a", "a a", "x", { flags: ["u"] }),
|
||||
);
|
||||
expect(first.output).toBe("x a");
|
||||
|
||||
const inertPhp = await adapter.replace(
|
||||
replacement(
|
||||
"(x)",
|
||||
'<?php "x"; ?>',
|
||||
"'; throw new Exception('not source'); //$1",
|
||||
),
|
||||
);
|
||||
expect(inertPhp.execution.accepted).toBe(true);
|
||||
expect(inertPhp.output).toContain("not source");
|
||||
|
||||
const templateCases = [
|
||||
["$2:$1/${1}", "b:a/a :a/a"],
|
||||
["\\1", "a a"],
|
||||
["\\\\1", "\\1 \\1"],
|
||||
["\\$1", "$1 $1"],
|
||||
["$99", " "],
|
||||
["$123", "3 3"],
|
||||
["${1x}", "${1x} ${1x}"],
|
||||
] as const;
|
||||
for (const [template, expected] of templateCases) {
|
||||
const result = await adapter.replace(
|
||||
replacement("(a)(b)?", "ab a", template),
|
||||
);
|
||||
expect(result.output, JSON.stringify(template)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces match/capture/output bounds on UTF-8 boundaries", async () => {
|
||||
const limited = await adapter.execute(
|
||||
request("a", "aaa", { maximumMatches: 1 }),
|
||||
);
|
||||
expect(limited.matches).toHaveLength(1);
|
||||
expect(limited.truncated).toBe(true);
|
||||
|
||||
const rows = await adapter.execute(
|
||||
request("(a)(b)", "ab", { maximumCaptureRows: 1 }),
|
||||
);
|
||||
expect(rows.matches).toHaveLength(0);
|
||||
expect(rows.truncated).toBe(true);
|
||||
|
||||
const output = await adapter.replace(
|
||||
replacement(".", "abc", "😀", { maximumOutputBytes: 5 }),
|
||||
);
|
||||
expect(output.output).toBe("😀");
|
||||
expect(output.outputBytes).toBe(4);
|
||||
expect(output.outputTruncated).toBe(true);
|
||||
|
||||
const partial = await adapter.replace(
|
||||
replacement("a", "aa", "x", { maximumMatches: 1 }),
|
||||
);
|
||||
expect(partial.execution.truncated).toBe(true);
|
||||
expect(partial.output).toBe("xa");
|
||||
|
||||
const expanded = await adapter.replace(
|
||||
replacement("a", "a", "😀".repeat(32_768), {
|
||||
maximumOutputBytes: 5,
|
||||
}),
|
||||
);
|
||||
expect(expanded.output).toBe("😀");
|
||||
expect(expanded.outputBytes).toBe(4);
|
||||
expect(expanded.outputTruncated).toBe(true);
|
||||
});
|
||||
|
||||
it("reports native compile failures and lossy byte-mode offsets", async () => {
|
||||
const compile = await adapter.execute(request("(", ""));
|
||||
expect(compile.accepted).toBe(false);
|
||||
expect(compile.diagnostics[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
code: "compile-error",
|
||||
range: { startUtf16: 1, endUtf16: 1 },
|
||||
}),
|
||||
);
|
||||
|
||||
const byteMode = await adapter.execute(request(".", "é", { flags: ["g"] }));
|
||||
expect(byteMode.accepted).toBe(false);
|
||||
expect(byteMode.diagnostics[0]).toEqual(
|
||||
expect.objectContaining({ code: "unaligned-utf8-offset" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects lone UTF-16 surrogates before entering PHP", async () => {
|
||||
await expect(adapter.execute(request(".", "\ud800"))).rejects.toThrow(
|
||||
/unpaired UTF-16 surrogate/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces UTF-16 pattern and replacement limits inside the PHP bridge", async () => {
|
||||
const common = {
|
||||
abi: 1 as const,
|
||||
flags: "u",
|
||||
options: {},
|
||||
subject: "",
|
||||
scanAll: false,
|
||||
maximumMatches: 1,
|
||||
maximumCaptureRows: 1,
|
||||
};
|
||||
await expect(
|
||||
bridge.run({
|
||||
...common,
|
||||
operation: "execute",
|
||||
pattern: "😀".repeat(32_769),
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: false,
|
||||
code: "pattern-limit",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
bridge.run({
|
||||
...common,
|
||||
operation: "replace",
|
||||
pattern: "",
|
||||
replacement: "😀".repeat(32_769),
|
||||
maximumOutputBytes: 1,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: false,
|
||||
code: "replacement-limit",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PythonEngineAdapter,
|
||||
PYODIDE_ENGINE_VERSION,
|
||||
PYTHON_ENGINE_VERSION,
|
||||
PYTHON_FLAVOUR_VERSION,
|
||||
} from "../../src/regex/execution/adapters/python/PythonEngineAdapter";
|
||||
import type {
|
||||
PyodideModule,
|
||||
@@ -28,7 +29,7 @@ function request(
|
||||
): RegexExecutionRequest {
|
||||
return {
|
||||
flavour: "python",
|
||||
flavourVersion: PYTHON_ENGINE_VERSION,
|
||||
flavourVersion: PYTHON_FLAVOUR_VERSION,
|
||||
pattern: "a",
|
||||
flags: ["g"],
|
||||
subject: "a",
|
||||
@@ -68,7 +69,7 @@ describe("CPython re 3.14.2 conformance through self-hosted Pyodide", () => {
|
||||
expect.objectContaining({
|
||||
flavour: "python",
|
||||
engineName: "CPython re via Pyodide",
|
||||
engineVersion: "CPython 3.14.2 re",
|
||||
engineVersion: PYTHON_ENGINE_VERSION,
|
||||
runtimeVersion: expect.stringContaining("Pyodide 314.0.3"),
|
||||
offsetUnit: "code-point",
|
||||
}),
|
||||
|
||||
344
tests/conformance/ruby.conformance.test.ts
Normal file
344
tests/conformance/ruby.conformance.test.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
// @vitest-environment node
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { File, PreopenDirectory } from "@bjorn3/browser_wasi_shim";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
RubyEngineAdapter,
|
||||
RUBY_ENGINE_VERSION,
|
||||
RUBY_FLAVOUR_VERSION,
|
||||
} from "../../src/regex/execution/adapters/ruby/RubyEngineAdapter";
|
||||
import { DefaultRubyVM } from "../../src/regex/execution/adapters/ruby/ruby-module";
|
||||
import type {
|
||||
RegexExecutionRequest,
|
||||
RegexReplacementRequest,
|
||||
} from "../../src/regex/model/match";
|
||||
|
||||
const runtimeFile = path.resolve(
|
||||
process.cwd(),
|
||||
"public",
|
||||
"engines",
|
||||
"ruby",
|
||||
"ruby.wasm",
|
||||
);
|
||||
|
||||
function request(
|
||||
overrides: Partial<RegexExecutionRequest> = {},
|
||||
): RegexExecutionRequest {
|
||||
return {
|
||||
flavour: "ruby",
|
||||
flavourVersion: RUBY_FLAVOUR_VERSION,
|
||||
pattern: "a",
|
||||
flags: ["g"],
|
||||
subject: "a",
|
||||
captureMetadata: [],
|
||||
scanAll: false,
|
||||
maximumMatches: 100,
|
||||
maximumCaptureRows: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CRuby 4.0.0 Regexp conformance through ruby.wasm", () => {
|
||||
let adapter: RubyEngineAdapter;
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await WebAssembly.compile(await readFile(runtimeFile));
|
||||
const { vm, wasi } = await DefaultRubyVM(module, {
|
||||
consolePrint: false,
|
||||
});
|
||||
const requestFile = new File([]);
|
||||
const root = wasi.fds[3];
|
||||
if (!(root instanceof PreopenDirectory)) {
|
||||
throw new Error("Ruby conformance runtime has no writable root.");
|
||||
}
|
||||
root.dir.contents.set("regex-tools-request.bin", requestFile);
|
||||
const outputFile = new File([]);
|
||||
root.dir.contents.set("regex-tools-output.bin", outputFile);
|
||||
adapter = new RubyEngineAdapter(
|
||||
vm,
|
||||
"Node conformance runtime",
|
||||
(payload) => {
|
||||
requestFile.data = payload;
|
||||
},
|
||||
() => outputFile.data,
|
||||
);
|
||||
await adapter.load();
|
||||
}, 30_000);
|
||||
|
||||
afterAll(() => {
|
||||
adapter?.terminate();
|
||||
});
|
||||
|
||||
it("reports the exact CRuby, ruby.wasm, platform and offset identity", async () => {
|
||||
await expect(adapter.load()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
flavour: "ruby",
|
||||
engineName: "CRuby Regexp via ruby.wasm",
|
||||
engineVersion: RUBY_ENGINE_VERSION,
|
||||
runtimeVersion: expect.stringMatching(
|
||||
/ruby\.wasm 2\.9\.3-2\.9\.4 · wasm32-wasi/u,
|
||||
),
|
||||
offsetUnit: "code-point",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses native Ruby named groups, intersections and optional captures", async () => {
|
||||
const result = await adapter.execute(
|
||||
request({
|
||||
pattern: "(?<word>[a-z&&[^aeiou]]+)(?:-(?<number>\\d+))?",
|
||||
flags: ["g", "i"],
|
||||
subject: "BCD-12 xyz",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(result.matches.map((match) => match.value)).toEqual([
|
||||
"BCD-12",
|
||||
"xyz",
|
||||
]);
|
||||
expect(result.matches[0]?.captures).toEqual([
|
||||
expect.objectContaining({
|
||||
groupNumber: 1,
|
||||
groupName: "word",
|
||||
value: "BCD",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
groupNumber: 2,
|
||||
groupName: "number",
|
||||
value: "12",
|
||||
}),
|
||||
]);
|
||||
expect(result.matches[1]?.captures).toEqual([
|
||||
expect.objectContaining({
|
||||
groupNumber: 1,
|
||||
groupName: "word",
|
||||
value: "xyz",
|
||||
}),
|
||||
{
|
||||
groupNumber: 2,
|
||||
groupName: "number",
|
||||
status: "did-not-participate",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes astral and zero-length code-point offsets to UTF-16", async () => {
|
||||
const result = await adapter.execute(
|
||||
request({
|
||||
pattern: "(?=.)",
|
||||
subject: "😀a",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.matches.map((match) => ({
|
||||
native: match.nativeRange,
|
||||
editor: match.range,
|
||||
value: match.value,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
native: { start: 0, end: 0, unit: "code-point" },
|
||||
editor: { startUtf16: 0, endUtf16: 0 },
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
native: { start: 1, end: 1, unit: "code-point" },
|
||||
editor: { startUtf16: 2, endUtf16: 2 },
|
||||
value: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("implements Ruby g, i, m and x with scan-all normalization", async () => {
|
||||
const combined = await adapter.execute(
|
||||
request({
|
||||
pattern: " a . b ",
|
||||
flags: ["i", "m", "x"],
|
||||
subject: "z A\nB q",
|
||||
scanAll: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(combined.accepted).toBe(true);
|
||||
expect(combined.flags.effectiveFlags).toBe("gimx");
|
||||
expect(combined.flags.internallyAddedGlobalFlag).toBe(true);
|
||||
expect(combined.matches.map((match) => match.value)).toEqual(["A\nB"]);
|
||||
});
|
||||
|
||||
it("uses native String#gsub/sub named, numbered and zero-length replacement", async () => {
|
||||
const replacementRequest: RegexReplacementRequest = {
|
||||
...request({
|
||||
pattern: "(?<word>[a-z]+)-(?<number>\\d+)",
|
||||
subject: "a-1 b-22",
|
||||
}),
|
||||
replacement: "\\k<word>[\\k<number>]",
|
||||
maximumOutputBytes: 1_024,
|
||||
};
|
||||
const replaced = await adapter.replace(replacementRequest);
|
||||
|
||||
expect(replaced.execution.accepted).toBe(true);
|
||||
expect(replaced.output).toBe("a[1] b[22]");
|
||||
expect(replaced.outputTruncated).toBe(false);
|
||||
|
||||
const numbered = await adapter.replace({
|
||||
...request({
|
||||
pattern: "([a-z]+)-(\\d+)",
|
||||
subject: "a-1 b-22",
|
||||
}),
|
||||
replacement: "\\1[\\2]",
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(numbered.output).toBe("a[1] b[22]");
|
||||
|
||||
const firstOnly = await adapter.replace({
|
||||
...request({
|
||||
pattern: "(?<word>[a-z]+)",
|
||||
flags: [],
|
||||
subject: "a b",
|
||||
}),
|
||||
replacement: "<\\k<word>>",
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(firstOnly.output).toBe("<a> b");
|
||||
|
||||
const zeroLength = await adapter.replace({
|
||||
...request({
|
||||
pattern: "(?=.)",
|
||||
subject: "😀a",
|
||||
}),
|
||||
replacement: "|",
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(zeroLength.output).toBe("|😀|a");
|
||||
});
|
||||
|
||||
it("matches CRuby replacement grammar without materializing expanded output", async () => {
|
||||
const base = request({
|
||||
pattern: "(a)(b)?",
|
||||
subject: "ab a",
|
||||
});
|
||||
const cases = [
|
||||
["\\0", "ab a"],
|
||||
["\\&", "ab a"],
|
||||
["\\1", "a a"],
|
||||
["\\2", "b "],
|
||||
["\\9", " "],
|
||||
["\\+", "b a"],
|
||||
["\\\\", "\\ \\"],
|
||||
["\\q", "\\q \\q"],
|
||||
["\\`", " ab "],
|
||||
["\\'", " a "],
|
||||
["$1", "$1 $1"],
|
||||
] as const;
|
||||
for (const [template, expected] of cases) {
|
||||
const result = await adapter.replace({
|
||||
...base,
|
||||
replacement: template,
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(result.execution.accepted, template).toBe(true);
|
||||
expect(result.output, template).toBe(expected);
|
||||
expect(result.outputTruncated, template).toBe(false);
|
||||
}
|
||||
|
||||
const namedDisablesNumeric = await adapter.replace({
|
||||
...request({
|
||||
pattern: "(?<word>a)",
|
||||
subject: "a",
|
||||
}),
|
||||
replacement: "\\1",
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(namedDisablesNumeric.output).toBe("");
|
||||
});
|
||||
|
||||
it("stops expansion at the byte bound and replacement at result caps", async () => {
|
||||
const boundedExpansion = await adapter.replace({
|
||||
...request({
|
||||
pattern: "(a+)",
|
||||
subject: "a".repeat(4_096),
|
||||
}),
|
||||
replacement: "\\1".repeat(32_768),
|
||||
maximumOutputBytes: 5,
|
||||
});
|
||||
expect(boundedExpansion.execution.accepted).toBe(true);
|
||||
expect(boundedExpansion.output).toBe("aaaaa");
|
||||
expect(boundedExpansion.outputBytes).toBe(5);
|
||||
expect(boundedExpansion.outputTruncated).toBe(true);
|
||||
|
||||
const partial = await adapter.replace({
|
||||
...request({
|
||||
pattern: "a",
|
||||
subject: "aaa",
|
||||
maximumMatches: 1,
|
||||
}),
|
||||
replacement: "x",
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(partial.execution.matches).toHaveLength(1);
|
||||
expect(partial.execution.truncated).toBe(true);
|
||||
expect(partial.output).toBe("xaa");
|
||||
expect(partial.outputTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds UTF-8 output without splitting an astral scalar", async () => {
|
||||
const result = await adapter.replace({
|
||||
...request({ pattern: ".", subject: "abc" }),
|
||||
replacement: "😀",
|
||||
maximumOutputBytes: 5,
|
||||
});
|
||||
|
||||
expect(result.execution.accepted).toBe(true);
|
||||
expect(result.output).toBe("😀");
|
||||
expect(result.outputBytes).toBe(4);
|
||||
expect(result.outputTruncated).toBe(true);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("reports native compile and replacement errors", async () => {
|
||||
const compileError = await adapter.execute(request({ pattern: "(" }));
|
||||
expect(compileError.accepted).toBe(false);
|
||||
expect(compileError.diagnostics[0]?.code).toBe("compile-error");
|
||||
|
||||
const replacementError = await adapter.replace({
|
||||
...request({ pattern: "(?<word>a)" }),
|
||||
replacement: "\\k<missing>",
|
||||
maximumOutputBytes: 1_024,
|
||||
});
|
||||
expect(replacementError.execution.accepted).toBe(false);
|
||||
expect(replacementError.execution.diagnostics[0]?.code).toBe(
|
||||
"replacement-error",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects lone UTF-16 surrogates before entering ruby.wasm", async () => {
|
||||
await expect(
|
||||
adapter.execute(request({ subject: `a${String.fromCharCode(0xd800)}` })),
|
||||
).rejects.toThrow(/unpaired UTF-16 surrogate/u);
|
||||
});
|
||||
|
||||
it("enforces authoritative match and capture-row collection caps", async () => {
|
||||
const matchCap = await adapter.execute(
|
||||
request({
|
||||
pattern: "a",
|
||||
subject: "aaa",
|
||||
maximumMatches: 1,
|
||||
}),
|
||||
);
|
||||
expect(matchCap.matches).toHaveLength(1);
|
||||
expect(matchCap.truncated).toBe(true);
|
||||
|
||||
const rowCap = await adapter.execute(
|
||||
request({
|
||||
pattern: "(a)(b)",
|
||||
subject: "ab",
|
||||
maximumCaptureRows: 1,
|
||||
}),
|
||||
);
|
||||
expect(rowCap.matches).toHaveLength(0);
|
||||
expect(rowCap.truncated).toBe(true);
|
||||
});
|
||||
});
|
||||
278
tests/conformance/rust.conformance.test.ts
Normal file
278
tests/conformance/rust.conformance.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
RUST_ENGINE_VERSION,
|
||||
RUST_FLAVOUR_VERSION,
|
||||
RustEngineAdapter,
|
||||
} from "../../src/regex/execution/adapters/rust/RustEngineAdapter";
|
||||
import {
|
||||
RustWasmBridge,
|
||||
type RustRegexWasmModule,
|
||||
} from "../../src/regex/execution/adapters/rust/RustWasmBridge";
|
||||
import type {
|
||||
RegexExecutionRequest,
|
||||
RegexReplacementRequest,
|
||||
} from "../../src/regex/model/match";
|
||||
|
||||
const pack = path.resolve("public/engines/rust");
|
||||
let adapter: RustEngineAdapter;
|
||||
|
||||
function request(
|
||||
pattern: string,
|
||||
subject: string,
|
||||
overrides: Partial<RegexExecutionRequest> = {},
|
||||
): RegexExecutionRequest {
|
||||
return {
|
||||
flavour: "rust",
|
||||
flavourVersion: RUST_FLAVOUR_VERSION,
|
||||
pattern,
|
||||
flags: ["g"],
|
||||
subject,
|
||||
captureMetadata: [],
|
||||
scanAll: false,
|
||||
maximumMatches: 100,
|
||||
maximumCaptureRows: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function replacement(
|
||||
pattern: string,
|
||||
subject: string,
|
||||
replacementText: string,
|
||||
overrides: Partial<RegexReplacementRequest> = {},
|
||||
): RegexReplacementRequest {
|
||||
return {
|
||||
...request(pattern, subject),
|
||||
replacement: replacementText,
|
||||
maximumOutputBytes: 1_024,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const imported = (await import(
|
||||
`${pathToFileURL(path.join(pack, "rust-regex.mjs")).href}?conformance`
|
||||
)) as RustRegexWasmModule;
|
||||
imported.initSync({
|
||||
module: await WebAssembly.compile(
|
||||
await readFile(path.join(pack, "rust-regex_bg.wasm")),
|
||||
),
|
||||
});
|
||||
adapter = new RustEngineAdapter(
|
||||
new RustWasmBridge(imported),
|
||||
"Node.js conformance",
|
||||
);
|
||||
await adapter.load();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
adapter?.terminate();
|
||||
});
|
||||
|
||||
describe("Rust regex 1.13.1 WebAssembly conformance", () => {
|
||||
it("loads the exact crate and compiler identity", async () => {
|
||||
await expect(adapter.load()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
flavour: "rust",
|
||||
engineName: "Rust regex crate",
|
||||
engineVersion: RUST_ENGINE_VERSION,
|
||||
runtimeVersion: expect.stringContaining(
|
||||
"rustc 1.97.1 wasm32-unknown-unknown",
|
||||
),
|
||||
offsetUnit: "utf8-byte",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns native UTF-8 byte offsets and named captures", async () => {
|
||||
const result = await adapter.execute(
|
||||
request("(?P<emoji>😀+)([A-Z]+)?", "x😀😀AB 😀"),
|
||||
);
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(
|
||||
result.matches.map(({ nativeRange, range }) => ({ nativeRange, range })),
|
||||
).toEqual([
|
||||
{
|
||||
nativeRange: { start: 1, end: 11, unit: "utf8-byte" },
|
||||
range: { startUtf16: 1, endUtf16: 7 },
|
||||
},
|
||||
{
|
||||
nativeRange: { start: 12, end: 16, unit: "utf8-byte" },
|
||||
range: { startUtf16: 8, endUtf16: 10 },
|
||||
},
|
||||
]);
|
||||
expect(result.matches[0]?.captures[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
groupNumber: 1,
|
||||
groupName: "emoji",
|
||||
value: "😀😀",
|
||||
nativeRange: { start: 1, end: 9, unit: "utf8-byte" },
|
||||
}),
|
||||
);
|
||||
expect(result.matches[1]?.captures[1]).toEqual({
|
||||
groupNumber: 2,
|
||||
status: "did-not-participate",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Rust's native Unicode default and rejects unsupported constructs", async () => {
|
||||
const unicode = await adapter.execute(
|
||||
request("^\\w+$", "Grüße", { flags: [] }),
|
||||
);
|
||||
expect(unicode.matches).toHaveLength(1);
|
||||
|
||||
const unsupported = await adapter.execute(request("(?<=a)b", "ab"));
|
||||
expect(unsupported.accepted).toBe(false);
|
||||
expect(unsupported.diagnostics[0]).toEqual(
|
||||
expect.objectContaining({ code: "compile-error" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps i, m, s, U, u, x and R through RegexBuilder", async () => {
|
||||
const cases = [
|
||||
["a", "A", ["i"]],
|
||||
["^b$", "a\nb\nc", ["m"]],
|
||||
["a.b", "a\nb", ["s"]],
|
||||
["a+?", "aaa", ["U"]],
|
||||
["^ a + $", "aaa", ["x"]],
|
||||
["^b$", "a\r\nb\r\nc", ["m", "R"]],
|
||||
["\\p{Greek}+", "λ", ["u"]],
|
||||
] as const;
|
||||
for (const [pattern, subject, flags] of cases) {
|
||||
const result = await adapter.execute(
|
||||
request(pattern, subject, { flags: [...flags] }),
|
||||
);
|
||||
expect(result.matches, `${flags.join("")}: ${pattern}`).toHaveLength(1);
|
||||
}
|
||||
const ungreedy = await adapter.execute(
|
||||
request("a+", "aaa", { flags: ["U"] }),
|
||||
);
|
||||
expect(ungreedy.matches[0]?.value).toBe("a");
|
||||
});
|
||||
|
||||
it("uses native Captures::expand replacement syntax", async () => {
|
||||
const result = await adapter.replace(
|
||||
replacement(
|
||||
"(?P<word>[a-z]+)-(\\d+)",
|
||||
"a-1 b-22",
|
||||
"${word}[$2]/$word/$$",
|
||||
),
|
||||
);
|
||||
expect(result.execution.accepted).toBe(true);
|
||||
expect(result.output).toBe("a[1]/a/$ b[22]/b/$");
|
||||
|
||||
const longest = await adapter.replace(replacement("(a)", "a", "$1x|${1}x"));
|
||||
expect(longest.output).toBe("|ax");
|
||||
});
|
||||
|
||||
it("bounds capture amplification and preserves the untouched partial suffix", async () => {
|
||||
const bounded = await adapter.replace(
|
||||
replacement("(a+)", "a".repeat(256 * 1024), "$1".repeat(4_096), {
|
||||
maximumOutputBytes: 5,
|
||||
}),
|
||||
);
|
||||
expect(bounded.execution.accepted).toBe(true);
|
||||
expect(bounded.output).toBe("aaaaa");
|
||||
expect(bounded.outputBytes).toBe(5);
|
||||
expect(bounded.outputTruncated).toBe(true);
|
||||
|
||||
const partial = await adapter.replace(
|
||||
replacement("a", "aaa", "x", { maximumMatches: 1 }),
|
||||
);
|
||||
expect(partial.execution.matches).toHaveLength(1);
|
||||
expect(partial.execution.truncated).toBe(true);
|
||||
expect(partial.output).toBe("xaa");
|
||||
expect(partial.outputTruncated).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces match/capture/output bounds without splitting UTF-8", async () => {
|
||||
const limited = await adapter.execute(
|
||||
request("a", "aaa", { maximumMatches: 1 }),
|
||||
);
|
||||
expect(limited.matches).toHaveLength(1);
|
||||
expect(limited.truncated).toBe(true);
|
||||
|
||||
const rows = await adapter.execute(
|
||||
request("(a)(b)", "ab", { maximumCaptureRows: 1 }),
|
||||
);
|
||||
expect(rows.matches).toHaveLength(0);
|
||||
expect(rows.truncated).toBe(true);
|
||||
|
||||
const output = await adapter.replace(
|
||||
replacement(".", "abc", "😀", { maximumOutputBytes: 5 }),
|
||||
);
|
||||
expect(output.output).toBe("😀");
|
||||
expect(output.outputBytes).toBe(4);
|
||||
expect(output.outputTruncated).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects lone UTF-16 surrogates before entering Rust", async () => {
|
||||
await expect(adapter.execute(request(".", "\ud800"))).rejects.toThrow(
|
||||
/unpaired UTF-16 surrogate/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts control-heavy subjects within the decoded 16 MiB contract", async () => {
|
||||
const subject = "\u0000".repeat(3 * 1024 * 1024);
|
||||
const result = await adapter.execute(
|
||||
request("^\\x00+$", subject, {
|
||||
flags: [],
|
||||
maximumMatches: 1,
|
||||
maximumCaptureRows: 1,
|
||||
}),
|
||||
);
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(result.matches[0]?.nativeRange).toEqual({
|
||||
start: 0,
|
||||
end: subject.length,
|
||||
unit: "utf8-byte",
|
||||
});
|
||||
});
|
||||
|
||||
it("ships verified metadata, licences, Cargo.lock and checksums", async () => {
|
||||
const metadata = JSON.parse(
|
||||
await readFile(path.join(pack, "engine-metadata.json"), "utf8"),
|
||||
) as {
|
||||
engineVersion: string;
|
||||
source: { crateChecksumSha256: string; license: string };
|
||||
toolchain: {
|
||||
rustcCommitSha1: string;
|
||||
wasmBindgenVersion: string;
|
||||
};
|
||||
};
|
||||
expect(metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
engineVersion: "1.13.1",
|
||||
source: expect.objectContaining({
|
||||
crateChecksumSha256:
|
||||
"f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d",
|
||||
license: "MIT OR Apache-2.0",
|
||||
}),
|
||||
toolchain: expect.objectContaining({
|
||||
rustcCommitSha1: "8bab26f4f68e0e26f0bb7960be334d5b520ea452",
|
||||
wasmBindgenVersion: "0.2.126",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const checksums = await readFile(path.join(pack, "SHA256SUMS"), "utf8");
|
||||
for (const line of checksums.trim().split("\n")) {
|
||||
const [expected, name] = line.split(/ {2}/u);
|
||||
const value = await readFile(path.join(pack, name ?? ""));
|
||||
expect(createHash("sha256").update(value).digest("hex"), name).toBe(
|
||||
expected,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
await readFile(path.join(pack, "LICENSE-MIT.txt"), "utf8"),
|
||||
).toContain("Permission is hereby granted");
|
||||
expect(
|
||||
await readFile(path.join(pack, "LICENSE-Apache-2.0.txt"), "utf8"),
|
||||
).toContain("Apache License");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user