feat: publish Regex Tools 0.1.0
This commit is contained in:
383
src/components/Workbench.test.tsx
Normal file
383
src/components/Workbench.test.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
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[],
|
||||
}));
|
||||
|
||||
vi.mock("../editors/CodeEditor", () => ({
|
||||
CodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
readOnly,
|
||||
}: {
|
||||
readonly value: string;
|
||||
readonly onChange?: (value: string) => void;
|
||||
readonly label: string;
|
||||
readonly readOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
aria-label={label}
|
||||
value={value}
|
||||
readOnly={readOnly}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
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: [],
|
||||
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;
|
||||
});
|
||||
|
||||
describe("Workbench syntax coordination", () => {
|
||||
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();
|
||||
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({
|
||||
pattern: "(?<new>b)",
|
||||
captureMetadata: [{ number: 1, name: "new" }],
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user