@miragon/modeler-api 1.0.0 → 1.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/README.md +110 -20
- package/dist/chunk-32BMH76T.js +7 -0
- package/dist/chunk-32BMH76T.js.map +1 -0
- package/dist/conformance.cjs +361 -1
- package/dist/conformance.cjs.map +1 -1
- package/dist/conformance.d.cts +66 -6
- package/dist/conformance.d.ts +66 -6
- package/dist/conformance.js +361 -1
- package/dist/conformance.js.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +36 -6
- package/dist/index.d.ts +36 -6
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-HWB2EYDZ.js +0 -7
- package/dist/chunk-HWB2EYDZ.js.map +0 -1
package/README.md
CHANGED
|
@@ -31,19 +31,23 @@ modeler's build — not a downstream integration review.
|
|
|
31
31
|
import type { CollaborativeModeler } from "@miragon/modeler-api";
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
| Law | Requirement
|
|
35
|
-
| --- |
|
|
36
|
-
| L1 | `importText` is **silent**: no change events, undo history erased without emitting — also over existing history and foreign content
|
|
37
|
-
| L2 | `exportText` is **canonical**: deterministic, a byte fixpoint on canonical input, idempotent otherwise
|
|
38
|
-
| L3 | `onContentChanged` fires for **every** user-visible change — element commands, config/axis/title edits, undo/redo — and nothing else; inside a listener `exportText()` already returns the new document
|
|
39
|
-
| L4 | undo directly after an import is a defined **no-op** — never a stale-history mutation
|
|
40
|
-
| L5 | view state survives a re-import; an import leaves the view **fitted** to the content; the readback may be a normalised form of what was set
|
|
41
|
-
| L6 | `exportText` is total after a successful import; foreign content is **preserved** through import → export, never dropped, never a throw
|
|
42
|
-
| L7 |
|
|
34
|
+
| Law | Requirement |
|
|
35
|
+
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
36
|
+
| L1 | `importText` is **silent**: no change events, undo history erased without emitting — also over existing history and foreign content; an interaction open during an import (a label editor with typed input) never commits |
|
|
37
|
+
| L2 | `exportText` is **canonical**: deterministic, a byte fixpoint on canonical input, idempotent otherwise |
|
|
38
|
+
| L3 | `onContentChanged` fires for **every** user-visible change — element commands, config/axis/title edits, undo/redo — and nothing else; inside a listener `exportText()` already returns the new document |
|
|
39
|
+
| L4 | undo directly after an import is a defined **no-op** — never a stale-history mutation |
|
|
40
|
+
| L5 | view state survives a re-import; an import leaves the view **fitted** to the content; the readback may be a normalised form of what was set |
|
|
41
|
+
| L6 | `exportText` is total after a successful import; foreign content is **preserved** through import → export, never dropped, never a throw |
|
|
42
|
+
| L7 | EVERY rejected import leaves no trace — previous document still rendered and exported, no events, undo history untouched — and the modeler **stays usable** |
|
|
43
43
|
|
|
44
44
|
View state is never part of the document: `setViewState` fires no change
|
|
45
45
|
events and `exportText` is independent of it.
|
|
46
46
|
|
|
47
|
+
Calls made before the first import (`exportText()`, a user command) may
|
|
48
|
+
return or throw whatever the modeler defines, but never wedge the instance:
|
|
49
|
+
the first `importText` still renders and resolves as specified.
|
|
50
|
+
|
|
47
51
|
`meta.editable` tells a viewer mount from an editor: a package that ships a
|
|
48
52
|
`NavigatedViewer`/`Viewer` exposes it under the same contract with
|
|
49
53
|
`editable: false` — it imports, exports, reveals and reports view state like
|
|
@@ -57,7 +61,22 @@ and selection events) powers host features like anchored todos, `?element=`
|
|
|
57
61
|
deep links and the selection half of live presence. Ids are ELEMENT ids
|
|
58
62
|
(a label shape resolves to its target) and survive edits to other elements —
|
|
59
63
|
positional ids that renumber when a line is inserted above break every anchor
|
|
60
|
-
below.
|
|
64
|
+
below. The id a session reports is the element's id in the DOCUMENT: after
|
|
65
|
+
`exportText()` → `importText()`, in this instance or any other (a peer, the
|
|
66
|
+
viewer, the next session), the element carries the same id — an element
|
|
67
|
+
created in the session gets an id the serializer writes back, never one that
|
|
68
|
+
lives only in memory. RECOMMENDED: when two peers create different elements
|
|
69
|
+
at the same time, their ids do not collide — random or peer-scoped ids, not a
|
|
70
|
+
per-instance counter such as `event_1`. An id derived from the content (OWM's
|
|
71
|
+
`component_<slug>`) collides only when both users create the same content,
|
|
72
|
+
which the merge has to reconcile anyway.
|
|
73
|
+
|
|
74
|
+
An interaction in progress during an import (an open label editor, a popup
|
|
75
|
+
with typed input) never commits during or because of it (L1): no change
|
|
76
|
+
event, nothing in the imported document or on its undo history, nothing
|
|
77
|
+
applied to an element the import removed. RECOMMENDED: keep the typed input
|
|
78
|
+
and reopen the interaction on the element with the same id when it is still
|
|
79
|
+
there — hosts re-import remote states while the user is typing.
|
|
61
80
|
|
|
62
81
|
## The conformance kit
|
|
63
82
|
|
|
@@ -79,10 +98,14 @@ registerConformance(
|
|
|
79
98
|
configMutations: [{ name: "axis labels", run: (m) => renameEvolutionAxis(m) }], // [] if the notation has none
|
|
80
99
|
undo: (m) => triggerUndo(m),
|
|
81
100
|
redo: (m) => triggerRedo(m),
|
|
101
|
+
probeTexts: [CUT_OFF_SAMPLE], // optional: damaged forms your parser meets, on top of the derived probes
|
|
102
|
+
openInteraction: (m) => typeIntoLabelEditor(m, "typed, not committed"), // open an editor, type, do NOT commit
|
|
103
|
+
interactionState: (m) => readOpenLabelEditor(m), // { elementId, text } | undefined
|
|
82
104
|
// engines that debounce change delivery: make this outlast the debounce
|
|
83
105
|
settle: () => new Promise((r) => setTimeout(r, 50)),
|
|
84
106
|
},
|
|
85
107
|
test,
|
|
108
|
+
{ onSkip: reportSkip, onAdvisory: reportAdvisory }, // optional: both default to console.warn
|
|
86
109
|
);
|
|
87
110
|
```
|
|
88
111
|
|
|
@@ -101,7 +124,50 @@ proves only that it agrees with itself.
|
|
|
101
124
|
a SKIP (`console.warn` by default, `registerConformance(h, test, { onSkip })`
|
|
102
125
|
to route it) instead of passing. An explicit `[]` (`invalidTexts`,
|
|
103
126
|
`foreignTexts`, `configMutations`, `looseTexts`) is a statement that the
|
|
104
|
-
notation has no such form and skips nothing.
|
|
127
|
+
notation has no such form and skips nothing. Without `openInteraction` the
|
|
128
|
+
L1 interaction case reports a skip; with `openInteraction` but without
|
|
129
|
+
`interactionState` only its keep-and-reopen half does.
|
|
130
|
+
|
|
131
|
+
**Every rejection is probed.** `invalidTexts` lists what MUST reject; the kit
|
|
132
|
+
does not take `[]` on trust. The L7 probe case derives damaged forms from
|
|
133
|
+
every canonical text — its first half, its first third, the text without its
|
|
134
|
+
last character, the text followed by a NUL character — plus your optional
|
|
135
|
+
`probeTexts`, and imports each over a document with undo history. A probe may
|
|
136
|
+
import or reject: one that imports must be silent (L1) with `exportText`
|
|
137
|
+
total (L6); one that rejects must leave the document, its element ids, the
|
|
138
|
+
change events and the undo history untouched (L7). After every probe the next
|
|
139
|
+
valid import must render and the next user command must apply. The case runs
|
|
140
|
+
for every harness and never skips.
|
|
141
|
+
|
|
142
|
+
**Calls before the first import.** A fresh instance gets `exportText()` and
|
|
143
|
+
one `mutate` before any document (throws tolerated — what they return is
|
|
144
|
+
unspecified — and a `mutate` that never settles too: the kit waits 2 s for
|
|
145
|
+
it, so a harness that waits for its command's change event still runs); then
|
|
146
|
+
importing the first two canonical texts must render each exactly like a
|
|
147
|
+
fresh instance does, and the next `mutate` must emit and apply.
|
|
148
|
+
|
|
149
|
+
**Interactions open during an import.** `openInteraction` opens an editor on
|
|
150
|
+
an element of `canonicalTexts[0]` and types without committing. The kit then
|
|
151
|
+
imports over it twice — a different document (`canonicalTexts[1]`, the edited
|
|
152
|
+
element is usually gone; a harness with one canonical text reports that leg
|
|
153
|
+
as skipped) and the same document again (the element is still there) — and
|
|
154
|
+
requires no change event, the imported document as the export and an undo
|
|
155
|
+
that changes nothing (L1, L4). `interactionState` lets it check the
|
|
156
|
+
recommended keep-and-reopen on the same-document import.
|
|
157
|
+
|
|
158
|
+
**Recommendations are advisories.** Where the contract only RECOMMENDS
|
|
159
|
+
(collision-free ids across peers, keep-and-reopen of an open interaction), the
|
|
160
|
+
kit reports what it finds as an ADVISORY through `onAdvisory`
|
|
161
|
+
(`console.warn` by default) and never fails: a recommendation becomes a law,
|
|
162
|
+
with a failing case, only in a major release. The collision check imports the
|
|
163
|
+
same canonical text into two fresh instances, runs `mutate` once on each and
|
|
164
|
+
advises when both allocate the same id — unless both new elements carry the
|
|
165
|
+
same name and the id spells it out (its letters and digits contain the
|
|
166
|
+
name's, as `component_new_component` does for "New Component"). Such an id is
|
|
167
|
+
derived from the content and collides for identical content by design, so
|
|
168
|
+
the check reports a skip instead: let `mutate`'s elements differ in content
|
|
169
|
+
(a counter the harness shares across instances, as the reference harness
|
|
170
|
+
does).
|
|
105
171
|
|
|
106
172
|
**View state is compared by readback.** The kit never compares
|
|
107
173
|
`getViewState()` with your `viewState` literal: it sets it, reads the
|
|
@@ -109,18 +175,20 @@ modeler's own (possibly normalised) state back, and requires that readback
|
|
|
109
175
|
to survive a re-import — a diagram-js viewbox that comes back rounded with
|
|
110
176
|
`scale`/`inner`/`outer` is fine.
|
|
111
177
|
|
|
112
|
-
**Element identity is verified through names.** After an unrelated edit
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
178
|
+
**Element identity is verified through names.** After an unrelated edit, and
|
|
179
|
+
after an export → import into the same instance, a fresh instance and the
|
|
180
|
+
viewer, every id the session reported must still exist and, where the
|
|
181
|
+
notation has names, still name the same element; a notation whose elements
|
|
182
|
+
carry no names is verified for id existence only.
|
|
116
183
|
|
|
117
184
|
The kit throws plain `Error`s — no assertion-library or runner dependency.
|
|
118
185
|
Its own CI proves it bites: a conforming reference modeler
|
|
119
186
|
([`test/reference-modeler.ts`](test/reference-modeler.ts) — also the model
|
|
120
|
-
for a harness) passes every case
|
|
121
|
-
historical drift AND per kit case fails exactly the case set that
|
|
122
|
-
the violated law (not every individual assertion has a saboteur;
|
|
123
|
-
does)
|
|
187
|
+
for a harness) passes every case and advises nothing, one deliberately broken
|
|
188
|
+
variant per historical drift AND per kit case fails exactly the case set that
|
|
189
|
+
encodes the violated law (not every individual assertion has a saboteur;
|
|
190
|
+
every case does), and one variant per recommendation fails nothing and
|
|
191
|
+
advises exactly once.
|
|
124
192
|
|
|
125
193
|
## Versioning
|
|
126
194
|
|
|
@@ -135,7 +203,29 @@ The contract is first published as 1.0.0, never 0.x: under 0.x,
|
|
|
135
203
|
additive surface into a lockstep release across all modelers and their
|
|
136
204
|
hosts. A new optional surface (a structured snapshot lane alongside the text
|
|
137
205
|
lane, richer element info) is a minor, and hosts feature-detect it. A new
|
|
138
|
-
required member or a stricter law is a major.
|
|
206
|
+
required member or a stricter law is a major. A minor may still add a case
|
|
207
|
+
that enforces what the contract text already states; a requirement the text
|
|
208
|
+
does not state yet ships as a RECOMMENDATION with an advisory, so no
|
|
209
|
+
conforming modeler turns red in a minor. A case that needs a new optional
|
|
210
|
+
harness field (`openInteraction`) reports a skip until the harness provides
|
|
211
|
+
it, and a check the harness cannot feed conclusively (the collision check
|
|
212
|
+
when both peers' new elements share a name their id spells out) reports a
|
|
213
|
+
skip too; a suite whose `onSkip` throws, to keep every law verified, turns red on
|
|
214
|
+
that upgrade by its own choice, and the skip names what to add or change.
|
|
215
|
+
|
|
216
|
+
### Candidates for 2.0
|
|
217
|
+
|
|
218
|
+
Recommendations today, proposed as hard laws for the next major:
|
|
219
|
+
|
|
220
|
+
- **Collision-free element ids across peers** — two instances that import the
|
|
221
|
+
same document and each create a different element never allocate the same
|
|
222
|
+
id for them; ids derived from the content (OWM's `component_<slug>`) meet
|
|
223
|
+
this by construction and collide only for identical content (today:
|
|
224
|
+
`ElementInfo.id` RECOMMENDED, advisory in the elements collision case).
|
|
225
|
+
- **Keep and reopen an interaction across an import** — an interaction open
|
|
226
|
+
during an import is reopened, with its typed input, on the element with the
|
|
227
|
+
same id when that element is still there (today: L1 RECOMMENDED, advisory in
|
|
228
|
+
the L1 interaction case).
|
|
139
229
|
|
|
140
230
|
## License
|
|
141
231
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @miragon/modeler-api — ONE interface every Miragon modeler implements.\n *\n * The contract encodes, as REQUIREMENTS, the behavioral drift that had to be\n * discovered per modeler against engine internals before it existed:\n * an `importDSL` that emitted change events (rewriting hand-authored files on\n * open), an import that left the undo stack on stale removed shapes, a\n * serializer that was not a byte fixpoint. A host embedding a conforming\n * modeler needs no per-engine suppression tricks — the laws below hold.\n *\n * The conformance test kit (`@miragon/modeler-api/conformance`) turns each\n * law into an executable case the modeler repo runs in its own CI.\n */\n\n/** the semver of THIS contract — a modeler declares the version it implements\n * in `meta.apiVersion`; hosts check compatibility with `apiVersionsCompatible`\n * (same major; while 0.x, the same minor too). The constant IS the\n * package version: release-please rewrites the literal below on every\n * release (release-please-config.json `extra-files`), and the kit's own\n * test pins it to package.json exactly. Modelers pin the npm package; the\n * constant is what travels in `meta`. */\nexport const MODELER_API_VERSION = \"1.1.0\"; // x-release-please-version\n\nexport interface ModelerMeta {\n /** stable notation id, e.g. \"wardley\", \"team-topology\", \"event-storming\" */\n readonly notation: string;\n /** the MODELER_API_VERSION this modeler implements — compatible when the\n * major matches and, while the contract is 0.x, the minor too (pre-1.0\n * minors may break: bump-minor-pre-major) */\n readonly apiVersion: string;\n /**\n * true on an editing mount (the package's Modeler), false on a VIEWER\n * (its NavigatedViewer/Viewer): a viewer never fires `onContentChanged`,\n * accepts no user commands, and still imports, exports, reveals and\n * reports view state exactly like the editor — hosts mount it on\n * read-only surfaces (an MCP-App widget in read-only mode) without\n * per-package knowledge or a command-stack shim.\n */\n readonly editable: boolean;\n}\n\nexport interface ImportResult {\n /** non-fatal notes from the import (unknown lines kept, migrations run) */\n warnings: string[];\n}\n\nexport interface ElementInfo {\n /**\n * stable element id — identical across re-imports of the same text AND\n * untouched by edits to OTHER elements: hosts anchor todos, presence and\n * `?element=` deep links to it, so an id that renumbers when a line is\n * inserted above (positional edge ids) breaks every anchor below it. An\n * element without an id in the text gets one the serializer WRITES BACK.\n *\n * The id a session reports IS the element's id in the document: after\n * `exportText()` → `importText()` — in this instance or in any other one\n * (a peer, the viewer, the next session) — the element carries the same\n * id. An id that lives only in memory anchors nothing: the todo or deep\n * link a host stored against it points nowhere once the text travels.\n *\n * RECOMMENDED: when two peers create different elements at the same time,\n * their ids do not collide. An id allocated regardless of the content —\n * a per-instance counter (`event_1` on both peers) — does: once both edits\n * merge into one document, two elements share one id; random or\n * peer-scoped ids do not. An id derived from the content (OWM's\n * `component_<slug>`) collides only when both users create the same\n * content, which the merge has to reconcile anyway.\n */\n id: string;\n /** human label, when the notation has one */\n name?: string;\n}\n\n/**\n * Optional element surface — what a host needs for anchored todos, `?element=`\n * deep links and the selection half of live presence. Absent = the notation\n * has no addressable element identity. Ids are ELEMENT ids throughout: a\n * shape that only labels another element (diagram-js external labels) is\n * reported and revealed as its target, never under its own label id.\n */\nexport interface ElementsSurface {\n /** every addressable element currently on the canvas */\n list(): ElementInfo[];\n /** select + scroll the element into view; false when the id is unknown.\n * Revealing is view behavior: it delivers a selection, never a change event */\n reveal(id: string): boolean;\n /** the current selection as stable element ids — synchronous, so a host\n * can read it at the moment of a click (a deep link carrying the\n * selection) without caching every delivery */\n selection(): string[];\n /** selection changes as STABLE element ids; returns the unsubscribe */\n onSelection(cb: (ids: string[]) => void): () => void;\n}\n\n/**\n * The collaborative-modeler contract. The LAWS (verified by the conformance\n * kit) are part of the interface:\n *\n * L1 SILENT IMPORT — `importText` never fires `onContentChanged` and erases\n * the undo history without emitting — also when history exists. Hosts\n * re-import remote states on every sync; an import echo would re-export\n * the canonical serialization over half-typed peer text.\n * An interaction in progress when an import runs (an open label editor,\n * a popup with typed input) never commits during or because of the\n * import: no change event comes from it, its input lands neither in the\n * imported document nor on its fresh undo history, and nothing is\n * applied to an element the import removed. RECOMMENDED: keep the typed\n * input and reopen the interaction on the element with the same id when\n * that element is still there after the import — a remote re-import\n * should not cost the user what they were typing.\n * L2 CANONICAL FIXPOINT — `exportText` is deterministic, and for any text t:\n * with c = exportText() after importText(t), importText(c) followed by\n * exportText() returns c byte-for-byte. Canonicalization is idempotent;\n * opening a file never rewrites it.\n * L3 ONE CHANGE EVENT — every user-visible atomic change (element commands\n * AND config/axis/title/style edits that live in the document, AND\n * undo/redo) fires `onContentChanged` at least once, and nothing else\n * does: not `importText`, not undo-history housekeeping, not view or\n * selection changes. When a listener runs, `exportText()` ALREADY\n * returns the post-change serialization — hosts export inside the\n * listener; an emit-before-apply would lose the last edit.\n * L4 DEFINED UNDO ACROSS IMPORTS — undo directly after an `importText` is a\n * no-op: it must never mutate or resurrect shapes from the pre-import\n * document (id coincidence included).\n * L5 VIEW-STATE ROUND-TRIP — `getViewState`/`setViewState` survive a\n * re-import: snapshot before, restore after, viewport unchanged. An\n * import leaves the view FITTED to the content (the defined post-import\n * view, the same for the same document and host — a host that wants to\n * keep the user's viewport across a remote re-import snapshots before\n * and restores after); before the first import `getViewState()` returns\n * `undefined` or a state `setViewState` accepts, and never throws. The\n * readback may be a NORMALISED form of what was set (a diagram-js\n * viewbox comes back rounded, with scale/inner/outer) — what must hold\n * is that setting a readback yields that readback again.\n * L6 EXPORT IS TOTAL AFTER IMPORT — after a successful import (and any\n * sequence of user commands and undos), `exportText` succeeds. Foreign or\n * auxiliary content the modeler does not understand is PRESERVED through\n * import → export (it comes back in the export, canonicalised like the\n * rest), never dropped and never a reason to throw.\n * L7 REJECTED IMPORTS LEAVE NO TRACE — `importText` on non-importable text\n * REJECTS (resolve-with-warnings means \"imported, with notes\"; rejection\n * means \"nothing imported\"). This holds for EVERY rejection, whatever\n * the input (a truncated or half-typed text included): afterwards the\n * previously imported document is still rendered with its elements,\n * `exportText` still returns it, no change event has fired and the undo\n * history is untouched.\n * The modeler stays USABLE: the next valid import renders, the next user\n * command applies. Hosts re-import half-typed peer text on every sync —\n * \"blank (or wedged) until healed\" is not an acceptable failure mode.\n *\n * View state is NEVER part of the document: `setViewState` fires no change\n * events and `exportText` is independent of it.\n */\nexport interface CollaborativeModeler {\n readonly meta: ModelerMeta;\n\n /** mount the canvas into a host element (once, before the first import).\n * The host must be attached to the document with a non-zero size — a\n * detached or 0×0 element gives diagram-js engines NaN viewboxes.\n * Calls made between mounting and the first import (`exportText()`, a\n * user command) return whatever the modeler defines, or throw (only\n * `getViewState` never does, L5), but they never wedge the instance:\n * the first `importText` still behaves exactly as specified */\n attachTo(host: Element): void;\n\n /** parse + render `text` — SILENT per L1, the view fitted to the content\n * per L5; resolves when rendered, REJECTS when nothing was imported (L7).\n * This holds for the first import too, whatever was called before it — a\n * host may export or route a command before its first document arrives */\n importText(text: string): Promise<ImportResult>;\n\n /** the canonical serialization of the current document (L2, L6) */\n exportText(): string;\n\n /** subscribe to user-visible model changes (L3); returns the unsubscribe */\n onContentChanged(cb: () => void): () => void;\n\n /** opaque viewport snapshot (zoom/scroll) — JSON-serializable (L5) */\n getViewState(): unknown;\n setViewState(state: unknown): void;\n\n readonly elements?: ElementsSurface;\n\n /** tear down DOM + listeners; further calls on the instance are undefined\n * behavior, but a second destroy() must not throw */\n destroy(): void;\n}\n"],"mappings":";AAqBO,IAAM,sBAAsB;","names":[]}
|
package/dist/conformance.cjs
CHANGED
|
@@ -28,7 +28,7 @@ __export(conformance_exports, {
|
|
|
28
28
|
module.exports = __toCommonJS(conformance_exports);
|
|
29
29
|
|
|
30
30
|
// src/index.ts
|
|
31
|
-
var MODELER_API_VERSION = "1.
|
|
31
|
+
var MODELER_API_VERSION = "1.1.0";
|
|
32
32
|
|
|
33
33
|
// src/conformance.ts
|
|
34
34
|
function attachedHost(width = 800, height = 600) {
|
|
@@ -70,6 +70,19 @@ function assertFinite(state, when) {
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
var defaultSettle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
73
|
+
var PRE_IMPORT_CALL_BOUND_MS = 2e3;
|
|
74
|
+
async function toleratedCall(action, ms) {
|
|
75
|
+
let timer;
|
|
76
|
+
const call = Promise.resolve().then(action).catch(() => void 0);
|
|
77
|
+
const bound = new Promise((resolve) => {
|
|
78
|
+
timer = setTimeout(resolve, ms);
|
|
79
|
+
});
|
|
80
|
+
try {
|
|
81
|
+
await Promise.race([call, bound]);
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
73
86
|
async function changeEvents(harness, modeler, action) {
|
|
74
87
|
const settle = harness.settle ?? defaultSettle;
|
|
75
88
|
let calls = 0;
|
|
@@ -86,12 +99,42 @@ async function changeEvents(harness, modeler, action) {
|
|
|
86
99
|
}
|
|
87
100
|
return { calls, exportAtEmit };
|
|
88
101
|
}
|
|
102
|
+
var lettersAndDigits = (value) => value.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
89
103
|
var secondText = (h) => h.canonicalTexts[1] ?? h.canonicalTexts[0] ?? "";
|
|
90
104
|
var mutationsOf = (h) => [
|
|
91
105
|
{ name: "mutate (element command)", run: (m) => h.mutate(m) },
|
|
92
106
|
...h.configMutations ?? []
|
|
93
107
|
];
|
|
94
108
|
var sortedIds = (m) => m.elements ? [...m.elements.list().map((e) => e.id)].sort() : void 0;
|
|
109
|
+
var namesById = (m) => m.elements ? new Map(m.elements.list().map((e) => [e.id, e.name])) : void 0;
|
|
110
|
+
async function importOrFail(m, text, failure) {
|
|
111
|
+
await m.importText(text).catch((e) => fail(`${failure}: ${String(e)}`));
|
|
112
|
+
}
|
|
113
|
+
var describeProbe = (probe) => {
|
|
114
|
+
const excerpt = probe.text.length > 40 ? `${probe.text.slice(0, 40)}\u2026` : probe.text;
|
|
115
|
+
return `${probe.label} (${JSON.stringify(excerpt)})`;
|
|
116
|
+
};
|
|
117
|
+
function probesOf(h) {
|
|
118
|
+
const probes = [];
|
|
119
|
+
h.canonicalTexts.forEach((text, index) => {
|
|
120
|
+
const source = `canonicalTexts[${index}]`;
|
|
121
|
+
const codePoints = Array.from(text);
|
|
122
|
+
const cut = (length) => codePoints.slice(0, length).join("");
|
|
123
|
+
probes.push(
|
|
124
|
+
{ label: `the first half of ${source}`, text: cut(Math.floor(codePoints.length / 2)) },
|
|
125
|
+
{ label: `the first third of ${source}`, text: cut(Math.floor(codePoints.length / 3)) },
|
|
126
|
+
{ label: `${source} without its last character`, text: cut(codePoints.length - 1) },
|
|
127
|
+
{ label: `${source} followed by a NUL character`, text: `${text}\0` }
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
(h.probeTexts ?? []).forEach((text, index) => probes.push({ label: `probeTexts[${index}]`, text }));
|
|
131
|
+
const seen = /* @__PURE__ */ new Set();
|
|
132
|
+
return probes.filter((probe) => {
|
|
133
|
+
if (seen.has(probe.text)) return false;
|
|
134
|
+
seen.add(probe.text);
|
|
135
|
+
return true;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
95
138
|
async function importContentText(h, m) {
|
|
96
139
|
for (const text of h.canonicalTexts) {
|
|
97
140
|
await m.importText(text);
|
|
@@ -117,6 +160,56 @@ function conformanceCases() {
|
|
|
117
160
|
}
|
|
118
161
|
}
|
|
119
162
|
},
|
|
163
|
+
{
|
|
164
|
+
name: "calls before the first import never wedge the instance",
|
|
165
|
+
async run(h) {
|
|
166
|
+
const m = await h.create();
|
|
167
|
+
let control;
|
|
168
|
+
try {
|
|
169
|
+
try {
|
|
170
|
+
m.exportText();
|
|
171
|
+
} catch {
|
|
172
|
+
}
|
|
173
|
+
await toleratedCall(() => h.mutate(m), PRE_IMPORT_CALL_BOUND_MS);
|
|
174
|
+
await (h.settle ?? defaultSettle)();
|
|
175
|
+
control = await h.create();
|
|
176
|
+
const texts = h.canonicalTexts.length > 0 ? h.canonicalTexts.slice(0, 2) : [""];
|
|
177
|
+
let last = "";
|
|
178
|
+
for (const [index, text] of texts.entries()) {
|
|
179
|
+
const source = `canonicalTexts[${index}]`;
|
|
180
|
+
await control.importText(text);
|
|
181
|
+
const expected = exportOrFail(control, `after importing ${source} into a fresh instance`);
|
|
182
|
+
await importOrFail(
|
|
183
|
+
m,
|
|
184
|
+
text,
|
|
185
|
+
`importText(${source}) rejected on an instance that ran exportText() and a user command before its first import (wedged)`
|
|
186
|
+
);
|
|
187
|
+
last = exportOrFail(m, `after importing ${source} into an instance used before its first import`);
|
|
188
|
+
if (last !== expected) {
|
|
189
|
+
fail(
|
|
190
|
+
`importText(${source}) on an instance that ran exportText() and a user command before its first import did not render that document:
|
|
191
|
+
--- expected (a fresh instance's export)
|
|
192
|
+
${expected}
|
|
193
|
+
--- got
|
|
194
|
+
${last}`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const { calls } = await changeEvents(h, m, () => h.mutate(m));
|
|
199
|
+
if (calls === 0) {
|
|
200
|
+
fail(
|
|
201
|
+
"a user mutation fired no onContentChanged on an instance used before its first import (wedged)"
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (exportOrFail(m, "after a user mutation on an instance used before its first import") === last) {
|
|
205
|
+
fail("a user mutation did not apply on an instance used before its first import (wedged)");
|
|
206
|
+
}
|
|
207
|
+
} finally {
|
|
208
|
+
m.destroy();
|
|
209
|
+
control?.destroy();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
},
|
|
120
213
|
{
|
|
121
214
|
name: "L1: importText is silent (no change events, for canonical, loose and foreign inputs)",
|
|
122
215
|
async run(h) {
|
|
@@ -151,6 +244,87 @@ function conformanceCases() {
|
|
|
151
244
|
}
|
|
152
245
|
}
|
|
153
246
|
},
|
|
247
|
+
{
|
|
248
|
+
name: "L1: an interaction open during importText never commits",
|
|
249
|
+
async run(h) {
|
|
250
|
+
if (!h.openInteraction) {
|
|
251
|
+
return skip(
|
|
252
|
+
"openInteraction is undefined \u2014 an interaction open during an import (a label editor with typed input) was not verified"
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const openInteraction = h.openInteraction;
|
|
256
|
+
const first = h.canonicalTexts[0] ?? "";
|
|
257
|
+
const second = h.canonicalTexts[1];
|
|
258
|
+
const m = await h.create();
|
|
259
|
+
try {
|
|
260
|
+
const legs = [];
|
|
261
|
+
if (second !== void 0) {
|
|
262
|
+
await m.importText(second);
|
|
263
|
+
const expected = exportOrFail(m, "after importing canonicalTexts[1]");
|
|
264
|
+
legs.push({ text: second, expected, what: "a different document (canonicalTexts[1])" });
|
|
265
|
+
}
|
|
266
|
+
await m.importText(first);
|
|
267
|
+
const firstExport = exportOrFail(m, "after importing canonicalTexts[0]");
|
|
268
|
+
legs.push({
|
|
269
|
+
text: first,
|
|
270
|
+
expected: firstExport,
|
|
271
|
+
what: "the same document again (canonicalTexts[0])"
|
|
272
|
+
});
|
|
273
|
+
let advice;
|
|
274
|
+
for (const [index, leg] of legs.entries()) {
|
|
275
|
+
await m.importText(first);
|
|
276
|
+
await openInteraction(m);
|
|
277
|
+
await (h.settle ?? defaultSettle)();
|
|
278
|
+
const opened = h.interactionState?.(m);
|
|
279
|
+
if (h.interactionState && opened === void 0) {
|
|
280
|
+
fail("interactionState reports no interaction after openInteraction \u2014 the case proves nothing");
|
|
281
|
+
}
|
|
282
|
+
const { calls } = await changeEvents(h, m, () => m.importText(leg.text).then(() => void 0));
|
|
283
|
+
if (calls > 0) {
|
|
284
|
+
fail(
|
|
285
|
+
`importing ${leg.what} over an open interaction fired onContentChanged ${calls}x \u2014 the interaction committed during or because of the import (if every import emits, see the L1 cases first)`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const reopened = h.interactionState?.(m);
|
|
289
|
+
if (exportOrFail(m, `after importing ${leg.what} over an open interaction`) !== leg.expected) {
|
|
290
|
+
fail(
|
|
291
|
+
`importing ${leg.what} over an open interaction did not leave that document \u2014 the interaction's input was applied to it`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (h.undo) {
|
|
295
|
+
await h.undo(m);
|
|
296
|
+
await (h.settle ?? defaultSettle)();
|
|
297
|
+
if (exportOrFail(m, `after an undo that followed importing ${leg.what}`) !== leg.expected) {
|
|
298
|
+
fail(
|
|
299
|
+
`undo after importing ${leg.what} over an open interaction changed the document \u2014 the interaction was committed onto the fresh undo history (L4)`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const sameDocument = index === legs.length - 1;
|
|
304
|
+
const kept = reopened?.elementId === opened?.elementId && reopened?.text === opened?.text;
|
|
305
|
+
if (sameDocument && opened !== void 0 && !kept) {
|
|
306
|
+
const input = opened.text === void 0 ? "" : ` with the input ${JSON.stringify(opened.text)}`;
|
|
307
|
+
const after = reopened === void 0 ? "closed it" : `left ${JSON.stringify(reopened)} open`;
|
|
308
|
+
advice = `an interaction on '${opened.elementId}'${input} did not survive a re-import of the same document (the import ${after}) \u2014 RECOMMENDED: keep the typed input and reopen the interaction on the element with the same id, so a remote re-import does not cost the user what they were typing`;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const unverified = [
|
|
312
|
+
...second === void 0 ? [
|
|
313
|
+
"canonicalTexts holds one text \u2014 importing a DIFFERENT document over an open interaction was not verified (add a second canonical text)"
|
|
314
|
+
] : [],
|
|
315
|
+
...h.interactionState ? [] : [
|
|
316
|
+
"interactionState is undefined \u2014 the RECOMMENDED keep-and-reopen of an interaction across an import was not checked (pass interactionState to check it)"
|
|
317
|
+
]
|
|
318
|
+
];
|
|
319
|
+
return {
|
|
320
|
+
...unverified.length > 0 ? { skipped: unverified.join("; ") } : {},
|
|
321
|
+
...advice === void 0 ? {} : { advisories: [advice] }
|
|
322
|
+
};
|
|
323
|
+
} finally {
|
|
324
|
+
m.destroy();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
},
|
|
154
328
|
{
|
|
155
329
|
name: "L2: canonical texts are byte fixpoints of import \u2192 export",
|
|
156
330
|
async run(h) {
|
|
@@ -473,6 +647,110 @@ ${out}`);
|
|
|
473
647
|
}
|
|
474
648
|
}
|
|
475
649
|
},
|
|
650
|
+
{
|
|
651
|
+
name: "elements (when present): ids and names survive export \u2192 import, in this and in a fresh instance",
|
|
652
|
+
async run(h) {
|
|
653
|
+
const m = await h.create();
|
|
654
|
+
const others = [];
|
|
655
|
+
try {
|
|
656
|
+
await m.importText(h.canonicalTexts[0] ?? "");
|
|
657
|
+
const elements = m.elements;
|
|
658
|
+
if (!elements)
|
|
659
|
+
return skip("the modeler exposes no elements surface (optional) \u2014 nothing to verify");
|
|
660
|
+
await h.mutate(m);
|
|
661
|
+
await (h.settle ?? defaultSettle)();
|
|
662
|
+
const session = new Map(elements.list().map((e) => [e.id, e.name]));
|
|
663
|
+
const exported = exportOrFail(m, "after a user mutation");
|
|
664
|
+
const expectSessionIds = (target, where) => {
|
|
665
|
+
const after = namesById(target);
|
|
666
|
+
if (!after) fail(`${where} exposes no elements surface \u2014 the editor does`);
|
|
667
|
+
for (const [id, name] of session) {
|
|
668
|
+
if (!after.has(id)) {
|
|
669
|
+
fail(
|
|
670
|
+
`element id '${id}' is gone after export \u2192 import ${where} \u2014 the id a session reports must be the element's id in the document (write it back)`
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
if (name !== void 0 && (after.get(id) ?? "") !== name) {
|
|
674
|
+
fail(
|
|
675
|
+
`element id '${id}' names a different element after export \u2192 import ${where} ('${name}' \u2192 '${String(after.get(id))}')`
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
await importOrFail(m, exported, "importText rejected the modeler's own export");
|
|
681
|
+
expectSessionIds(m, "into the same instance");
|
|
682
|
+
const fresh = await h.create();
|
|
683
|
+
others.push(fresh);
|
|
684
|
+
await importOrFail(fresh, exported, "a fresh instance rejected the modeler's own export");
|
|
685
|
+
expectSessionIds(fresh, "into a fresh instance");
|
|
686
|
+
if (h.createReadonly) {
|
|
687
|
+
const viewer = await h.createReadonly();
|
|
688
|
+
others.push(viewer);
|
|
689
|
+
await importOrFail(viewer, exported, "the viewer rejected the editor's export");
|
|
690
|
+
if (viewer.elements) expectSessionIds(viewer, "into the viewer");
|
|
691
|
+
}
|
|
692
|
+
} finally {
|
|
693
|
+
m.destroy();
|
|
694
|
+
for (const other of others) other.destroy();
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
name: "elements (when present, recommended): ids created on two peers at the same time do not collide",
|
|
700
|
+
async run(h) {
|
|
701
|
+
const text = h.canonicalTexts[0] ?? "";
|
|
702
|
+
const peers = [];
|
|
703
|
+
try {
|
|
704
|
+
const created = [];
|
|
705
|
+
for (let count = 0; count < 2; count += 1) {
|
|
706
|
+
const peer = await h.create();
|
|
707
|
+
peers.push(peer);
|
|
708
|
+
await peer.importText(text);
|
|
709
|
+
if (!peer.elements)
|
|
710
|
+
return skip("the modeler exposes no elements surface (optional) \u2014 nothing to verify");
|
|
711
|
+
const before = new Set(peer.elements.list().map((e) => e.id));
|
|
712
|
+
await h.mutate(peer);
|
|
713
|
+
await (h.settle ?? defaultSettle)();
|
|
714
|
+
const added = new Map(
|
|
715
|
+
peer.elements.list().filter((e) => !before.has(e.id)).map((e) => [e.id, e.name])
|
|
716
|
+
);
|
|
717
|
+
if (added.size === 0) {
|
|
718
|
+
fail(
|
|
719
|
+
"the harness mutate must ADD an element (the elements cases compare the ids two instances allocate)"
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
created.push(added);
|
|
723
|
+
}
|
|
724
|
+
const [
|
|
725
|
+
onFirst = /* @__PURE__ */ new Map(),
|
|
726
|
+
onSecond = /* @__PURE__ */ new Map()
|
|
727
|
+
] = created;
|
|
728
|
+
const shared = [...onFirst.keys()].filter((id) => onSecond.has(id));
|
|
729
|
+
const derivedFromContent = (id) => {
|
|
730
|
+
const name = onFirst.get(id) ?? "";
|
|
731
|
+
if (name === "" || name !== (onSecond.get(id) ?? "")) return false;
|
|
732
|
+
const spelled = lettersAndDigits(name);
|
|
733
|
+
return spelled !== "" && lettersAndDigits(id).includes(spelled);
|
|
734
|
+
};
|
|
735
|
+
const allocated = shared.filter((id) => !derivedFromContent(id));
|
|
736
|
+
const quoted = (ids) => ids.map((id) => `'${id}'`).join(", ");
|
|
737
|
+
if (allocated.length > 0) {
|
|
738
|
+
return {
|
|
739
|
+
advisories: [
|
|
740
|
+
`two instances that imported the same document and each created one element, not of the same name, allocated the same id (${quoted(allocated)}) \u2014 RECOMMENDED: allocate ids for new elements that do not collide across peers (random or peer-scoped, not a per-instance counter); once both edits merge, two elements share one id`
|
|
741
|
+
]
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
if (shared.length > 0) {
|
|
745
|
+
return skip(
|
|
746
|
+
`both instances created an element of the same name and allocated the same id, one derived from that name (${quoted(shared)}) \u2014 such ids collide for identical content by design, so the check is inconclusive: let mutate's elements differ in content (a counter the harness shares across instances)`
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
} finally {
|
|
750
|
+
for (const peer of peers) peer.destroy();
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
},
|
|
476
754
|
{
|
|
477
755
|
name: "L6: export is total after imports, user mutations and undo",
|
|
478
756
|
async run(h) {
|
|
@@ -594,6 +872,84 @@ ${out}`);
|
|
|
594
872
|
}
|
|
595
873
|
}
|
|
596
874
|
},
|
|
875
|
+
{
|
|
876
|
+
name: "L7: every rejection of a derived probe leaves no trace",
|
|
877
|
+
async run(h) {
|
|
878
|
+
const base = h.canonicalTexts[0] ?? "";
|
|
879
|
+
const m = await h.create();
|
|
880
|
+
try {
|
|
881
|
+
await m.importText(base);
|
|
882
|
+
const baseline = exportOrFail(m, "after importing canonicalTexts[0]");
|
|
883
|
+
const baseIds = sortedIds(m);
|
|
884
|
+
let previous;
|
|
885
|
+
const reimportBase = async () => {
|
|
886
|
+
if (previous === void 0) return;
|
|
887
|
+
const after = `after importing ${describeProbe(previous)}`;
|
|
888
|
+
await importOrFail(m, base, `the next valid import ${after} did not render`);
|
|
889
|
+
const doc = exportOrFail(m, `on the next valid import ${after}`);
|
|
890
|
+
if (doc !== baseline || baseIds && !same(baseIds, sortedIds(m))) {
|
|
891
|
+
fail(`the next valid import ${after} did not replace the document (wedged)`);
|
|
892
|
+
}
|
|
893
|
+
};
|
|
894
|
+
const mutateAndExpectApplied = async () => {
|
|
895
|
+
await h.mutate(m);
|
|
896
|
+
await (h.settle ?? defaultSettle)();
|
|
897
|
+
const doc = exportOrFail(m, "after a user mutation");
|
|
898
|
+
if (doc === baseline) {
|
|
899
|
+
fail(
|
|
900
|
+
previous === void 0 ? "the harness mutation did not change the export \u2014 the case proves nothing" : `a user mutation after importing ${describeProbe(previous)} did not apply (wedged)`
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
return doc;
|
|
904
|
+
};
|
|
905
|
+
for (const probe of probesOf(h)) {
|
|
906
|
+
await reimportBase();
|
|
907
|
+
const doc = await mutateAndExpectApplied();
|
|
908
|
+
const ids = sortedIds(m);
|
|
909
|
+
const what = `importText(${describeProbe(probe)})`;
|
|
910
|
+
let rejected = false;
|
|
911
|
+
const { calls } = await changeEvents(
|
|
912
|
+
h,
|
|
913
|
+
m,
|
|
914
|
+
() => m.importText(probe.text).then(
|
|
915
|
+
() => void 0,
|
|
916
|
+
() => {
|
|
917
|
+
rejected = true;
|
|
918
|
+
}
|
|
919
|
+
)
|
|
920
|
+
);
|
|
921
|
+
if (rejected) {
|
|
922
|
+
if (calls > 0) fail(`a rejected ${what} fired onContentChanged ${calls}x (L7)`);
|
|
923
|
+
if (exportOrFail(m, `after a rejected ${what}`) !== doc) {
|
|
924
|
+
fail(`a rejected ${what} changed the document \u2014 the previous state must stay intact (L7)`);
|
|
925
|
+
}
|
|
926
|
+
if (ids && !same(ids, sortedIds(m))) {
|
|
927
|
+
fail(
|
|
928
|
+
`a rejected ${what} changed the rendered elements \u2014 the canvas must keep the previous document (L7)`
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
if (h.undo) {
|
|
932
|
+
await h.undo(m);
|
|
933
|
+
if (exportOrFail(m, `after an undo that followed a rejected ${what}`) !== baseline) {
|
|
934
|
+
fail(
|
|
935
|
+
`a rejected ${what} erased the undo history \u2014 the previous document's history must stay intact (L7)`
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
} else {
|
|
940
|
+
if (calls > 0)
|
|
941
|
+
fail(`${what} resolved and fired onContentChanged ${calls}x \u2014 an import is silent (L1)`);
|
|
942
|
+
exportOrFail(m, `after ${what} resolved (L6)`);
|
|
943
|
+
}
|
|
944
|
+
previous = probe;
|
|
945
|
+
}
|
|
946
|
+
await reimportBase();
|
|
947
|
+
await mutateAndExpectApplied();
|
|
948
|
+
} finally {
|
|
949
|
+
m.destroy();
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
},
|
|
597
953
|
{
|
|
598
954
|
name: "viewer (when shipped): read-only, silent, canonical, fits, reveals, keeps foreign content, unshaken by a rejected import",
|
|
599
955
|
async run(h) {
|
|
@@ -701,10 +1057,14 @@ function registerConformance(harness, test, options = {}) {
|
|
|
701
1057
|
const onSkip = options.onSkip ?? ((name, reason) => {
|
|
702
1058
|
console.warn(`modeler-api conformance: SKIPPED "${name}" \u2014 ${reason}`);
|
|
703
1059
|
});
|
|
1060
|
+
const onAdvisory = options.onAdvisory ?? ((name, advice) => {
|
|
1061
|
+
console.warn(`modeler-api conformance: ADVISORY "${name}" \u2014 ${advice}`);
|
|
1062
|
+
});
|
|
704
1063
|
for (const conformanceCase of conformanceCases()) {
|
|
705
1064
|
test(`conformance: ${conformanceCase.name}`, async () => {
|
|
706
1065
|
const outcome = await conformanceCase.run(harness);
|
|
707
1066
|
if (outcome?.skipped) onSkip(conformanceCase.name, outcome.skipped);
|
|
1067
|
+
for (const advice of outcome?.advisories ?? []) onAdvisory(conformanceCase.name, advice);
|
|
708
1068
|
});
|
|
709
1069
|
}
|
|
710
1070
|
}
|