@elabs-ai/components-process 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/core/index.d.ts +1029 -0
- package/dist/core/index.js +1553 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/process-worker.js +462 -0
- package/dist/core/process-worker.js.map +1 -0
- package/dist/index.d.ts +1153 -0
- package/dist/index.js +3146 -0
- package/dist/index.js.map +1 -0
- package/dist/test/index.d.ts +196 -0
- package/dist/test/index.js +527 -0
- package/dist/test/index.js.map +1 -0
- package/package.json +80 -0
- package/src/abstraction-controls/abstraction-controls-fixtures.ts +86 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +188 -0
- package/src/abstraction-controls/abstraction-controls.test.tsx +226 -0
- package/src/abstraction-controls/abstraction-controls.tsx +288 -0
- package/src/abstraction-controls/auto-abstraction.test.ts +196 -0
- package/src/abstraction-controls/auto-abstraction.ts +128 -0
- package/src/abstraction-controls/index.ts +4 -0
- package/src/core/abstract-graph.test.ts +209 -0
- package/src/core/abstract-graph.ts +407 -0
- package/src/core/adapters/csv.test.ts +131 -0
- package/src/core/adapters/csv.ts +146 -0
- package/src/core/adapters/flat.test.ts +149 -0
- package/src/core/adapters/flat.ts +168 -0
- package/src/core/aggregate-performance.test.ts +208 -0
- package/src/core/aggregate-performance.ts +200 -0
- package/src/core/detect-rework.test.ts +134 -0
- package/src/core/detect-rework.ts +100 -0
- package/src/core/discover-graph.test.ts +378 -0
- package/src/core/discover-graph.ts +202 -0
- package/src/core/duration-stats.test.ts +116 -0
- package/src/core/duration-stats.ts +162 -0
- package/src/core/event-log.test.ts +224 -0
- package/src/core/event-log.ts +244 -0
- package/src/core/extract-variants.test.ts +126 -0
- package/src/core/extract-variants.ts +140 -0
- package/src/core/filter-log.test.ts +193 -0
- package/src/core/filter-log.ts +215 -0
- package/src/core/fixtures/generate-bpi-2012-subset.test.ts +50 -0
- package/src/core/fixtures/generate-bpi-2012-subset.ts +216 -0
- package/src/core/fixtures/generate-bpi-2012-subset.write.ts +40 -0
- package/src/core/fixtures/order-to-cash-small.json +200 -0
- package/src/core/fixtures/synthetic-log.test.ts +109 -0
- package/src/core/fixtures/synthetic-log.ts +167 -0
- package/src/core/index.ts +118 -0
- package/src/core/reconcile-graph.test.ts +175 -0
- package/src/core/reconcile-graph.ts +107 -0
- package/src/core/scale.test.ts +80 -0
- package/src/core/scale.ts +100 -0
- package/src/core/types.ts +151 -0
- package/src/core/worker/create-process-worker.test.ts +255 -0
- package/src/core/worker/create-process-worker.ts +211 -0
- package/src/core/worker/process-worker.ts +80 -0
- package/src/index.ts +29 -0
- package/src/metric-layer-switch/index.ts +6 -0
- package/src/metric-layer-switch/metric-layer-switch.stories.tsx +131 -0
- package/src/metric-layer-switch/metric-layer-switch.test.tsx +102 -0
- package/src/metric-layer-switch/metric-layer-switch.tsx +276 -0
- package/src/process-explorer.stories.tsx +392 -0
- package/src/process-kpi-strip/index.ts +6 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +128 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +106 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +237 -0
- package/src/process-map/index.ts +13 -0
- package/src/process-map/map-model.test.ts +326 -0
- package/src/process-map/map-model.ts +873 -0
- package/src/process-map/process-activity-node.tsx +200 -0
- package/src/process-map/process-map-context.ts +71 -0
- package/src/process-map/process-map.stories.tsx +673 -0
- package/src/process-map/process-map.test.tsx +523 -0
- package/src/process-map/process-map.tsx +979 -0
- package/src/process-map/process-transition-edge.test.tsx +160 -0
- package/src/process-map/process-transition-edge.tsx +151 -0
- package/src/process-map/use-process-layout.test.tsx +265 -0
- package/src/process-map/use-process-layout.ts +315 -0
- package/src/test/contract.test.ts +99 -0
- package/src/test/contract.ts +118 -0
- package/src/test/doubles.test.tsx +51 -0
- package/src/test/doubles.tsx +82 -0
- package/src/test/index.ts +34 -0
- package/src/test/primitives.tsx +35 -0
- package/src/use-process-explorer/index.ts +8 -0
- package/src/use-process-explorer/use-process-explorer.test.ts +564 -0
- package/src/use-process-explorer/use-process-explorer.ts +540 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual-scale helpers shared by every process view — RM-049.
|
|
3
|
+
*
|
|
4
|
+
* One implementation, so RM-051's edge width, RM-054's coverage bar and RM-050's
|
|
5
|
+
* abstraction threshold all agree on what "the 90th percentile" and "map this count to a
|
|
6
|
+
* stroke width" mean. Re-deriving a quantile per component is how two views end up
|
|
7
|
+
* disagreeing about the same log.
|
|
8
|
+
*
|
|
9
|
+
* All three are pure and total: they never throw, and they answer for the degenerate
|
|
10
|
+
* inputs (empty array, zero-width domain) rather than returning `NaN`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Ascending numeric comparator. Extracted so every sort in `/core` uses the same one. */
|
|
14
|
+
export function ascending(a: number, b: number): number {
|
|
15
|
+
return a - b;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Only finite samples take part in a scale; `NaN`/`Infinity` are dropped, not propagated. */
|
|
19
|
+
function finiteOnly(values: readonly number[]): number[] {
|
|
20
|
+
const out: number[] = [];
|
|
21
|
+
for (const v of values) if (Number.isFinite(v)) out.push(v);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The extent of `values`, ignoring non-finite entries.
|
|
27
|
+
*
|
|
28
|
+
* @returns `[min, max]`, or `[0, 0]` when there is nothing finite to measure.
|
|
29
|
+
*/
|
|
30
|
+
export function minMax(values: readonly number[]): [number, number] {
|
|
31
|
+
let min = Number.POSITIVE_INFINITY;
|
|
32
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
33
|
+
let seen = false;
|
|
34
|
+
for (const v of values) {
|
|
35
|
+
if (!Number.isFinite(v)) continue;
|
|
36
|
+
seen = true;
|
|
37
|
+
if (v < min) min = v;
|
|
38
|
+
if (v > max) max = v;
|
|
39
|
+
}
|
|
40
|
+
return seen ? [min, max] : [0, 0];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The `q`-quantile of an ALREADY ASCENDING-SORTED, all-finite array, by linear
|
|
45
|
+
* interpolation between order statistics (the R-7 / `d3.quantile` definition).
|
|
46
|
+
*
|
|
47
|
+
* Exported for callers that already hold a sorted array — {@link durationStats} sorts
|
|
48
|
+
* once and reads several quantiles off it. Use {@link quantile} when the input is not
|
|
49
|
+
* known to be sorted.
|
|
50
|
+
*/
|
|
51
|
+
export function quantileSorted(sorted: readonly number[], q: number): number {
|
|
52
|
+
const n = sorted.length;
|
|
53
|
+
if (n === 0) return 0;
|
|
54
|
+
if (n === 1) return sorted[0] as number;
|
|
55
|
+
const clamped = q < 0 ? 0 : q > 1 ? 1 : q;
|
|
56
|
+
const pos = clamped * (n - 1);
|
|
57
|
+
const lo = Math.floor(pos);
|
|
58
|
+
const hi = Math.ceil(pos);
|
|
59
|
+
const loValue = sorted[lo] as number;
|
|
60
|
+
if (lo === hi) return loValue;
|
|
61
|
+
const hiValue = sorted[hi] as number;
|
|
62
|
+
return loValue + (hiValue - loValue) * (pos - lo);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The `q`-quantile of `values` (0 ≤ `q` ≤ 1; out-of-range values are clamped).
|
|
67
|
+
*
|
|
68
|
+
* Sorts a COPY, so the caller's array is untouched. Non-finite entries are dropped;
|
|
69
|
+
* an empty (or all-non-finite) input answers `0`.
|
|
70
|
+
*/
|
|
71
|
+
export function quantile(values: readonly number[], q: number): number {
|
|
72
|
+
return quantileSorted(finiteOnly(values).sort(ascending), q);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Map `value` from `domain` onto `range`, clamped to the range's endpoints.
|
|
77
|
+
*
|
|
78
|
+
* The workhorse behind "frequency → stroke width" and "share → bar length". A
|
|
79
|
+
* DEGENERATE domain (`d0 === d1`, e.g. a graph whose every edge has the same count)
|
|
80
|
+
* answers `range[0]`: with a zero-width domain every input sits at the domain floor, so
|
|
81
|
+
* the range floor is the consistent answer — and it is what the clamped interpolation
|
|
82
|
+
* gives for any `value <= d0`. A caller that wants a different neutral (the widest
|
|
83
|
+
* stroke, say) special-cases it explicitly rather than relying on a surprise here.
|
|
84
|
+
*
|
|
85
|
+
* A descending domain (`d0 > d1`) is honoured — it simply inverts the mapping.
|
|
86
|
+
*/
|
|
87
|
+
export function clampWidth(
|
|
88
|
+
value: number,
|
|
89
|
+
domain: readonly [number, number],
|
|
90
|
+
range: readonly [number, number],
|
|
91
|
+
): number {
|
|
92
|
+
const [d0, d1] = domain;
|
|
93
|
+
const [r0, r1] = range;
|
|
94
|
+
if (!Number.isFinite(value)) return r0;
|
|
95
|
+
const span = d1 - d0;
|
|
96
|
+
if (span === 0) return r0;
|
|
97
|
+
const t = (value - d0) / span;
|
|
98
|
+
const clamped = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
99
|
+
return r0 + (r1 - r0) * clamped;
|
|
100
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework-free event-log and process-graph model — RM-049.
|
|
3
|
+
*
|
|
4
|
+
* Every downstream item in the process-mining track (RM-050 abstraction, RM-051 the
|
|
5
|
+
* process map, RM-052 the variant explorer, RM-053 the case table, RM-054 the coverage
|
|
6
|
+
* strip) reads these shapes, so they are the wave's frozen contract. Keep additions
|
|
7
|
+
* additive: a required field added here is a breaking change for five items at once.
|
|
8
|
+
*
|
|
9
|
+
* NOTHING in this module — or anywhere under `src/core/` — may import React, React Flow,
|
|
10
|
+
* visx, d3 or an `@elabs-ai/components-*` package. See `.claude/rules/process-components.md`
|
|
11
|
+
* and `pnpm process:reuse:check`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One raw row of an event log, before normalization.
|
|
16
|
+
*
|
|
17
|
+
* `timestamp` accepts the three shapes tabular sources actually produce — an ISO string,
|
|
18
|
+
* an epoch number, or a `Date` — because the adapters hand rows through unchanged and a
|
|
19
|
+
* consumer should not have to pre-convert. {@link normalizeLog} resolves all three to
|
|
20
|
+
* epoch milliseconds.
|
|
21
|
+
*/
|
|
22
|
+
export interface EventRow {
|
|
23
|
+
/** Case (process instance) this event belongs to. */
|
|
24
|
+
caseId: string;
|
|
25
|
+
/** Activity name. This is the node identity in the discovered graph. */
|
|
26
|
+
activity: string;
|
|
27
|
+
/** When the event completed (or, for a `lifecycle: "start"` row, when it started). */
|
|
28
|
+
timestamp: string | number | Date;
|
|
29
|
+
/**
|
|
30
|
+
* Optional explicit start of an interval event. Ignored when the row is one half of a
|
|
31
|
+
* `lifecycle` pair — the paired `"start"` row wins, because it is the observed value.
|
|
32
|
+
*/
|
|
33
|
+
startTimestamp?: string | number | Date;
|
|
34
|
+
/** Who or what executed the event (a user, a queue, a system). */
|
|
35
|
+
resource?: string;
|
|
36
|
+
/** Lifecycle transition. Absent means the row is an atomic (already-complete) event. */
|
|
37
|
+
lifecycle?: "start" | "complete";
|
|
38
|
+
/** Free-form event-level attributes carried through normalization untouched. */
|
|
39
|
+
attributes?: Record<string, string | number | boolean | null>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A raw event log: the rows, plus optional per-case attributes keyed by `caseId`. */
|
|
43
|
+
export interface EventLog {
|
|
44
|
+
events: EventRow[];
|
|
45
|
+
caseAttributes?: Record<string, Record<string, unknown>>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Summary statistics over a set of duration samples, in milliseconds.
|
|
50
|
+
*
|
|
51
|
+
* All seven members are always present; an empty sample set yields zeros rather than
|
|
52
|
+
* `null`, so a renderer never has to branch on absence.
|
|
53
|
+
*/
|
|
54
|
+
export interface DurationStats {
|
|
55
|
+
min: number;
|
|
56
|
+
max: number;
|
|
57
|
+
mean: number;
|
|
58
|
+
median: number;
|
|
59
|
+
p90: number;
|
|
60
|
+
sum: number;
|
|
61
|
+
/** Mean after discarding the lowest and highest 10% of samples. */
|
|
62
|
+
trimmedMean: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Per-activity aggregates in a discovered graph. */
|
|
66
|
+
export interface ActivityStats {
|
|
67
|
+
/** Stable node identity — the activity name. */
|
|
68
|
+
id: string;
|
|
69
|
+
/** Human-readable label. Equal to `id` unless a caller relabels the graph. */
|
|
70
|
+
label: string;
|
|
71
|
+
/** Total occurrences across all cases. */
|
|
72
|
+
instances: number;
|
|
73
|
+
/** Number of distinct cases the activity occurs in at least once. */
|
|
74
|
+
cases: number;
|
|
75
|
+
/** True when the activity starts at least one case. */
|
|
76
|
+
isStart: boolean;
|
|
77
|
+
/** True when the activity ends at least one case. */
|
|
78
|
+
isEnd: boolean;
|
|
79
|
+
/** Distribution of the activity's own execution durations. */
|
|
80
|
+
duration: DurationStats;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Per-edge aggregates in a discovered graph (a directly-follows relation). */
|
|
84
|
+
export interface TransitionStats {
|
|
85
|
+
source: string;
|
|
86
|
+
target: string;
|
|
87
|
+
/** Total occurrences of the directly-follows pair across all cases. */
|
|
88
|
+
count: number;
|
|
89
|
+
/** Number of distinct cases the pair occurs in at least once. */
|
|
90
|
+
caseCount: number;
|
|
91
|
+
/** Distribution of the flow time between the two activities. */
|
|
92
|
+
duration: DurationStats;
|
|
93
|
+
/** `source === target`. */
|
|
94
|
+
isSelfLoop: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Whether the edge points backwards in the laid-out graph. Discovery does not lay out,
|
|
97
|
+
* so this is always `false` here; a layout pass sets it (RM-044).
|
|
98
|
+
*/
|
|
99
|
+
isBackEdge: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A directly-follows graph plus its totals. */
|
|
103
|
+
export interface ProcessGraph {
|
|
104
|
+
activities: ActivityStats[];
|
|
105
|
+
transitions: TransitionStats[];
|
|
106
|
+
/** Activity name → number of cases that start with it. */
|
|
107
|
+
startActivities: Record<string, number>;
|
|
108
|
+
/** Activity name → number of cases that end with it. */
|
|
109
|
+
endActivities: Record<string, number>;
|
|
110
|
+
totals: { cases: number; events: number; variants: number };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One distinct activity sequence, with the cases that follow it. */
|
|
114
|
+
export interface Variant {
|
|
115
|
+
/** Stable, reproducible id derived from the sequence — see `variantId`. */
|
|
116
|
+
id: string;
|
|
117
|
+
sequence: string[];
|
|
118
|
+
/** Number of cases following this sequence. */
|
|
119
|
+
count: number;
|
|
120
|
+
/** `count / totalCases`. */
|
|
121
|
+
share: number;
|
|
122
|
+
/** Running share across the descending-frequency order — monotonically non-decreasing. */
|
|
123
|
+
cumulativeShare: number;
|
|
124
|
+
/** Case ids following this sequence, in first-appearance order. */
|
|
125
|
+
caseIds: string[];
|
|
126
|
+
/** Distribution of the end-to-end case durations of `caseIds`. */
|
|
127
|
+
duration: DurationStats;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** How an edge or node frequency is expressed to the reader. */
|
|
131
|
+
export type FrequencyMode =
|
|
132
|
+
| "absolute"
|
|
133
|
+
| "absolute_case"
|
|
134
|
+
| "relative"
|
|
135
|
+
| "relative_case"
|
|
136
|
+
| "relative_antecedent"
|
|
137
|
+
| "relative_consequent"
|
|
138
|
+
| "max_repetitions";
|
|
139
|
+
|
|
140
|
+
/** Which member of a {@link DurationStats} a performance view reads. */
|
|
141
|
+
export type PerformanceAgg = "median" | "mean" | "min" | "max" | "sum" | "p90" | "trimmed_mean";
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Which elapsed time an edge measures.
|
|
145
|
+
*
|
|
146
|
+
* - `idle_time` — from the source's completion to the target's start (waiting time).
|
|
147
|
+
* - `inter_start_time` — from the source's start to the target's start (cycle time).
|
|
148
|
+
*
|
|
149
|
+
* For atomic events the two coincide, because start and completion are the same instant.
|
|
150
|
+
*/
|
|
151
|
+
export type FlowTime = "idle_time" | "inter_start_time";
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { discoverGraph } from "../discover-graph";
|
|
4
|
+
import { normalizeLog } from "../event-log";
|
|
5
|
+
import { extractVariants } from "../extract-variants";
|
|
6
|
+
import fixture from "../fixtures/order-to-cash-small.json";
|
|
7
|
+
import type { AnyLog } from "../event-log";
|
|
8
|
+
import type { EventLog } from "../types";
|
|
9
|
+
import { createProcessWorker, type ProcessWorkerLike } from "./create-process-worker";
|
|
10
|
+
import { handleProcessRequest, type ProcessWorkerResponse } from "./process-worker";
|
|
11
|
+
|
|
12
|
+
const orderToCash = fixture as EventLog;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A stand-in for `Worker` that speaks the REAL protocol: it receives the same request
|
|
16
|
+
* object `postMessage` would clone, answers it asynchronously, and delivers the answer as
|
|
17
|
+
* a `{ data }` message event to the listeners the handle registered.
|
|
18
|
+
*
|
|
19
|
+
* This is deliberately not a spy. It exercises the message plumbing — id correlation,
|
|
20
|
+
* listener registration, the `{ data }` unwrap — which is the only part of the worker path
|
|
21
|
+
* that does not also run inline.
|
|
22
|
+
*/
|
|
23
|
+
class FakeWorker implements ProcessWorkerLike {
|
|
24
|
+
readonly received: unknown[] = [];
|
|
25
|
+
terminated = false;
|
|
26
|
+
private readonly listeners = new Map<string, ((event: unknown) => void)[]>();
|
|
27
|
+
|
|
28
|
+
constructor(private readonly options: { garbleFirst?: boolean } = {}) {}
|
|
29
|
+
|
|
30
|
+
postMessage(message: unknown): void {
|
|
31
|
+
this.received.push(message);
|
|
32
|
+
const response = handleProcessRequest(message as Parameters<typeof handleProcessRequest>[0]);
|
|
33
|
+
// A real worker answers on a later task; resolving synchronously here would hide an
|
|
34
|
+
// ordering bug the moment one appeared.
|
|
35
|
+
void Promise.resolve().then(() => {
|
|
36
|
+
if (this.terminated) return;
|
|
37
|
+
if (this.options.garbleFirst === true && this.received.length === 1) {
|
|
38
|
+
// Something that is not a response at all — the handle must ignore it and stay
|
|
39
|
+
// waiting rather than settling the promise with rubbish.
|
|
40
|
+
this.emit("message", { data: { nonsense: true } });
|
|
41
|
+
}
|
|
42
|
+
this.emit("message", { data: response });
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
terminate(): void {
|
|
47
|
+
this.terminated = true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
addEventListener(type: "message" | "error" | "messageerror", listener: (e: unknown) => void) {
|
|
51
|
+
const bucket = this.listeners.get(type);
|
|
52
|
+
if (bucket === undefined) this.listeners.set(type, [listener]);
|
|
53
|
+
else bucket.push(listener);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
emit(type: string, event: unknown): void {
|
|
57
|
+
for (const listener of this.listeners.get(type) ?? []) listener(event);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("createProcessWorker degrades to the calling thread", () => {
|
|
62
|
+
it("runs inline in an environment with no Worker, and still answers correctly", async () => {
|
|
63
|
+
// The fallback here is ENVIRONMENT-DRIVEN, not simulated: jsdom implements no `Worker`
|
|
64
|
+
// constructor, so this is the same code path a server render or an embedded webview
|
|
65
|
+
// takes. Asserted rather than assumed, because the whole test is vacuous otherwise.
|
|
66
|
+
expect(typeof Worker).toBe("undefined");
|
|
67
|
+
|
|
68
|
+
const handle = createProcessWorker();
|
|
69
|
+
expect(handle.inline).toBe(true);
|
|
70
|
+
expect(await handle.discover(orderToCash)).toEqual(discoverGraph(orderToCash));
|
|
71
|
+
expect(await handle.variants(orderToCash)).toEqual(extractVariants(orderToCash));
|
|
72
|
+
handle.terminate();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("falls back when constructing the worker throws", async () => {
|
|
76
|
+
let attempts = 0;
|
|
77
|
+
const handle = createProcessWorker({
|
|
78
|
+
createWorker: () => {
|
|
79
|
+
attempts += 1;
|
|
80
|
+
throw new Error("no worker here");
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
expect(handle.inline).toBe(false); // optimistic until the first request proves otherwise
|
|
84
|
+
expect(await handle.discover(orderToCash)).toEqual(discoverGraph(orderToCash));
|
|
85
|
+
expect(handle.inline).toBe(true);
|
|
86
|
+
expect(attempts).toBe(1);
|
|
87
|
+
// Degraded for good — it does not retry the broken constructor on every call.
|
|
88
|
+
expect(await handle.variants(orderToCash)).toEqual(extractVariants(orderToCash));
|
|
89
|
+
expect(attempts).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("falls back when postMessage throws, without losing the in-flight request", async () => {
|
|
93
|
+
const handle = createProcessWorker({
|
|
94
|
+
createWorker: () => ({
|
|
95
|
+
postMessage() {
|
|
96
|
+
throw new Error("detached");
|
|
97
|
+
},
|
|
98
|
+
terminate() {},
|
|
99
|
+
addEventListener() {},
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
expect(await handle.discover(orderToCash)).toEqual(discoverGraph(orderToCash));
|
|
103
|
+
expect(handle.inline).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("answers a request already in flight when the worker errors", async () => {
|
|
107
|
+
const silent: ProcessWorkerLike & { fail?: () => void } = {
|
|
108
|
+
postMessage() {
|
|
109
|
+
// Never answers — the error event is what settles this request.
|
|
110
|
+
},
|
|
111
|
+
terminate() {},
|
|
112
|
+
addEventListener(type, listener) {
|
|
113
|
+
if (type === "error") silent.fail = () => listener(new Error("worker died"));
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
const handle = createProcessWorker({ createWorker: () => silent });
|
|
117
|
+
const pending = handle.discover(orderToCash);
|
|
118
|
+
silent.fail?.();
|
|
119
|
+
expect(await pending).toEqual(discoverGraph(orderToCash));
|
|
120
|
+
expect(handle.inline).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("runs inline on request when asked to, without ever constructing a worker", async () => {
|
|
124
|
+
let constructed = 0;
|
|
125
|
+
const handle = createProcessWorker({
|
|
126
|
+
forceInline: true,
|
|
127
|
+
createWorker: () => {
|
|
128
|
+
constructed += 1;
|
|
129
|
+
return new FakeWorker();
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
expect(handle.inline).toBe(true);
|
|
133
|
+
await handle.discover(orderToCash);
|
|
134
|
+
expect(constructed).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("createProcessWorker over the message protocol", () => {
|
|
139
|
+
it("sends the request across and resolves with the worker's answer", async () => {
|
|
140
|
+
const fake = new FakeWorker();
|
|
141
|
+
const handle = createProcessWorker({ createWorker: () => fake });
|
|
142
|
+
const graph = await handle.discover(orderToCash);
|
|
143
|
+
expect(handle.inline).toBe(false);
|
|
144
|
+
expect(graph).toEqual(discoverGraph(orderToCash));
|
|
145
|
+
expect(fake.received).toEqual([{ id: 1, kind: "discover", log: orderToCash }]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("agrees with the inline path, request for request", async () => {
|
|
149
|
+
const viaWorker = createProcessWorker({ createWorker: () => new FakeWorker() });
|
|
150
|
+
const viaInline = createProcessWorker({ forceInline: true });
|
|
151
|
+
const options = { flowTime: "inter_start_time" } as const;
|
|
152
|
+
|
|
153
|
+
expect(await viaWorker.discover(orderToCash, options)).toEqual(
|
|
154
|
+
await viaInline.discover(orderToCash, options),
|
|
155
|
+
);
|
|
156
|
+
expect(await viaWorker.variants(orderToCash)).toEqual(await viaInline.variants(orderToCash));
|
|
157
|
+
viaWorker.terminate();
|
|
158
|
+
viaInline.terminate();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("keeps concurrent requests apart by id", async () => {
|
|
162
|
+
const fake = new FakeWorker();
|
|
163
|
+
const handle = createProcessWorker({ createWorker: () => fake });
|
|
164
|
+
const [graph, variants] = await Promise.all([
|
|
165
|
+
handle.discover(orderToCash),
|
|
166
|
+
handle.variants(orderToCash),
|
|
167
|
+
]);
|
|
168
|
+
expect(graph).toEqual(discoverGraph(orderToCash));
|
|
169
|
+
expect(variants).toEqual(extractVariants(orderToCash));
|
|
170
|
+
expect(fake.received.map((message) => (message as { id: number }).id)).toEqual([1, 2]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("ignores a message that is not a response", async () => {
|
|
174
|
+
const handle = createProcessWorker({
|
|
175
|
+
createWorker: () => new FakeWorker({ garbleFirst: true }),
|
|
176
|
+
});
|
|
177
|
+
expect(await handle.discover(orderToCash)).toEqual(discoverGraph(orderToCash));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("passes an already-normalized log through untouched", async () => {
|
|
181
|
+
const normalized = normalizeLog(orderToCash);
|
|
182
|
+
const fake = new FakeWorker();
|
|
183
|
+
const handle = createProcessWorker({ createWorker: () => fake });
|
|
184
|
+
expect(await handle.discover(normalized)).toEqual(discoverGraph(normalized));
|
|
185
|
+
// The handle does not normalize, copy or reshape on the way out — `asNormalizedLog` is
|
|
186
|
+
// idempotent and the log crosses `postMessage` as the caller handed it over.
|
|
187
|
+
expect((fake.received[0] as { log: unknown }).log).toBe(normalized);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("rejects when the worker reports a failure", async () => {
|
|
191
|
+
const handle = createProcessWorker({ createWorker: () => new FakeWorker() });
|
|
192
|
+
await expect(handle.discover({ nope: true } as unknown as AnyLog)).rejects.toThrow();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("rejects pending and later work once terminated", async () => {
|
|
196
|
+
const silent: ProcessWorkerLike = {
|
|
197
|
+
postMessage() {},
|
|
198
|
+
terminate() {},
|
|
199
|
+
addEventListener() {},
|
|
200
|
+
};
|
|
201
|
+
const handle = createProcessWorker({ createWorker: () => silent });
|
|
202
|
+
const pending = handle.discover(orderToCash);
|
|
203
|
+
handle.terminate();
|
|
204
|
+
await expect(pending).rejects.toThrow("process worker terminated");
|
|
205
|
+
await expect(handle.variants(orderToCash)).rejects.toThrow("process worker terminated");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("handleProcessRequest", () => {
|
|
210
|
+
it("is the one function both paths run", () => {
|
|
211
|
+
const response = handleProcessRequest({ id: 7, kind: "discover", log: orderToCash });
|
|
212
|
+
expect(response).toEqual({
|
|
213
|
+
id: 7,
|
|
214
|
+
ok: true,
|
|
215
|
+
kind: "discover",
|
|
216
|
+
graph: discoverGraph(orderToCash),
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("honours discover options", () => {
|
|
221
|
+
const response = handleProcessRequest({
|
|
222
|
+
id: 1,
|
|
223
|
+
kind: "discover",
|
|
224
|
+
log: orderToCash,
|
|
225
|
+
options: { flowTime: "inter_start_time" },
|
|
226
|
+
});
|
|
227
|
+
expect(response).toEqual({
|
|
228
|
+
id: 1,
|
|
229
|
+
ok: true,
|
|
230
|
+
kind: "discover",
|
|
231
|
+
graph: discoverGraph(orderToCash, { flowTime: "inter_start_time" }),
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("answers variants", () => {
|
|
236
|
+
expect(handleProcessRequest({ id: 2, kind: "variants", log: orderToCash })).toEqual({
|
|
237
|
+
id: 2,
|
|
238
|
+
ok: true,
|
|
239
|
+
kind: "variants",
|
|
240
|
+
variants: extractVariants(orderToCash),
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("turns a throw into an ok:false response rather than killing the worker", () => {
|
|
245
|
+
const response = handleProcessRequest({
|
|
246
|
+
id: 3,
|
|
247
|
+
kind: "discover",
|
|
248
|
+
log: { nope: true } as unknown as AnyLog,
|
|
249
|
+
});
|
|
250
|
+
expect(response.ok).toBe(false);
|
|
251
|
+
expect((response as Extract<ProcessWorkerResponse, { ok: false }>).error).toBeTruthy();
|
|
252
|
+
// Still answerable afterwards — the failure is per-request, not per-worker.
|
|
253
|
+
expect(handleProcessRequest({ id: 4, kind: "variants", log: orderToCash }).ok).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
});
|