563 lines
17 KiB
TypeScript
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();
|
|
});
|
|
});
|