Files
regex-tools/src/components/Workbench.test.tsx

563 lines
17 KiB
TypeScript

import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RegexExecutionRequest } from "../regex/model/match";
import type {
CaptureDefinition,
RegexSyntaxRequest,
RegexSyntaxResult,
ReplacementSyntaxResult,
} from "../regex/model/syntax";
interface PendingPattern {
readonly request: RegexSyntaxRequest;
readonly resolve: (result: RegexSyntaxResult) => void;
}
interface PendingReplacement {
readonly replacement: string;
readonly resolve: (result: ReplacementSyntaxResult) => void;
}
interface SyntaxInstance {
readonly label: string;
cancelCalls: number;
}
const syntaxHarness = vi.hoisted(() => ({
instances: [] as SyntaxInstance[],
patterns: [] as PendingPattern[],
replacements: [] as PendingReplacement[],
}));
const engineHarness = vi.hoisted(() => ({
requests: [] as RegexExecutionRequest[],
emitWholeFirstCharacterMatch: false,
}));
vi.mock("../editors/CodeEditor", () => ({
CodeEditor({
value,
onChange,
label,
readOnly,
marks,
selectedRange,
onSelectionChange,
}: {
readonly value: string;
readonly onChange?: (value: string) => void;
readonly label: string;
readonly readOnly?: boolean;
readonly marks?: readonly unknown[];
readonly selectedRange?: { readonly startUtf16: number };
readonly onSelectionChange?: (range: {
readonly startUtf16: number;
readonly endUtf16: number;
}) => void;
}) {
return (
<textarea
aria-label={label}
value={value}
readOnly={readOnly}
data-mark-count={marks?.length ?? 0}
data-selected-start={selectedRange?.startUtf16}
onChange={(event) => onChange?.(event.target.value)}
onSelect={(event) =>
onSelectionChange?.({
startUtf16: event.currentTarget.selectionStart,
endUtf16: event.currentTarget.selectionEnd,
})
}
/>
);
},
}));
vi.mock("../regex/execution/SyntaxSupervisor", () => ({
SyntaxSupervisor: class {
readonly instance: SyntaxInstance;
constructor(label = "Syntax parser") {
this.instance = { label, cancelCalls: 0 };
syntaxHarness.instances.push(this.instance);
}
parsePattern(request: RegexSyntaxRequest): Promise<RegexSyntaxResult> {
return new Promise((resolve) => {
syntaxHarness.patterns.push({ request, resolve });
});
}
parseReplacement(request: {
readonly replacement: string;
}): Promise<ReplacementSyntaxResult> {
return new Promise((resolve) => {
syntaxHarness.replacements.push({
replacement: request.replacement,
resolve,
});
});
}
cancel(): void {
this.instance.cancelCalls += 1;
}
dispose(): void {}
},
}));
vi.mock("../regex/execution/EngineSupervisor", () => ({
EngineSupervisor: class {
result(request: RegexExecutionRequest) {
return {
accepted: true,
engine: {
id: "test-engine",
displayName: "Test engine",
version: "1",
flavour: "ecmascript",
offsetUnit: "utf16",
runtime: "worker",
},
flags: {
userFlags: request.flags.join(""),
effectiveFlags: request.flags.join(""),
internallyAddedIndicesFlag: false,
internallyAddedGlobalFlag: false,
},
matches: engineHarness.emitWholeFirstCharacterMatch
? [
{
matchNumber: 1,
value: request.subject.slice(0, 1),
valueStatus: "complete" as const,
range: { startUtf16: 0, endUtf16: 1 },
nativeRange: { start: 0, end: 1, unit: "utf16" as const },
captures: [],
},
]
: [],
diagnostics: [],
elapsedMs: 1,
truncated: false,
};
}
execute(request: RegexExecutionRequest) {
engineHarness.requests.push(request);
return Promise.resolve(this.result(request));
}
replace(
request: RegexExecutionRequest & {
readonly replacement: string;
},
) {
engineHarness.requests.push(request);
return Promise.resolve({
execution: this.result(request),
output: request.replacement,
outputBytes: request.replacement.length,
outputTruncated: false,
truncated: false,
});
}
cancel(): void {}
dispose(): void {}
},
}));
import { Workbench } from "./Workbench";
function syntaxResult(
pattern: string,
captures: readonly CaptureDefinition[],
): RegexSyntaxResult {
return {
accepted: true,
root: {
id: `root-${pattern}`,
kind: "pattern",
range: { startUtf16: 0, endUtf16: pattern.length },
raw: pattern,
explanation: "Test syntax",
children: [],
properties: {
zeroWidth: false,
nullable: false,
consumesInput: true,
},
support: {
flavour: "ecmascript",
status: "supported",
notes: [],
},
provenance: {
provider: "test",
providerVersion: "1",
source: "parsed",
},
},
tokens: [],
captures,
diagnostics: [],
provider: { id: "test", version: "1" },
coverage: {
status: "full-tested",
summary: "Test coverage",
unsupportedConstructs: [],
},
};
}
async function advanceParseDebounce(): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(120);
});
}
async function resolvePattern(
index: number,
captures: readonly CaptureDefinition[],
): Promise<void> {
await act(async () => {
const pending = syntaxHarness.patterns[index];
if (!pending) throw new Error(`Pattern request ${index} is unavailable`);
pending.resolve(syntaxResult(pending.request.pattern, captures));
await Promise.resolve();
});
}
async function resolveLatestReplacement(): Promise<void> {
await act(async () => {
const pending = syntaxHarness.replacements.at(-1);
if (!pending) throw new Error("Replacement request is unavailable");
pending.resolve({
accepted: true,
tokens: [],
diagnostics: [],
totalDiagnostics: 0,
diagnosticsTruncated: false,
});
await Promise.resolve();
});
}
afterEach(() => {
cleanup();
vi.useRealTimers();
syntaxHarness.instances.length = 0;
syntaxHarness.patterns.length = 0;
syntaxHarness.replacements.length = 0;
engineHarness.requests.length = 0;
engineHarness.emitWholeFirstCharacterMatch = false;
});
describe("Workbench syntax coordination", () => {
it("exposes enumerated engine options and forwards the selected C++ grammar", async () => {
vi.useFakeTimers();
render(<Workbench />);
fireEvent.change(screen.getByRole("combobox", { name: "Flavour" }), {
target: { value: "cpp" },
});
const grammar = screen.getByRole("combobox", { name: "Grammar" });
expect(grammar).toHaveValue("ECMAScript");
fireEvent.change(grammar, { target: { value: "extended" } });
await advanceParseDebounce();
expect(syntaxHarness.patterns.at(-1)?.request).toMatchObject({
flavour: "cpp",
flags: ["g"],
options: { grammar: "extended" },
});
});
it("blocks Run until current syntax arrives and isolates replacement parsing", async () => {
vi.useFakeTimers();
render(<Workbench />);
fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
await advanceParseDebounce();
expect(syntaxHarness.patterns[0]?.request).toMatchObject({
flavour: "ecmascript",
flavourVersion: "2025",
flags: ["g", "u"],
options: {},
});
expect(
screen.getByRole("combobox", { name: "Flavour" }),
).not.toBeDisabled();
expect(screen.getByRole("combobox", { name: "Version" })).toBeDisabled();
await resolvePattern(0, [
{
number: 1,
name: "old",
range: { startUtf16: 0, endUtf16: 9 },
repeated: false,
},
]);
expect(syntaxHarness.instances.map((instance) => instance.label)).toEqual([
"Pattern syntax parser",
"Replacement syntax parser",
]);
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
const patternInput = screen.getByRole("textbox", {
name: "Regular expression pattern",
});
const replacementInput = screen.getByRole("textbox", {
name: "Replacement template",
});
fireEvent.change(patternInput, { target: { value: "(?<new>b)" } });
fireEvent.change(replacementInput, { target: { value: "$<new>" } });
const runButton = screen.getByRole("button", { name: "Run" });
expect(runButton).toBeDisabled();
fireEvent.click(runButton);
expect(engineHarness.requests).toHaveLength(0);
await advanceParseDebounce();
expect(syntaxHarness.patterns[1]?.request.pattern).toBe("(?<new>b)");
await resolvePattern(1, [
{
number: 1,
name: "new",
range: { startUtf16: 0, endUtf16: 9 },
repeated: false,
},
]);
expect(runButton).toBeDisabled();
await resolveLatestReplacement();
expect(runButton).toBeEnabled();
const patternParser = syntaxHarness.instances[0];
const replacementParser = syntaxHarness.instances[1];
if (!patternParser || !replacementParser) {
throw new Error("Expected both syntax parser instances");
}
const patternCancelCount = patternParser.cancelCalls;
fireEvent.change(replacementInput, { target: { value: "$<new>!" } });
fireEvent.change(replacementInput, { target: { value: "[$<new>]" } });
expect(patternParser.cancelCalls).toBe(patternCancelCount);
expect(replacementParser.cancelCalls).toBeGreaterThan(0);
expect(
syntaxHarness.replacements.map((request) => request.replacement),
).toContain("[$<new>]");
expect(runButton).toBeDisabled();
await resolveLatestReplacement();
expect(runButton).toBeEnabled();
fireEvent.click(runButton);
await act(async () => {
await Promise.resolve();
});
expect(engineHarness.requests).toHaveLength(1);
expect(engineHarness.requests[0]).toMatchObject({
flavour: "ecmascript",
flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
options: {},
pattern: "(?<new>b)",
captureMetadata: [{ number: 1, name: "new" }],
});
});
it("does not revive an old syntax snapshot when rapid edits return to the same text", async () => {
vi.useFakeTimers();
render(<Workbench />);
fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
await advanceParseDebounce();
await resolvePattern(0, []);
const patternInput = screen.getByRole("textbox", {
name: "Regular expression pattern",
});
const initialPattern = (patternInput as HTMLTextAreaElement).value;
const runButton = screen.getByRole("button", { name: "Run" });
expect(runButton).toBeEnabled();
fireEvent.change(patternInput, { target: { value: "temporary" } });
fireEvent.change(patternInput, { target: { value: initialPattern } });
expect(runButton).toBeDisabled();
fireEvent.click(screen.getByRole("treeitem"));
expect(patternInput).toHaveAttribute("data-mark-count", "0");
await advanceParseDebounce();
await resolvePattern(1, []);
expect(patternInput).toHaveAttribute("data-mark-count", "0");
expect(runButton).toBeEnabled();
});
it("retains one exact result frame until the edited subject has a replacement frame", async () => {
vi.useFakeTimers();
engineHarness.emitWholeFirstCharacterMatch = true;
render(<Workbench />);
fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
await advanceParseDebounce();
await resolvePattern(0, []);
fireEvent.click(screen.getByRole("button", { name: "Run" }));
await act(async () => {
await Promise.resolve();
});
const subjectInput = screen.getByRole("textbox", { name: "Test text" });
const originalSubject = (subjectInput as HTMLTextAreaElement).value;
expect(subjectInput).toHaveAttribute("data-mark-count", "1");
expect(document.querySelector(".extraction-row")).toHaveTextContent("2");
fireEvent.change(subjectInput, { target: { value: "Z new subject" } });
expect(screen.getByTestId("stale-result-frame")).toHaveTextContent(
"bound to the last completed inputs",
);
expect(subjectInput).toHaveAttribute("data-mark-count", "0");
expect(document.querySelector(".extraction-row")).toHaveTextContent("2");
fireEvent.change(subjectInput, { target: { value: originalSubject } });
expect(screen.getByTestId("stale-result-frame")).toBeInTheDocument();
expect(subjectInput).toHaveAttribute("data-mark-count", "0");
fireEvent.change(subjectInput, { target: { value: "Z new subject" } });
(subjectInput as HTMLTextAreaElement).setSelectionRange(0, 1);
fireEvent.select(subjectInput);
expect(
document.querySelector(".extraction-row.is-selected"),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Run" }));
await act(async () => {
await Promise.resolve();
});
expect(screen.queryByTestId("stale-result-frame")).not.toBeInTheDocument();
expect(subjectInput).toHaveAttribute("data-mark-count", "1");
expect(document.querySelector(".extraction-row")).toHaveTextContent("Z");
});
it("captures a complete result and reruns only the selected saved test", async () => {
vi.useFakeTimers();
render(<Workbench />);
fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
await advanceParseDebounce();
await resolvePattern(0, []);
fireEvent.click(screen.getByRole("button", { name: "Run" }));
await act(async () => {
await Promise.resolve();
});
fireEvent.click(screen.getByRole("button", { name: "Unit tests" }));
const addCurrent = screen.getByRole("button", {
name: "Add current result",
});
expect(addCurrent).toBeEnabled();
fireEvent.click(addCurrent);
expect(screen.getByText("Current match result")).toBeVisible();
expect(screen.getByText("match count: 0", { exact: false })).toBeVisible();
fireEvent.click(
screen.getByRole("checkbox", { name: "Select Current match result" }),
);
fireEvent.click(screen.getByRole("button", { name: "Run selected" }));
const testPatternIndex = syntaxHarness.patterns.length - 1;
await resolvePattern(testPatternIndex, []);
await act(async () => {
await Promise.resolve();
});
expect(screen.getByText(/^Pass —/)).toBeVisible();
expect(engineHarness.requests.at(-1)?.pattern).toBe(
syntaxHarness.patterns[0]?.request.pattern,
);
expect(engineHarness.requests.at(-1)?.scanAll).toBe(false);
});
it("hard-stops an active suite at the aggregate wall deadline", async () => {
vi.useFakeTimers();
render(<Workbench />);
fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
await advanceParseDebounce();
await resolvePattern(0, []);
fireEvent.click(screen.getByRole("button", { name: "Run" }));
await act(async () => {
await Promise.resolve();
});
fireEvent.click(screen.getByRole("button", { name: "Unit tests" }));
fireEvent.click(screen.getByRole("button", { name: "Add current result" }));
fireEvent.click(screen.getByRole("button", { name: "Run all" }));
expect(screen.getByRole("button", { name: "Cancel" })).toBeVisible();
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
});
expect(screen.getByText(/Suite stopped at the 60 second/u)).toBeVisible();
expect(screen.getByRole("button", { name: "Run all" })).toBeEnabled();
expect(
syntaxHarness.instances.find(
(instance) => instance.label === "Syntax parser",
)?.cancelCalls,
).toBeGreaterThan(0);
});
it("opens the bounded subject minimizer with the current editor snapshot", () => {
vi.useFakeTimers();
render(<Workbench />);
fireEvent.click(screen.getByRole("button", { name: "Minimize" }));
expect(
screen.getByRole("heading", { name: "Minimize a reproducer" }),
).toBeVisible();
expect(screen.getByLabelText("Subject to minimize")).toHaveValue(
"2026-07-24 alice\nInvalid row\n2025-12-31 Bérénice",
);
expect(screen.getByLabelText("Minimization failure kind")).toHaveValue(
"unit-test-failure",
);
fireEvent.click(
screen.getByRole("button", { name: "Close subject minimizer" }),
);
expect(
screen.queryByRole("heading", { name: "Minimize a reproducer" }),
).not.toBeInTheDocument();
});
it("opens and closes the grammar-backed formatter workspace", () => {
vi.useFakeTimers();
render(<Workbench />);
fireEvent.click(screen.getByRole("button", { name: "Format" }));
expect(
screen.getByRole("heading", { name: "Format pattern" }),
).toBeVisible();
expect(
screen.getByText(/Wait for the ECMAScript syntax provider/),
).toBeVisible();
fireEvent.click(
screen.getByRole("button", { name: "Close pattern formatter" }),
);
expect(
screen.queryByRole("heading", { name: "Format pattern" }),
).not.toBeInTheDocument();
});
});