fix: retain completed results during live editing

This commit is contained in:
2026-07-27 13:05:02 +02:00
parent 7f3a91ad37
commit d2e9b3b569
22 changed files with 576 additions and 117 deletions

View File

@@ -2,6 +2,16 @@
## Unreleased
## 0.4.1 — 2026-07-27
- Keep the last completed syntax and execution frame visible while edited
inputs are parsed and executed, then replace it atomically with the newest
revision instead of rendering transient empty panels.
- Bind every retained result to its exact flavour, options, pattern, flags,
subject, replacement, mode and syntax snapshots. Mark it as stale, disable
execution until current syntax is ready, and suppress stale editor ranges
and cross-panel selection.
## 0.4.0 — 2026-07-27
- Expand the workbench from four to twelve selectable execution profiles:

View File

@@ -8,9 +8,9 @@ Patterns, replacement templates, test text and project files stay in the
browser. There is no account, backend, upload, remote execution, telemetry,
runtime CDN, remote font, or cloud-save service.
## Current release — 0.4.0
## Current release — 0.4.1
The v0.4.0 release contains twelve selectable, executable profiles:
The v0.4.1 release contains twelve selectable, executable profiles:
- **JavaScript (ECMAScript)** — the current browser's native `RegExp`, with
ECMAScript 2025 syntax supplied by regexpp 4.12.2;
@@ -99,6 +99,9 @@ relabeled Scala engine.
- Parsing and execution run in separate workers.
- Actual execution can be terminated by killing its worker.
- The worker is recreated after timeout, crash or cancellation.
- Live editing retains the last completed, input-bound syntax and result frame
until the newest revision finishes. Stale frames are labelled and cannot
enable Run, decorate current editors, or drive current-input selections.
- ECMAScript capture ranges use the engine's `d` indices result; every other
profile returns its native range records through a bounded fixed bridge.
- Named, numbered, optional, empty and repeated-final captures are represented.

View File

@@ -1,10 +1,10 @@
# Source identity
- Project: Regex Tools
- Release version: `0.4.0`
- Release tag: `v0.4.0`
- Release version: `0.4.1`
- Release tag: `v0.4.1`
- Repository: <https://git.add-ideas.de/zemion/regex-tools>
- Previous release tag: `v0.3.0`
- Previous release tag: `v0.4.0`
- Licence: GPL-3.0-or-later
- Copyright: © 2026 Albrecht Degering
@@ -82,7 +82,7 @@ metadata, closed file sets, checksums and exact licence/notice material are
installed under `public/engines/` and included in the application source and
build.
The v0.4.0 release includes native browser ECMAScript, standalone PCRE2 10.47,
The v0.4.1 release includes native browser ECMAScript, standalone PCRE2 10.47,
PHP 8.5.8 `preg` with PCRE2 10.44, legacy Perl 5.28.1 via WebPerl 0.09-beta,
CPython 3.14.2 `re`, CRuby 4.0.0 `Regexp`, TeaVM 0.15.0 `java.util.regex`,
Emscripten 6.0.4 libc++ `std::wregex`, Go 1.26.5 `regexp`, Rust `regex` 1.13.1,
@@ -91,4 +91,4 @@ TeaVM-backed Scala/JVM compatibility profile.
Earlier releases remain available under their historical tags and artifact
coordinates. A future public artifact must bump package, manifest, tag and
source identity together; it must not reuse `v0.4.0`.
source identity together; it must not reuse `v0.4.1`.

View File

@@ -18,7 +18,7 @@ budget. Consumers must inspect the preview status rather than treating a
preview as complete. In particular, list export is disabled if its template
would consume an incomplete value.
Repeated captures expose only the final retained value in all twelve v0.4.0
Repeated captures expose only the final retained value in all twelve v0.4.1
profiles. This includes .NET even though its native API can retain capture
history: the current adapter deliberately normalizes only the final record and
reports `captureHistory: false`. The UI labels the limitation and does not

View File

@@ -1,6 +1,6 @@
# Flavour support
Version 0.4.0 exposes twelve executable profiles. “Partial lexical provider”
Version 0.4.1 exposes twelve executable profiles. “Partial lexical provider”
means that Regex Tools recognizes common structure and replacement tokens for
explanation only; the named runtime compiler and replacement operation remain
authoritative.

View File

@@ -11,7 +11,7 @@ selected real ECMAScript or PCRE2 worker. It can preserve:
authoritative, untruncated output.
PHP, Perl, Python, Ruby, Java, C++, Go, Rust, .NET and Scala compatibility are
not supported minimization targets in v0.4.0. The workspace disables reduction
not supported minimization targets in v0.4.1. The workspace disables reduction
for those active profiles; it does not substitute ECMAScript, PCRE2 or a
lexical provider as an execution oracle.

View File

@@ -38,9 +38,9 @@ should test both a direct root mount and a nested mount such as
`/apps/regex/`, including cold loading of all twelve profiles.
The Portal must consume the immutable release ZIP, verify its SHA-256, verify
manifest ID `de.add-ideas.regex-tools` and version `0.4.0`, and mount it at a
manifest ID `de.add-ideas.regex-tools` and version `0.4.1`, and mount it at a
relative target such as `apps/regex/`.
The package, manifest, tag, artifact URL and checksum must all use the v0.4.0
The package, manifest, tag, artifact URL and checksum must all use the v0.4.1
identity. Historical artifact coordinates remain immutable and must not be
overwritten or reused.

View File

@@ -4,7 +4,7 @@ Inspection date: 2026-07-27.
| Reference | Exact revision/version | Licence | Use and copying status |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Regex Tools | Release identity `v0.4.0` | GPL-3.0-or-later | Application source written for this project. |
| Regex Tools | Release identity `v0.4.1` | GPL-3.0-or-later | Application source written for this project. |
| Toolbox SDK | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` / 0.2.2 | Apache-2.0 | Contract, shell and release conventions; package APIs used, no source adapted. |
| Toolbox Portal | `9347554324299c8ec9dfaec2c9730907736f62c3` / aggregate v0.8.0 / package 0.2.7 | AGPL-3.0-only | Assembly/lock conventions inspected; no source copied into the app. |
| regexpp | 4.12.2; pinned npm integrity in `package-lock.json` | MIT | ECMAScript 2025 syntax provider; normalized in the syntax worker. |
@@ -30,6 +30,6 @@ Inspection date: 2026-07-27.
| RegexLib | Website inspected | Redistribution licence not established | No fixture copied. |
| RGXP.RU | Website inspected | Redistribution licence not established | No fixture copied. |
All v0.1.0 through v0.4.0 conformance cases are project-authored. See
All v0.1.0 through v0.4.1 conformance cases are project-authored. See
`tests/fixtures/README.md`. Runtime pack metadata and adjacent licence/notice
files are authoritative for the exact binary distribution.

View File

@@ -18,8 +18,8 @@ on publication failure, replaces an existing exact version only with
`--force`, and produces:
```text
release/regex-tools-0.4.0.zip
release/regex-tools-0.4.0.zip.sha256
release/regex-tools-0.4.1.zip
release/regex-tools-0.4.1.zip.sha256
```
The release scan checks ASCII/UTF-8, UTF-16LE and UTF-16BE content in text,
@@ -43,6 +43,5 @@ anchored base-runtime legal files. The C++ gate requires its exact traced
component/source manifest and six independently pinned legal files. The release
packager names every one of those files explicitly.
The existing v0.1.0, v0.2.0 and v0.3.0 ZIPs and matching sidecars are
historical immutable artifacts. Creating v0.4.0 must not replace, rename or
remove them.
The existing v0.1.0 through v0.4.0 ZIPs and matching sidecars are historical
immutable artifacts. Creating v0.4.1 must not replace, rename or remove them.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "regex-tools",
"version": "0.4.0",
"version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "regex-tools",
"version": "0.4.0",
"version": "0.4.1",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.2",

View File

@@ -1,6 +1,6 @@
{
"name": "regex-tools",
"version": "0.4.0",
"version": "0.4.1",
"description": "Develop, explain, test and apply regular expressions locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",

View File

@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.regex-tools",
"name": "Regex Tools",
"version": "0.4.0",
"version": "0.4.1",
"description": "Develop, explain, test and apply regular expressions locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",

View File

@@ -19,7 +19,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
const gplVersion3TextSha256 =
"fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0";
const expectedApplicationVersion = "0.4.0";
const expectedApplicationVersion = "0.4.1";
const requiredRootFiles = [
"LICENSE",
"README.md",

View File

@@ -8,6 +8,7 @@ export interface ExplanationTreeProps {
readonly root?: NormalizedRegexNode;
readonly selectedId?: string;
readonly flags?: readonly string[];
readonly stale?: boolean;
readonly onSelect: (node: NormalizedRegexNode) => void;
}
@@ -165,6 +166,7 @@ export function ExplanationTree({
root,
selectedId,
flags = [],
stale = false,
onSelect,
}: ExplanationTreeProps) {
const totalNodes = useMemo(() => (root ? countNodes(root) : 0), [root]);
@@ -191,8 +193,18 @@ export function ExplanationTree({
<p className="eyebrow">Pattern structure</p>
<h2 id="explanation-heading">Explanation tree</h2>
</div>
<span className="provenance-badge">
Deterministic · syntax provider
<span
className={`provenance-badge ${stale ? "is-stale" : ""}`}
data-testid={stale ? "stale-pattern-syntax" : undefined}
title={
stale
? "This is the last parsed pattern. Its ranges are not applied to the current editor."
: undefined
}
>
{stale
? "Stale snapshot · checking current pattern"
: "Deterministic · syntax provider"}
</span>
</header>
{totalNodes > renderedNodes ? (

View File

@@ -74,6 +74,24 @@ describe("ReplacementPanel completeness", () => {
},
);
it("labels retained tokens with the flavour that parsed the snapshot", () => {
render(
<ReplacementPanel
flavour="python"
presentedFlavour="java"
replacement="$1"
onReplacementChange={() => undefined}
onSelectToken={() => undefined}
onSelectContribution={() => undefined}
/>,
);
expect(screen.getByText("Python re template syntax")).toBeInTheDocument();
expect(
screen.getByText("Java Pattern derived token explanation"),
).toBeInTheDocument();
});
it("labels a match-limited replacement as partial", () => {
renderResult({
execution: { ...execution, truncated: true },

View File

@@ -71,7 +71,11 @@ export function ReplacementPanel({
replacement,
onReplacementChange,
syntax,
presentedSyntax,
syntaxStale = false,
presentedFlavour,
result,
resultFlavour,
preview,
selectedToken,
selectedContributionId,
@@ -83,7 +87,11 @@ export function ReplacementPanel({
readonly replacement: string;
readonly onReplacementChange: (value: string) => void;
readonly syntax?: ReplacementSyntaxResult;
readonly presentedSyntax?: ReplacementSyntaxResult;
readonly syntaxStale?: boolean;
readonly presentedFlavour?: RegexFlavourId;
readonly result?: RegexReplacementResult;
readonly resultFlavour?: RegexFlavourId;
readonly preview?: ReplacementPreview;
readonly selectedToken?: ReplacementToken;
readonly selectedContributionId?: string;
@@ -96,8 +104,16 @@ export function ReplacementPanel({
const activeFlavour =
flavour ?? result?.execution.engine.flavour ?? "ecmascript";
const engineLabel = TEMPLATE_FLAVOUR_LABELS[activeFlavour];
const retainedTokens = syntax?.tokens ?? [];
const editorTokens = retainedTokens.slice(
const presentedEngineLabel =
TEMPLATE_FLAVOUR_LABELS[presentedFlavour ?? activeFlavour];
const resultEngineLabel =
TEMPLATE_FLAVOUR_LABELS[
resultFlavour ?? result?.execution.engine.flavour ?? activeFlavour
];
const retainedSyntax = presentedSyntax ?? syntax;
const retainedTokens = retainedSyntax?.tokens ?? [];
const currentTokens = syntax?.tokens ?? [];
const editorTokens = currentTokens.slice(
0,
TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks,
);
@@ -129,7 +145,7 @@ export function ReplacementPanel({
maximumLengthUtf16={
DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16
}
selectedRange={selectedToken?.range}
selectedRange={syntaxStale ? undefined : selectedToken?.range}
marks={[
...editorTokens.map((token) => ({
id: token.id,
@@ -137,7 +153,7 @@ export function ReplacementPanel({
className: `cm-replacement-token cm-replacement-${token.kind}`,
label: token.explanation,
})),
...(selectedToken
...(!syntaxStale && selectedToken
? [
{
id: "selected-replacement-token",
@@ -163,7 +179,7 @@ export function ReplacementPanel({
unit limit.
</p>
) : null}
{retainedTokens.length >
{currentTokens.length >
TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks ? (
<p
className="render-limit-banner"
@@ -171,7 +187,7 @@ export function ReplacementPanel({
>
<strong>Editor decorations limited.</strong> Showing the first{" "}
{TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks.toLocaleString()}{" "}
of {retainedTokens.length.toLocaleString()} complete replacement
of {currentTokens.length.toLocaleString()} complete replacement
tokens.
</p>
) : null}
@@ -182,9 +198,24 @@ export function ReplacementPanel({
>
<header className="panel-heading">
<div>
<p className="eyebrow">Derived token explanation</p>
<p className="eyebrow">
{presentedEngineLabel} derived token explanation
</p>
<h2 id="replacement-tree-heading">Replacement tokens</h2>
</div>
<span
className={`provenance-badge ${syntaxStale ? "is-stale" : ""}`}
data-testid={syntaxStale ? "stale-replacement-syntax" : undefined}
title={
syntaxStale
? "These are the last parsed template tokens. Their ranges are not applied to the current editor."
: undefined
}
>
{syntaxStale
? "Stale snapshot · checking current template"
: "Current template"}
</span>
</header>
{renderedTokens.length > 0 ? (
<>
@@ -222,12 +253,12 @@ export function ReplacementPanel({
</>
) : (
<p className="empty-state">
{syntax?.accepted === false
{retainedSyntax?.accepted === false
? "Token parsing is unavailable until the template is within the input limit."
: "Enter a replacement template."}
</p>
)}
{syntax?.diagnosticsTruncated ? (
{retainedSyntax?.diagnosticsTruncated ? (
<p
className="render-limit-banner"
data-testid="replacement-diagnostic-render-limit"
@@ -237,7 +268,8 @@ export function ReplacementPanel({
{TEMPLATE_PRESENTATION_LIMITS.replacementDiagnostics.toLocaleString()}{" "}
entries, including a summary, for{" "}
{(
syntax.totalDiagnostics ?? syntax.diagnostics.length
retainedSyntax.totalDiagnostics ??
retainedSyntax.diagnostics.length
).toLocaleString()}{" "}
findings.
</p>
@@ -252,7 +284,7 @@ export function ReplacementPanel({
<header className="panel-heading">
<div>
<p className="eyebrow">
Native matches · bounded {engineLabel} substitution
Native matches · bounded {resultEngineLabel} substitution
</p>
<h2>Replacement output</h2>
</div>

View File

@@ -37,6 +37,7 @@ const syntaxHarness = vi.hoisted(() => ({
const engineHarness = vi.hoisted(() => ({
requests: [] as RegexExecutionRequest[],
emitWholeFirstCharacterMatch: false,
}));
vi.mock("../editors/CodeEditor", () => ({
@@ -45,18 +46,35 @@ vi.mock("../editors/CodeEditor", () => ({
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,
})
}
/>
);
},
@@ -115,7 +133,18 @@ vi.mock("../regex/execution/EngineSupervisor", () => ({
internallyAddedIndicesFlag: false,
internallyAddedGlobalFlag: false,
},
matches: [],
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,
@@ -231,6 +260,7 @@ afterEach(() => {
syntaxHarness.patterns.length = 0;
syntaxHarness.replacements.length = 0;
engineHarness.requests.length = 0;
engineHarness.emitWholeFirstCharacterMatch = false;
});
describe("Workbench syntax coordination", () => {
@@ -346,6 +376,79 @@ describe("Workbench syntax coordination", () => {
});
});
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 />);

View File

@@ -22,6 +22,7 @@ import type {
} from "../regex/model/match";
import type {
NormalizedRegexNode,
RegexSyntaxRequest,
RegexSyntaxResult,
ReplacementSyntaxResult,
ReplacementToken,
@@ -105,6 +106,79 @@ type RunState =
| { readonly status: "timeout"; readonly message: string }
| { readonly status: "error"; readonly message: string };
interface ReplacementSyntaxSnapshot {
readonly revision: number;
readonly flavour: RegexFlavourId;
readonly replacement: string;
readonly patternSyntax: RegexSyntaxResult;
readonly result: ReplacementSyntaxResult;
}
interface ResultFrame {
readonly revision: number;
readonly flavour: RegexFlavourId;
readonly flavourVersion: string;
readonly options: RegexEngineOptions;
readonly pattern: string;
readonly flags: readonly string[];
readonly subject: string;
readonly replacement: string;
readonly mode: WorkspaceMode;
readonly scanAll: boolean;
readonly syntaxRequest: RegexSyntaxRequest;
readonly syntax: RegexSyntaxResult;
readonly replacementSyntax?: ReplacementSyntaxResult;
readonly execution: RegexExecutionResult;
readonly replacementResult?: RegexReplacementResult;
}
function engineOptionsEqual(
left: RegexEngineOptions,
right: RegexEngineOptions,
): boolean {
const entries = Object.entries(left);
return (
entries.length === Object.keys(right).length &&
entries.every(([name, value]) => right[name] === value)
);
}
function resultFrameMatchesInput(
frame: ResultFrame | undefined,
revision: number,
input: {
readonly flavour: RegexFlavourId;
readonly flavourVersion: string;
readonly options: RegexEngineOptions;
readonly pattern: string;
readonly flags: readonly string[];
readonly subject: string;
readonly replacement: string;
readonly mode: WorkspaceMode;
readonly scanAll: boolean;
readonly syntax: RegexSyntaxResult | undefined;
readonly replacementSyntax: ReplacementSyntaxResult | undefined;
},
): frame is ResultFrame {
return (
frame !== undefined &&
frame.revision === revision &&
frame.flavour === input.flavour &&
frame.flavourVersion === input.flavourVersion &&
frame.pattern === input.pattern &&
frame.subject === input.subject &&
frame.replacement === input.replacement &&
frame.mode === input.mode &&
frame.scanAll === input.scanAll &&
frame.syntax === input.syntax &&
frame.flags.length === input.flags.length &&
frame.flags.every((flag, index) => flag === input.flags[index]) &&
engineOptionsEqual(frame.options, input.options) &&
(frame.mode !== "replace" ||
frame.replacementSyntax === input.replacementSyntax)
);
}
function findSmallestNode(
node: NormalizedRegexNode,
range: SourceRange,
@@ -401,11 +475,9 @@ export function Workbench() {
);
const [syntaxSnapshot, setSyntaxSnapshot] =
useState<PatternSyntaxSnapshot<RegexSyntaxResult>>();
const [replacementSyntax, setReplacementSyntax] =
useState<ReplacementSyntaxResult>();
const [execution, setExecution] = useState<RegexExecutionResult>();
const [replacementResult, setReplacementResult] =
useState<RegexReplacementResult>();
const [replacementSyntaxSnapshot, setReplacementSyntaxSnapshot] =
useState<ReplacementSyntaxSnapshot>();
const [resultFrame, setResultFrame] = useState<ResultFrame>();
const [selectedSyntax, setSelectedSyntax] = useState<NormalizedRegexNode>();
const [requestedPatternRange, setRequestedPatternRange] =
useState<SourceRange>();
@@ -443,6 +515,10 @@ export function Workbench() {
const [showSubjectWhitespace, setShowSubjectWhitespace] = useState(false);
const [corpusSession, setCorpusSession] = useState(0);
const [subjectCursor, setSubjectCursor] = useState({ line: 1, column: 1 });
const [syntaxGeneration, setSyntaxGeneration] = useState(0);
const [replacementSyntaxGeneration, setReplacementSyntaxGeneration] =
useState(0);
const [executionGeneration, setExecutionGeneration] = useState(0);
const [projectIdentity, setProjectIdentity] = useState(() => ({
id: projectId(),
createdAt: new Date().toISOString(),
@@ -490,9 +566,7 @@ export function Workbench() {
return testEngineSupervisor.current;
};
const clearExecutionResults = useCallback(() => {
setExecution(undefined);
setReplacementResult(undefined);
const clearResultSelections = useCallback(() => {
setSelectedExtraction(undefined);
setSelectedCaptureRow(undefined);
setRequestedSubjectRange(undefined);
@@ -502,22 +576,24 @@ export function Workbench() {
const invalidateExecution = (preserveCurrentResult = false) => {
executionRevision.current += 1;
setExecutionGeneration(executionRevision.current);
engineSupervisor.current?.cancel();
clearExecutionResults();
clearResultSelections();
if (!preserveCurrentResult) setCurrentResultDraft(undefined);
setRunState({
status: "idle",
message: "Inputs changed; previous results were cleared.",
message:
"Inputs changed. The last completed result remains visible until the current inputs complete.",
});
};
const invalidatePatternSyntax = () => {
syntaxRevision.current += 1;
replacementSyntaxRevision.current += 1;
setSyntaxGeneration(syntaxRevision.current);
setReplacementSyntaxGeneration(replacementSyntaxRevision.current);
patternSyntaxSupervisor.current?.cancel();
replacementSyntaxSupervisor.current?.cancel();
setSyntaxSnapshot(undefined);
setReplacementSyntax(undefined);
setSelectedSyntax(undefined);
setSelectedReplacementToken(undefined);
setSelectedReplacementContribution(undefined);
@@ -539,8 +615,8 @@ export function Workbench() {
const changeReplacement = (value: string) => {
invalidateExecution();
replacementSyntaxRevision.current += 1;
setReplacementSyntaxGeneration(replacementSyntaxRevision.current);
replacementSyntaxSupervisor.current?.cancel();
setReplacementSyntax(undefined);
setSelectedReplacementToken(undefined);
setSelectedReplacementContribution(undefined);
setRequestedOutputRange(undefined);
@@ -582,16 +658,20 @@ export function Workbench() {
options,
};
}, [flags, flavour, flavourVersion, options, pattern]);
const syntax = isPatternSyntaxForInput(syntaxSnapshot, syntaxRequest)
? syntaxSnapshot.result
: undefined;
const syntax =
syntaxSnapshot?.revision === syntaxGeneration &&
isPatternSyntaxForInput(syntaxSnapshot, syntaxRequest)
? syntaxSnapshot.result
: undefined;
const presentedSyntax = syntax ?? syntaxSnapshot?.result;
const presentedSyntaxIsStale =
presentedSyntax !== undefined && syntax === undefined;
useEffect(() => {
const currentRevision = syntaxRevision.current;
const timer = window.setTimeout(() => {
if (pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
if (syntaxRevision.current !== currentRevision) return;
setSyntaxSnapshot(undefined);
setRunState({
status: "error",
message: `Pattern exceeds the ${DEFAULT_REGEX_LIMITS.patternHardLengthUtf16.toLocaleString()} UTF-16 unit limit.`,
@@ -613,7 +693,6 @@ export function Workbench() {
: undefined,
);
if (!result.accepted) {
clearExecutionResults();
setRunState({
status: "error",
message:
@@ -622,7 +701,6 @@ export function Workbench() {
} else if (
result.captures.length > DEFAULT_REGEX_LIMITS.maximumCaptureGroups
) {
clearExecutionResults();
setRunState({
status: "error",
message: captureGroupLimitMessage(result.captures.length),
@@ -637,7 +715,6 @@ export function Workbench() {
) {
return;
}
clearExecutionResults();
setRunState({
status: "error",
message: error instanceof Error ? error.message : String(error),
@@ -645,20 +722,30 @@ export function Workbench() {
});
}, DEFAULT_REGEX_LIMITS.liveParseDebounceMs);
return () => window.clearTimeout(timer);
}, [clearExecutionResults, pattern, syntaxRequest]);
}, [pattern, syntaxRequest]);
useEffect(() => {
if (!syntax) return;
const currentRevision = ++replacementSyntaxRevision.current;
const currentRevision = replacementSyntaxRevision.current;
const patternSyntax = syntax;
void replacementSyntaxEngine()
.parseReplacement({
flavour,
replacement,
captureMetadata: syntax.captures,
captureMetadata: patternSyntax.captures,
})
.then((result) => {
if (replacementSyntaxRevision.current !== currentRevision) return;
setReplacementSyntax(result);
setReplacementSyntaxSnapshot({
revision: currentRevision,
flavour,
replacement,
patternSyntax,
result,
});
setSelectedReplacementToken(undefined);
setSelectedReplacementContribution(undefined);
setRequestedOutputRange(undefined);
})
.catch((error: unknown) => {
if (replacementSyntaxRevision.current !== currentRevision) return;
@@ -671,6 +758,22 @@ export function Workbench() {
});
}, [flavour, replacement, syntax]);
const replacementSyntax =
replacementSyntaxSnapshot?.revision === replacementSyntaxGeneration &&
replacementSyntaxSnapshot.flavour === flavour &&
replacementSyntaxSnapshot.replacement === replacement &&
replacementSyntaxSnapshot.patternSyntax === syntax
? replacementSyntaxSnapshot.result
: undefined;
const presentedReplacementSyntax =
replacementSyntax ?? replacementSyntaxSnapshot?.result;
const presentedReplacementSyntaxIsStale =
presentedReplacementSyntax !== undefined && replacementSyntax === undefined;
const presentedReplacementFlavour =
replacementSyntax !== undefined
? flavour
: replacementSyntaxSnapshot?.flavour;
const subjectBytes = useMemo(() => utf8ByteLength(subject), [subject]);
const makeRequest = useCallback(
@@ -704,7 +807,6 @@ export function Workbench() {
window.clearTimeout(liveExecutionTimer.current);
liveExecutionTimer.current = undefined;
}
clearExecutionResults();
setCurrentResultDraft(undefined);
if (
!syntax?.accepted ||
@@ -744,6 +846,23 @@ export function Workbench() {
return;
}
const requestRevision = ++executionRevision.current;
setExecutionGeneration(requestRevision);
const currentSyntax = syntax;
const currentReplacementSyntax = replacementSyntax;
const frameIdentity = {
revision: requestRevision,
flavour,
flavourVersion,
options: { ...options },
pattern,
flags: [...flags],
subject,
replacement,
mode,
scanAll,
syntaxRequest,
syntax: currentSyntax,
} as const;
const limit =
kind === "live"
? DEFAULT_REGEX_LIMITS.liveExecutionTimeoutMs
@@ -766,16 +885,13 @@ export function Workbench() {
limit,
);
if (executionRevision.current !== requestRevision) return;
setExecution(result.execution);
if (!result.execution.accepted) {
setReplacementResult(undefined);
setRunState({
status: "error",
message: engineRejectionMessage(result.execution),
});
return;
}
setReplacementResult(result);
const commonDraft = {
name: "Current replacement result",
pattern,
@@ -825,11 +941,17 @@ export function Workbench() {
: undefined;
resultDraftNotice = decision.notice;
setCurrentResultDraft(resultDraft);
setResultFrame({
...frameIdentity,
replacementSyntax: currentReplacementSyntax,
execution: result.execution,
replacementResult: result,
});
clearResultSelections();
elapsedMs = result.execution.elapsedMs;
} else {
const result = await executionEngine().execute(makeRequest(), limit);
if (executionRevision.current !== requestRevision) return;
setExecution(result);
if (!result.accepted) {
setRunState({
status: "error",
@@ -861,6 +983,11 @@ export function Workbench() {
}
: undefined,
);
setResultFrame({
...frameIdentity,
execution: result,
});
clearResultSelections();
elapsedMs = result.elapsedMs;
}
setRunState({
@@ -871,7 +998,6 @@ export function Workbench() {
});
} catch (error) {
if (executionRevision.current !== requestRevision) return;
clearExecutionResults();
const timedOut =
error instanceof WorkerRequestError && error.kind === "timeout";
setRunState({
@@ -886,20 +1012,22 @@ export function Workbench() {
},
[
activeTestConfiguration,
clearExecutionResults,
clearResultSelections,
flags,
flavour,
flavourVersion,
makeRequest,
mode,
options,
pattern,
replacement,
replacementSyntax?.accepted,
replacementSyntax,
scanAll,
subjectBytes,
subject,
syntax?.accepted,
syntax?.captures.length,
subjectBytes,
syntax,
syntaxSnapshot,
syntaxRequest,
flags,
pattern,
tests,
timeoutMs,
],
@@ -943,22 +1071,49 @@ export function Workbench() {
syntax?.accepted,
]);
const resultFrameIsCurrent = resultFrameMatchesInput(
resultFrame,
executionGeneration,
{
flavour,
flavourVersion,
options,
pattern,
flags,
subject,
replacement,
mode,
scanAll,
syntax,
replacementSyntax,
},
);
const resultFrameIsStale = resultFrame !== undefined && !resultFrameIsCurrent;
const execution = resultFrame?.execution;
const replacementResult = resultFrame?.replacementResult;
const resultSubject = resultFrame?.subject ?? subject;
const resultSyntax = resultFrame?.syntax;
const extraction = useMemo(
() => buildExtractionTree(execution?.matches ?? [], syntax?.captures ?? []),
[execution?.matches, syntax?.captures],
() =>
buildExtractionTree(
execution?.matches ?? [],
resultSyntax?.captures ?? [],
),
[execution?.matches, resultSyntax?.captures],
);
const captureRows = useMemo(
() => buildCaptureRows(execution?.matches ?? [], subject),
[execution?.matches, subject],
() => buildCaptureRows(execution?.matches ?? [], resultSubject),
[execution?.matches, resultSubject],
);
const repeatedCaptures = useMemo(
() =>
new Set(
syntax?.captures
resultSyntax?.captures
.filter((capture) => capture.repeated)
.map((capture) => capture.number) ?? [],
),
[syntax?.captures],
[resultSyntax?.captures],
);
const selectedSubjectRange = useMemo(
() =>
@@ -971,8 +1126,12 @@ export function Workbench() {
[selectedCaptureRow, selectedExtraction?.range],
);
const subjectMarkSummary = useMemo(
() => subjectMarks(execution, selectedSubjectRange),
[execution, selectedSubjectRange],
() =>
subjectMarks(
resultFrameIsCurrent ? execution : undefined,
resultFrameIsCurrent ? selectedSubjectRange : undefined,
),
[execution, resultFrameIsCurrent, selectedSubjectRange],
);
const patternMarkSummary = useMemo(
() => syntaxMarks(syntax, selectedSyntax),
@@ -980,15 +1139,19 @@ export function Workbench() {
);
const replacementPreview = useMemo(
() =>
replacementSyntax?.accepted === true && replacementResult
? buildReplacementPreview(replacementSyntax, replacementResult, subject)
resultFrame?.replacementSyntax?.accepted === true && replacementResult
? buildReplacementPreview(
resultFrame.replacementSyntax,
replacementResult,
resultSubject,
)
: undefined,
[replacementResult, replacementSyntax, subject],
[replacementResult, resultFrame, resultSubject],
);
const diagnostics = [
...(syntax?.diagnostics ?? []),
...(replacementSyntax?.diagnostics ?? []),
...(execution?.diagnostics ?? []),
...(resultFrameIsCurrent ? (execution?.diagnostics ?? []) : []),
];
const currentProject: RegexProjectV1 = {
schemaVersion: 1,
@@ -1029,14 +1192,14 @@ export function Workbench() {
);
setSelectedCaptureRow(captureRow);
setRequestedSubjectRange(
requestEditorSelection && node.range
resultFrameIsCurrent && requestEditorSelection && node.range
? {
startUtf16: node.range.startUtf16,
endUtf16: node.range.endUtf16,
}
: undefined,
);
if (node.groupNumber !== undefined) {
if (resultFrameIsCurrent && node.groupNumber !== undefined) {
const definition = syntax?.captures.find(
(capture) => capture.number === node.groupNumber,
);
@@ -1052,16 +1215,29 @@ export function Workbench() {
: undefined,
);
}
} else {
} else if (resultFrameIsCurrent) {
setSelectedSyntax(syntax?.root);
setRequestedPatternRange(syntax?.root.range);
} else {
setSelectedSyntax(undefined);
setRequestedPatternRange(undefined);
}
};
const selectSyntaxNode = (
node: NormalizedRegexNode,
requestEditorSelection = true,
mapsToCurrentPattern = true,
) => {
if (!mapsToCurrentPattern) {
setSelectedSyntax(undefined);
setRequestedPatternRange(undefined);
setSelectedExtraction(undefined);
setSelectedCaptureRow(undefined);
setRequestedSubjectRange(undefined);
return;
}
setSelectedSyntax(node);
setRequestedPatternRange(
requestEditorSelection
@@ -1072,6 +1248,13 @@ export function Workbench() {
: undefined,
);
if (!resultFrameIsCurrent) {
setSelectedExtraction(undefined);
setSelectedCaptureRow(undefined);
setRequestedSubjectRange(undefined);
return;
}
const enclosingCapture = syntax?.captures
.filter(
(capture) =>
@@ -1129,7 +1312,9 @@ export function Workbench() {
contribution: ReplacementContributionPreview,
) => {
setSelectedReplacementContribution(contribution);
setSelectedReplacementToken(contribution.token);
setSelectedReplacementToken(
resultFrameIsCurrent ? contribution.token : undefined,
);
setRequestedOutputRange(contribution.outputRange);
const match = execution?.matches.find(
@@ -1163,7 +1348,7 @@ export function Workbench() {
} else {
setSelectedExtraction(undefined);
setSelectedCaptureRow(undefined);
setRequestedSubjectRange(match?.range);
setRequestedSubjectRange(resultFrameIsCurrent ? match?.range : undefined);
}
};
@@ -1416,6 +1601,7 @@ export function Workbench() {
const importedFlavour = AVAILABLE_REGEX_FLAVOURS.require(project.flavour);
invalidatePatternSyntax();
executionRevision.current += 1;
setExecutionGeneration(executionRevision.current);
engineSupervisor.current?.cancel();
cancelTestRun();
setFlavour(importedFlavour.id);
@@ -1442,7 +1628,10 @@ export function Workbench() {
setTestResults(new Map());
setLive(false);
setScanAll(project.ui?.scanAll ?? false);
clearExecutionResults();
setResultFrame(undefined);
setSyntaxSnapshot(undefined);
setReplacementSyntaxSnapshot(undefined);
clearResultSelections();
setSelectedSyntax(undefined);
setRequestedPatternRange(undefined);
setSelectedReplacementToken(undefined);
@@ -1973,7 +2162,7 @@ export function Workbench() {
>
<CapabilityPanel
syntax={syntax}
execution={execution}
execution={resultFrameIsCurrent ? execution : undefined}
onClose={() => setShowCapabilities(false)}
/>
</ModalDialog>
@@ -2041,10 +2230,17 @@ export function Workbench() {
<span>{runState.status}</span>
<strong>{runState.message}</strong>
{execution?.accepted ? (
<small>
<small
className={resultFrameIsStale ? "is-stale" : undefined}
data-testid={resultFrameIsStale ? "stale-result-frame" : undefined}
>
{resultFrameIsStale ? "Stale result · " : ""}
{execution.matches.length.toLocaleString()} matches ·{" "}
{execution.elapsedMs.toFixed(2)} ms · native offsets{" "}
{execution.engine.offsetUnit}
{resultFrameIsStale
? " · bound to the last completed inputs; no ranges are applied to current editors"
: ""}
</small>
) : null}
</div>
@@ -2097,10 +2293,17 @@ export function Workbench() {
/>
</section>
<ExplanationTree
root={syntax?.root}
root={presentedSyntax?.root}
selectedId={selectedSyntax?.id}
flags={flags}
onSelect={(node) => selectSyntaxNode(node)}
flags={
presentedSyntaxIsStale
? (syntaxSnapshot?.request.flags ?? [])
: flags
}
stale={presentedSyntaxIsStale}
onSelect={(node) =>
selectSyntaxNode(node, true, !presentedSyntaxIsStale)
}
/>
</div>
{mode !== "tests" && mode !== "corpus" ? (
@@ -2146,6 +2349,11 @@ export function Workbench() {
onChange={changeSubject}
onSelectionChange={(range) => {
setRequestedSubjectRange(undefined);
if (!resultFrameIsCurrent) {
setSelectedExtraction(undefined);
setSelectedCaptureRow(undefined);
return;
}
const node = findSmallestExtractionNode(extraction, range);
if (node) {
selectExtraction(node, false);
@@ -2180,7 +2388,7 @@ export function Workbench() {
{mode === "match" ? (
<CaptureTable
rows={captureRows}
subject={subject}
subject={resultSubject}
sourceResultTruncated={execution?.truncated ?? false}
selectedId={selectedCaptureRow?.id}
onSelect={(row) => {
@@ -2194,7 +2402,7 @@ export function Workbench() {
setSelectedCaptureRow(row);
setSelectedExtraction(undefined);
setRequestedSubjectRange(
row.start === undefined
!resultFrameIsCurrent || row.start === undefined
? undefined
: {
startUtf16: row.start,
@@ -2202,9 +2410,11 @@ export function Workbench() {
},
);
}
const definition = syntax?.captures.find(
(capture) => capture.number === row.groupNumber,
);
const definition = resultFrameIsCurrent
? syntax?.captures.find(
(capture) => capture.number === row.groupNumber,
)
: undefined;
if (definition && syntax) {
const selected = findSmallestNode(syntax.root, definition.range);
setSelectedSyntax(selected);
@@ -2226,7 +2436,11 @@ export function Workbench() {
replacement={replacement}
onReplacementChange={changeReplacement}
syntax={replacementSyntax}
presentedSyntax={presentedReplacementSyntax}
syntaxStale={presentedReplacementSyntaxIsStale}
presentedFlavour={presentedReplacementFlavour}
result={replacementResult}
resultFlavour={resultFrame?.flavour}
preview={replacementPreview}
selectedToken={selectedReplacementToken}
selectedContributionId={selectedReplacementContribution?.id}
@@ -2244,7 +2458,7 @@ export function Workbench() {
templateSource={listTemplate}
onTemplateChange={setListTemplate}
matches={execution?.matches ?? []}
subject={subject}
subject={resultSubject}
sourceResultTruncated={execution?.truncated ?? false}
/>
) : null}

View File

@@ -161,6 +161,24 @@ textarea:focus-visible,
color: color-mix(in srgb, var(--regex-mint) 70%, var(--toolbox-text));
}
.provenance-badge.is-stale,
.run-status > small.is-stale {
color: color-mix(in srgb, var(--regex-warning) 78%, var(--toolbox-text));
}
.provenance-badge.is-stale {
border-color: color-mix(
in srgb,
var(--regex-warning) 45%,
var(--toolbox-border)
);
background: color-mix(
in srgb,
var(--regex-warning) 9%,
var(--toolbox-surface)
);
}
.primary-button,
.secondary-button,
.icon-button {

View File

@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.regex-tools",
"name": "Regex Tools",
"version": "0.4.0",
"version": "0.4.1",
"description": "Develop, explain, test and apply regular expressions locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",

View File

@@ -1,3 +1,3 @@
export const APPLICATION_VERSION = "0.4.0";
export const APPLICATION_VERSION = "0.4.1";
export const SYNTAX_PROFILE = "community";
export const REGEXPP_VERSION = "4.12.2";

View File

@@ -446,6 +446,7 @@ test("rapid pattern and replacement edits cannot run with stale syntax", async (
await page.goto("./");
await waitForReady(page);
await page.getByLabel("Live").uncheck();
const initialExtractionCount = await page.locator(".extraction-row").count();
await page.getByRole("button", { name: "Replace", exact: true }).click();
await setEditor(page, "editor-regular-expression-pattern", "(?<stale>\\w+)");
@@ -462,7 +463,10 @@ test("rapid pattern and replacement edits cannot run with stale syntax", async (
await expect(runButton).toBeDisabled();
await runButton.evaluate((button: HTMLButtonElement) => button.click());
await expect(page.locator(".run-status")).not.toHaveClass(/status-running/u);
await expect(page.locator(".extraction-row")).toHaveCount(0);
await expect(page.locator(".extraction-row")).toHaveCount(
initialExtractionCount,
);
await expect(page.getByTestId("stale-result-frame")).toBeVisible();
await expect(runButton).toBeEnabled();
const currentNamedToken = page.locator(".token-row").filter({
@@ -484,22 +488,31 @@ test("rapid pattern and replacement edits cannot run with stale syntax", async (
).toHaveText("[A] [B]");
});
test("manual editing clears results that no longer describe the inputs", async ({
test("manual editing retains an explicitly stale result without applying its ranges", async ({
page,
}) => {
await page.goto("./");
await waitForReady(page);
await page.getByLabel("Live").uncheck();
const extractionRows = page.locator(".extraction-row");
const captureRows = page.locator(".capture-table-scroll tbody tr");
const initialExtractionCount = await extractionRows.count();
const initialCaptureCount = await captureRows.count();
expect(initialExtractionCount).toBeGreaterThan(0);
expect(initialCaptureCount).toBeGreaterThan(0);
await setEditor(page, "editor-regular-expression-pattern", "nomatch");
await expect(page.locator(".run-status")).toHaveClass(/status-idle/u);
await expect(page.locator(".run-status")).toContainText(
"previous results were cleared",
"last completed result remains visible until the current inputs complete",
);
await expect(page.locator(".run-status small")).toHaveCount(0);
await expect(page.locator(".extraction-row")).toHaveCount(0);
await expect(page.locator(".capture-table-scroll tbody tr")).toHaveCount(0);
await expect(page.getByTestId("stale-result-frame")).toContainText(
"no ranges are applied to current editors",
);
await expect(extractionRows).toHaveCount(initialExtractionCount);
await expect(captureRows).toHaveCount(initialCaptureCount);
await expect(page.locator(".cm-subject-match")).toHaveCount(0);
});
@@ -509,6 +522,10 @@ test("capture-group resource limits block execution visibly", async ({
await page.goto("./");
await waitForReady(page);
await page.getByLabel("Live").uncheck();
const initialExtractionCount = await page.locator(".extraction-row").count();
const initialCaptureCount = await page
.locator(".capture-table-scroll tbody tr")
.count();
await setEditor(
page,
@@ -520,8 +537,14 @@ test("capture-group resource limits block execution visibly", async ({
await expect(page.locator(".run-status")).toContainText(
"above the 1,000 group execution limit",
);
await expect(page.locator(".run-status small")).toHaveCount(0);
await expect(page.locator(".extraction-row")).toHaveCount(0);
await expect(page.getByTestId("stale-result-frame")).toBeVisible();
await expect(page.locator(".extraction-row")).toHaveCount(
initialExtractionCount,
);
await expect(page.locator(".capture-table-scroll tbody tr")).toHaveCount(
initialCaptureCount,
);
await expect(page.locator(".cm-subject-match")).toHaveCount(0);
});
test("large valid patterns bound syntax tree nodes and editor decorations", async ({
@@ -779,6 +802,25 @@ test("replacement and list modes use native and typed templates", async ({
page.getByTestId("editor-replacement-output").locator(".cm-selected-range"),
).toHaveCount(1);
await page.getByLabel("Live").uncheck();
await setEditor(page, "editor-replacement-template", "[$<user> — $<date>]");
await expect(page.getByTestId("stale-replacement-syntax")).not.toBeVisible();
await expect(page.getByTestId("stale-result-frame")).toBeVisible();
await userContribution.click();
await expect(
page
.getByTestId("editor-replacement-template")
.locator(".cm-selected-range"),
).toHaveCount(0);
await expect(
page
.getByTestId("editor-regular-expression-pattern")
.locator(".cm-selected-range"),
).toHaveCount(0);
await expect(
page.getByTestId("editor-test-text").locator(".cm-selected-range"),
).toHaveCount(0);
await page.getByRole("button", { name: "List", exact: true }).click();
await expect(
page.getByRole("heading", { name: "Generated rows" }),
@@ -1117,6 +1159,10 @@ test("a timeout kills the worker and the next request uses a fresh engine", asyn
await page.goto("./");
await waitForReady(page);
await page.getByLabel("Live").uncheck();
const initialExtractionCount = await page.locator(".extraction-row").count();
const initialCaptureCount = await page
.locator(".capture-table-scroll tbody tr")
.count();
await page.getByLabel("Manual limit").selectOption("250");
await setEditor(page, "editor-regular-expression-pattern", "^(a|aa)+$");
await setEditor(page, "editor-test-text", `${"a".repeat(48)}!`);
@@ -1127,9 +1173,13 @@ test("a timeout kills the worker and the next request uses a fresh engine", asyn
await expect(page.locator(".run-status")).toContainText(
"worker was terminated",
);
await expect(page.locator(".run-status small")).toHaveCount(0);
await expect(page.locator(".extraction-row")).toHaveCount(0);
await expect(page.locator(".capture-table-scroll tbody tr")).toHaveCount(0);
await expect(page.getByTestId("stale-result-frame")).toBeVisible();
await expect(page.locator(".extraction-row")).toHaveCount(
initialExtractionCount,
);
await expect(page.locator(".capture-table-scroll tbody tr")).toHaveCount(
initialCaptureCount,
);
await expect(page.locator(".cm-subject-match")).toHaveCount(0);
await setEditor(page, "editor-regular-expression-pattern", "^ok$");