@hydranium/conformance 1.0.0-next.10

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/lib/conformance-suite.d.ts +97 -0
  4. package/lib/conformance-suite.d.ts.map +1 -0
  5. package/lib/conformance-suite.js +97 -0
  6. package/lib/conformance-suite.js.map +1 -0
  7. package/lib/data/index.d.ts +69 -0
  8. package/lib/data/index.d.ts.map +1 -0
  9. package/lib/data/index.js +223 -0
  10. package/lib/data/index.js.map +1 -0
  11. package/lib/glsp/index.d.ts +124 -0
  12. package/lib/glsp/index.d.ts.map +1 -0
  13. package/lib/glsp/index.js +99 -0
  14. package/lib/glsp/index.js.map +1 -0
  15. package/lib/index.d.ts +11 -0
  16. package/lib/index.d.ts.map +1 -0
  17. package/lib/index.js +19 -0
  18. package/lib/index.js.map +1 -0
  19. package/lib/jest/index.d.ts +34 -0
  20. package/lib/jest/index.d.ts.map +1 -0
  21. package/lib/jest/index.js +59 -0
  22. package/lib/jest/index.js.map +1 -0
  23. package/lib/lsp/index.d.ts +88 -0
  24. package/lib/lsp/index.d.ts.map +1 -0
  25. package/lib/lsp/index.js +198 -0
  26. package/lib/lsp/index.js.map +1 -0
  27. package/lib/model.d.ts +111 -0
  28. package/lib/model.d.ts.map +1 -0
  29. package/lib/model.js +28 -0
  30. package/lib/model.js.map +1 -0
  31. package/lib/vitest/index.d.ts +48 -0
  32. package/lib/vitest/index.d.ts.map +1 -0
  33. package/lib/vitest/index.js +45 -0
  34. package/lib/vitest/index.js.map +1 -0
  35. package/package.json +120 -0
  36. package/src/conformance-suite.ts +148 -0
  37. package/src/data/index.ts +297 -0
  38. package/src/glsp/index.ts +217 -0
  39. package/src/index.ts +20 -0
  40. package/src/jest/index.ts +81 -0
  41. package/src/lsp/index.ts +269 -0
  42. package/src/model.ts +122 -0
  43. package/src/vitest/index.ts +81 -0
@@ -0,0 +1,148 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ /**
11
+ * One conformance check. A check with a {@link body} is RUN (emitted as a
12
+ * runner test); a check WITHOUT a body is SKIPPED — its optional fixture input
13
+ * was absent, so it reports as skipped with a named {@link skipReason} rather
14
+ * than passing vacuously. The false-green guard (require both a valid and an
15
+ * invalid model) lives in the slices; this type is the head-agnostic unit they
16
+ * build.
17
+ */
18
+ export interface ConformanceCheck {
19
+ readonly title: string;
20
+ /** Present ⇒ the check runs. Absent ⇒ the check is skipped with {@link skipReason}. */
21
+ readonly body?: () => void | Promise<void>;
22
+ /** Why the check was skipped — surfaced in the `it.skip` title and the summary. */
23
+ readonly skipReason?: string;
24
+ }
25
+
26
+ /** A single skipped check in a {@link ConformanceSummary}. */
27
+ export interface SkippedCheck {
28
+ readonly title: string;
29
+ readonly reason: string;
30
+ }
31
+
32
+ /** Ran-vs-skipped tally over a battery of {@link ConformanceCheck}s. */
33
+ export interface ConformanceSummary {
34
+ readonly total: number;
35
+ readonly ran: number;
36
+ readonly skipped: ReadonlyArray<SkippedCheck>;
37
+ }
38
+
39
+ /** Placeholder reason for a skipped check that supplied none. */
40
+ const NO_REASON = 'no reason given';
41
+
42
+ /**
43
+ * Tally a battery of checks into ran-vs-skipped counts. A check is RAN when
44
+ * it carries a {@link ConformanceCheck.body}; otherwise it is SKIPPED and its
45
+ * (possibly defaulted) reason is collected.
46
+ */
47
+ export function summarizeChecks(checks: ReadonlyArray<ConformanceCheck>): ConformanceSummary {
48
+ const skipped: SkippedCheck[] = [];
49
+ let ran = 0;
50
+ for (const check of checks) {
51
+ if (check.body) {
52
+ ran++;
53
+ } else {
54
+ skipped.push({ title: check.title, reason: check.skipReason ?? NO_REASON });
55
+ }
56
+ }
57
+ return { total: checks.length, ran, skipped };
58
+ }
59
+
60
+ /**
61
+ * The `it.skip` title for a skipped check — the check title annotated with
62
+ * its (possibly defaulted) reason, so the runner's report explains the skip
63
+ * inline instead of showing a bare greyed-out line.
64
+ */
65
+ export function skippedTitle(check: Pick<ConformanceCheck, 'title' | 'skipReason'>): string {
66
+ return `${check.title} [skipped: ${check.skipReason ?? NO_REASON}]`;
67
+ }
68
+
69
+ /**
70
+ * Render a one-or-more-line human summary of a {@link ConformanceSummary},
71
+ * prefixed by the suite name. Printed once per suite so a half-implemented
72
+ * adopter reads as *skipped*, never as a silent false green.
73
+ */
74
+ export function formatSummary(suite: string, summary: ConformanceSummary): string {
75
+ const header = `${suite}: ${summary.ran}/${summary.total} ran, ${summary.skipped.length} skipped`;
76
+ if (summary.skipped.length === 0) {
77
+ return header;
78
+ }
79
+ const lines = summary.skipped.map(skip => ` - skipped: ${skip.title} (${skip.reason})`);
80
+ return [header, ...lines].join('\n');
81
+ }
82
+
83
+ /**
84
+ * Write the ran-vs-skipped summary somewhere a test runner does not swallow.
85
+ *
86
+ * `console.log` is NOT that place: vitest attributes console output to the
87
+ * running task, and an `afterAll` hook has none, so the line is dropped with no
88
+ * warning — measured, not inferred. That silently disables the kit's only
89
+ * false-green guard: a half-implemented adopter is supposed to read as
90
+ * *skipped*, and with the summary gone it reads as a clean pass.
91
+ *
92
+ * `process.stderr` bypasses the interception. A runner with no `process` (a
93
+ * browser runner) falls back to `console.log`, where nothing is intercepting in
94
+ * the first place — the kit's `.` entry is neutrality-gated, so this must not
95
+ * assume Node.
96
+ */
97
+ export function writeConformanceSummary(text: string): void {
98
+ const stderr = (globalThis as { process?: { stderr?: { write?(chunk: string): unknown } } }).process?.stderr;
99
+ if (typeof stderr?.write === 'function') {
100
+ stderr.write(`${text}\n`);
101
+ return;
102
+ }
103
+ console.log(text);
104
+ }
105
+
106
+ /**
107
+ * The minimal test-runner surface {@link emitConformanceSuite} drives, so the
108
+ * kit core names NO concrete runner (`@jest/globals` / `vitest` / `node:test`)
109
+ * and stays runner-agnostic. A thin per-runner adapter subpath
110
+ * (`@hydranium/conformance/jest`, `@hydranium/conformance/vitest`) binds these
111
+ * methods to its runner's `describe` / `it` / `it.skip` / `afterAll`.
112
+ * Structural by design — jest and vitest both satisfy it directly.
113
+ */
114
+ export interface ConformanceRunner {
115
+ /** Group the suite's checks under `name`; `register` queues them (called synchronously). */
116
+ describe(name: string, register: () => void): void;
117
+ /** Register a running check. */
118
+ test(name: string, body: () => void | Promise<void>): void;
119
+ /** Register a skipped check (greyed out, never executed). */
120
+ skip(name: string): void;
121
+ /** Register a once-per-suite teardown — used to print the ran-vs-skipped summary. */
122
+ afterAll(fn: () => void | Promise<void>): void;
123
+ }
124
+
125
+ /**
126
+ * Emit a battery of {@link ConformanceCheck}s through an injected
127
+ * {@link ConformanceRunner} — each check with a body becomes a `test`, each
128
+ * without becomes a `skip` carrying its {@link skippedTitle}, and an `afterAll`
129
+ * prints the {@link formatSummary} so the suite always reports ran-vs-skipped.
130
+ * The thin, runner-agnostic translation layer between a slice's planned checks
131
+ * (whose ran/skip decisions are the tested pure logic above) and a runner; a
132
+ * per-runner adapter's `runXxxConformance` builds the check list and hands it
133
+ * here with the runner bound.
134
+ */
135
+ export function emitConformanceSuite(runner: ConformanceRunner, suite: string, checks: ReadonlyArray<ConformanceCheck>): void {
136
+ runner.describe(suite, () => {
137
+ for (const check of checks) {
138
+ if (check.body) {
139
+ runner.test(check.title, check.body);
140
+ } else {
141
+ runner.skip(skippedTitle(check));
142
+ }
143
+ }
144
+ runner.afterAll(() => {
145
+ writeConformanceSummary(formatSummary(suite, summarizeChecks(checks)));
146
+ });
147
+ });
148
+ }
@@ -0,0 +1,297 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ /**
11
+ * The `@hydranium/conformance/data` slice — protocol conformance for the
12
+ * data-server head. The driver port IS the protocol-native
13
+ * {@link DataServerProtocol} proxy (plus the captured `onDocumentUpdated`
14
+ * events), so no upstream wire-lib dep enters the kit and `DataServerHarness`
15
+ * satisfies the port structurally with no adapter.
16
+ */
17
+
18
+ import assert from 'node:assert/strict';
19
+ import { TransferDocument, type TransferDiagnostic, type TransferElement } from '@hydranium/protocol';
20
+ import type { DataServerProtocol, TransferDocumentUpdatedEvent } from '@hydranium/protocol/data';
21
+ import { type Harness, waitFor } from '@hydranium/protocol/testing';
22
+ import type { ConformanceCheck } from '../conformance-suite.js';
23
+ import { type LanguageFixture, resolveDeferred, resolveModel } from '../model.js';
24
+
25
+ /**
26
+ * The data-server driver port — a live, connected, READY data-server exposed
27
+ * through its protocol-native proxy plus the captured client-side update
28
+ * events. The kit names only `@hydranium/protocol` types, so a
29
+ * `DataServerHarness` satisfies the port structurally (it has `proxy` +
30
+ * `events` + `dispose`) with NO adapter. `extends Harness` gives the kit the
31
+ * universal `dispose()` teardown.
32
+ *
33
+ * The kit seeds documents purely through the proxy: `updateModelDocument` is
34
+ * an upsert (it creates a cold URI from the payload, not just modifies an
35
+ * existing one), so the slice needs no services-level open hook. The adopter
36
+ * only has to stand the server up READY in its `connect` — see
37
+ * {@link DataConformanceOptions.connect}.
38
+ */
39
+ export interface DataConformanceDriver<
40
+ TTransfer extends TransferElement,
41
+ TDiagnostic extends TransferDiagnostic = TransferDiagnostic
42
+ > extends Harness {
43
+ readonly proxy: DataServerProtocol<TTransfer, TDiagnostic>;
44
+ /** Captured `onDocumentUpdated` events, append order — the subscription check's observation target. */
45
+ readonly events: ReadonlyArray<TransferDocumentUpdatedEvent<TTransfer, TDiagnostic>>;
46
+ }
47
+
48
+ /** Options for `runDataConformance`. */
49
+ export interface DataConformanceOptions<TTransfer extends TransferElement, TDiagnostic extends TransferDiagnostic = TransferDiagnostic> {
50
+ /**
51
+ * Establish a freshly-wired, connected, READY data-server driver. Called
52
+ * once per check for isolation; the kit disposes it after the check. The
53
+ * adopter must initialise the workspace before returning, so `waitForReady`
54
+ * resolves rather than hanging.
55
+ */
56
+ readonly connect: () => DataConformanceDriver<TTransfer, TDiagnostic> | Promise<DataConformanceDriver<TTransfer, TDiagnostic>>;
57
+ /** Per-language fixtures; the grammar-bearing checks run once per language. */
58
+ readonly languages: ReadonlyArray<LanguageFixture>;
59
+ /**
60
+ * Set this when the head under test HAS a project tier. Supplying it IS the
61
+ * claim that `getProjects` answers at least one project, so an empty array
62
+ * becomes a failure rather than a vacuous pass.
63
+ *
64
+ * Left unset the claim is not made and the check reports skipped with a
65
+ * named reason, because `[]` is the documented answer for a head with no
66
+ * project tier (`UNQUALIFIED_PROJECT_REFERENCE` everywhere) — indistinguishable
67
+ * from "never implemented" without the adopter saying which it is.
68
+ */
69
+ readonly expectsProjects?: boolean;
70
+ /** Suite title override. Default `'conformance: data-server'`. */
71
+ readonly suiteTitle?: string;
72
+ }
73
+
74
+ /** Client id the kit seeds/edits documents under (the originating author). */
75
+ const AUTHOR = 'conformance-author';
76
+ /** Client id the kit subscribes under — distinct from {@link AUTHOR} so the echo is recognisable. */
77
+ const SUBSCRIBER = 'conformance-subscriber';
78
+ /**
79
+ * Client id for a write made BEFORE any subscription exists. Distinct from
80
+ * {@link AUTHOR} so an event caused by it is recognisable: a head that fans
81
+ * notifications out regardless of its subscription table is otherwise
82
+ * indistinguishable from one that honours the table, since both deliver
83
+ * something for the post-subscribe write.
84
+ */
85
+ const SEEDER = 'conformance-seeder';
86
+
87
+ /**
88
+ * Build the data-server check battery: server-level checks once, then the
89
+ * grammar-bearing checks per language. Each check connects a fresh driver
90
+ * and disposes it, so checks never interfere. Exported for the kit's own
91
+ * unit tests; adopters call `runDataConformance`.
92
+ *
93
+ * `LanguageFixture.edit` is read HERE and nowhere else in the kit, and is
94
+ * optional: its two checks report skipped when it is absent. See
95
+ * {@link LanguageFixture} for which slice reads which field.
96
+ */
97
+ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic extends TransferDiagnostic = TransferDiagnostic>(
98
+ options: DataConformanceOptions<TTransfer, TDiagnostic>
99
+ ): ConformanceCheck[] {
100
+ const { connect, expectsProjects } = options;
101
+ const checks: ConformanceCheck[] = [];
102
+
103
+ // Split from `waitForReady` so a failure names which of the two broke.
104
+ //
105
+ // SHAPE ONLY: an empty array is the documented answer for a head with no
106
+ // project tier, so "returns `[]`" passes here and is indistinguishable from
107
+ // "never implemented". The discriminating part is the element-type assertion
108
+ // (it fails a head answering with the wrong element type, which the bare
109
+ // `Array.isArray` it replaced could not) plus the separate emptiness check
110
+ // below, which only an adopter's `expectsProjects` can license.
111
+ checks.push({
112
+ title: 'getProjects answers an array of well-formed projects',
113
+ body: async () => {
114
+ const driver = await connect();
115
+ try {
116
+ const projects = await driver.proxy.getProjects();
117
+ assert.ok(Array.isArray(projects), 'getProjects did not return an array');
118
+ for (const project of projects) {
119
+ assert.ok(
120
+ typeof project?.id === 'string' && project.id.length > 0,
121
+ `getProjects returned a project with no id: ${JSON.stringify(project)}`
122
+ );
123
+ // Type, not emptiness: the empty string is
124
+ // `UNQUALIFIED_PROJECT_REFERENCE`, the documented value for a
125
+ // project that does not qualify names, and the framework's own
126
+ // reference example uses it. Requiring non-empty here failed
127
+ // that example and would have failed every adopter taking the
128
+ // documented default.
129
+ assert.ok(typeof project.referenceName === 'string', `project ${project.id} has no referenceName`);
130
+ }
131
+ const ids = projects.map(project => project.id);
132
+ assert.strictEqual(new Set(ids).size, ids.length, `getProjects returned duplicate project ids: ${ids.join(', ')}`);
133
+ } finally {
134
+ driver.dispose();
135
+ }
136
+ }
137
+ });
138
+
139
+ // Separate from the shape check so a failure names which claim broke, and
140
+ // gated because only the adopter knows whether their head has a project
141
+ // tier at all. Planned either way — reported as skipped with a reason
142
+ // rather than silently absent, which is what distinguishes an opt-out from
143
+ // lost coverage.
144
+ checks.push({
145
+ title: 'getProjects answers at least one project (projects expected)',
146
+ skipReason: expectsProjects
147
+ ? undefined
148
+ : 'options supplied no `expectsProjects` (an empty project list is the documented answer for a head with no project tier)',
149
+ body: expectsProjects
150
+ ? async () => {
151
+ const driver = await connect();
152
+ try {
153
+ const projects = await driver.proxy.getProjects();
154
+ assert.ok(
155
+ projects.length > 0,
156
+ 'getProjects returned an empty array although `expectsProjects` claims the head has a project tier'
157
+ );
158
+ } finally {
159
+ driver.dispose();
160
+ }
161
+ }
162
+ : undefined
163
+ });
164
+
165
+ checks.push({
166
+ title: 'waitForReady resolves rather than hanging or rejecting',
167
+ body: async () => {
168
+ const driver = await connect();
169
+ try {
170
+ // Resolves `Promise<void>`, and over JSON-RPC a void response comes
171
+ // back as `null` — so the value carries no information and only the
172
+ // fact that it settles at all is assertable here.
173
+ await driver.proxy.waitForReady();
174
+ } finally {
175
+ driver.dispose();
176
+ }
177
+ }
178
+ });
179
+
180
+ for (const language of options.languages) {
181
+ const { valid, invalid, edit } = language;
182
+ const tag = `[${valid.languageId}]`;
183
+
184
+ checks.push({
185
+ title: `getModelDocument(valid) returns a coherent envelope ${tag}`,
186
+ body: async () => {
187
+ const driver = await connect();
188
+ try {
189
+ // Resolved AFTER `connect`, which is the whole point of allowing a
190
+ // thunk: the fixture may name a workspace `connect` just created.
191
+ const model = resolveModel(valid);
192
+ await driver.proxy.updateModelDocument({ uri: model.uri, clientId: AUTHOR, model: model.text });
193
+ // `includeDiagnostics` for the same reason the invalid check
194
+ // passes it: a synchronous read settles at the integrity-settled
195
+ // phase, so an empty array without it can mean "validation has
196
+ // not run" rather than "the document is clean".
197
+ const document = await driver.proxy.getModelDocument({ uri: model.uri, includeDiagnostics: true });
198
+ assert.strictEqual(document.uri, model.uri);
199
+ // The SHAPE, not truthiness: `{}` is truthy, so a head answering
200
+ // a shaped-but-contentless envelope for a document it did parse
201
+ // would pass. `$type` is the one field every TransferElement
202
+ // carries, so it is assertable with no fixture knowledge.
203
+ const { root } = TransferDocument.assertLoaded(document);
204
+ assert.ok(
205
+ typeof root.$type === 'string' && root.$type.length > 0,
206
+ `getModelDocument(valid) returned a root with no $type: ${JSON.stringify(root)}`
207
+ );
208
+ // `version` is the conflict token every later update gates on, so
209
+ // an envelope that omits it is unusable however good the root is.
210
+ assert.ok(
211
+ Number.isInteger(document.version),
212
+ `getModelDocument(valid) returned a non-integer version: ${String(document.version)}`
213
+ );
214
+ assert.deepStrictEqual(document.diagnostics, []);
215
+ } finally {
216
+ driver.dispose();
217
+ }
218
+ }
219
+ });
220
+
221
+ checks.push({
222
+ title: `getModelDocument(invalid) reports at least one diagnostic ${tag}`,
223
+ body: async () => {
224
+ const driver = await connect();
225
+ try {
226
+ const model = resolveModel(invalid);
227
+ await driver.proxy.updateModelDocument({ uri: model.uri, clientId: AUTHOR, model: model.text });
228
+ // Diagnostics are a validation-phase product; a synchronous read settles at the
229
+ // integrity-settled phase by default, so request validation explicitly here.
230
+ // Safe despite `includeDiagnostics` waiting rather than forcing a build: the
231
+ // update above has already driven this document through validation.
232
+ const document = await driver.proxy.getModelDocument({ uri: model.uri, includeDiagnostics: true });
233
+ assert.ok(document.diagnostics.length >= 1, 'getModelDocument(invalid) reported no diagnostics');
234
+ } finally {
235
+ driver.dispose();
236
+ }
237
+ }
238
+ });
239
+
240
+ // Opt-in: both remaining checks need a second, observably different model
241
+ // text, which only `edit` supplies.
242
+ const editSkipReason = 'fixture supplies no `edit` (data-slice only; omit it if this language is not driven through the data head)';
243
+
244
+ checks.push({
245
+ title: `updateModelDocument applies an edit a follow-up get reflects ${tag}`,
246
+ skipReason: edit ? undefined : editSkipReason,
247
+ body: edit
248
+ ? async () => {
249
+ const driver = await connect();
250
+ try {
251
+ const model = resolveModel(valid);
252
+ await driver.proxy.updateModelDocument({ uri: model.uri, clientId: AUTHOR, model: model.text });
253
+ await driver.proxy.updateModelDocument({ uri: model.uri, clientId: AUTHOR, model: resolveDeferred(edit.to) });
254
+ const document = await driver.proxy.getModelDocument({ uri: model.uri });
255
+ assert.ok(edit.expect(document.root), 'edit.expect(root) was false — the edit was not reflected by a follow-up get');
256
+ } finally {
257
+ driver.dispose();
258
+ }
259
+ }
260
+ : undefined
261
+ });
262
+
263
+ checks.push({
264
+ title: `subscribe + update delivers an onDocumentUpdated event with the originating clientId ${tag}`,
265
+ skipReason: edit ? undefined : editSkipReason,
266
+ body: edit
267
+ ? async () => {
268
+ const driver = await connect();
269
+ try {
270
+ const model = resolveModel(valid);
271
+ await driver.proxy.updateModelDocument({ uri: model.uri, clientId: SEEDER, model: model.text });
272
+ await driver.proxy.watchModelDocument({ uri: model.uri, clientId: SUBSCRIBER });
273
+ await driver.proxy.updateModelDocument({ uri: model.uri, clientId: AUTHOR, model: resolveDeferred(edit.to) });
274
+ await waitFor(() => driver.events.some(event => event.sourceClientId === AUTHOR), {
275
+ message: `no onDocumentUpdated event for ${model.uri} after the post-subscription update`
276
+ });
277
+ const last = driver.events[driver.events.length - 1];
278
+ assert.strictEqual(last.document.uri, model.uri);
279
+ assert.strictEqual(last.sourceClientId, AUTHOR);
280
+ // Nothing from before the subscription. Waiting for the
281
+ // post-subscribe event first is what makes this provable: the
282
+ // two notifications share one ordered connection, so a seeding
283
+ // event that was ever going to arrive has arrived by now.
284
+ assert.ok(
285
+ !driver.events.some(event => event.sourceClientId === SEEDER),
286
+ 'an onDocumentUpdated event arrived for the update made BEFORE watchModelDocument'
287
+ );
288
+ } finally {
289
+ driver.dispose();
290
+ }
291
+ }
292
+ : undefined
293
+ });
294
+ }
295
+
296
+ return checks;
297
+ }
@@ -0,0 +1,217 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ /**
11
+ * The `@hydranium/conformance/glsp` slice — protocol conformance for the GLSP
12
+ * head, generic over the adopter's action type `TAction`. The kit imports NO
13
+ * `@eclipse-glsp/*` types: the FIXTURE supplies the native actions
14
+ * (`requestModel()`, `createOperation.action()`) and the kit matches responses
15
+ * by `kind` (a `string`) via the driver's `nextAction`.
16
+ *
17
+ * The driver port (`GlspConformanceDriver<TAction>`) is the minimal
18
+ * `start` / `dispatch` / `nextAction` surface that `@hydranium/glsp-server/
19
+ * testing`'s `GlspHarness` satisfies with no adapter. Grammar-specific setup
20
+ * and assertions (seeding a source root for the light path, reading the
21
+ * mutated source model) are the adopter's via `prepare` / `expectResponse` /
22
+ * `createOperation.expectMutated`, which receive the CONCRETE driver `TDriver`
23
+ * — so the port stays grammar-agnostic while the adopter keeps full access to
24
+ * its harness.
25
+ */
26
+
27
+ import assert from 'node:assert/strict';
28
+ import type { Harness } from '@hydranium/protocol/testing';
29
+ import type { ConformanceCheck } from '../conformance-suite.js';
30
+
31
+ /**
32
+ * The GLSP driver port — a live GLSP server driven through the action
33
+ * round-trip. Generic over the adopter action type `TAction` so the kit names
34
+ * no `@eclipse-glsp/*` type. `@hydranium/glsp-server/testing`'s `GlspHarness`
35
+ * satisfies it structurally with `TAction = Action`. `extends Harness` gives
36
+ * the kit the universal `dispose()` teardown.
37
+ */
38
+ export interface GlspConformanceDriver<TAction> extends Harness {
39
+ /** Drive `initialize` + `initializeClientSession`; resolve once the session exists. */
40
+ start(): Promise<void>;
41
+ /** Send an action to the server. Fire-and-forget (GLSP `process` is `void`). */
42
+ dispatch(action: TAction): void;
43
+ /** Resolve with the next captured action whose `kind` matches; reject on timeout. */
44
+ nextAction<T extends TAction = TAction>(kind: string, timeoutMs?: number): Promise<T>;
45
+ }
46
+
47
+ /**
48
+ * Opt-in create-operation spec. The adopter supplies the operation action, the
49
+ * expected response kind it settles with, and a matcher reading the mutated
50
+ * source model off the concrete driver.
51
+ */
52
+ export interface GlspCreateOperationSpec<TAction, TDriver extends GlspConformanceDriver<TAction>> {
53
+ /**
54
+ * Construct the create operation to dispatch.
55
+ *
56
+ * Receives the driver, and is called AFTER the initial `requestModel` has
57
+ * settled — so the loaded model is available here. Two things depend on that:
58
+ *
59
+ * - **Capturing a "before" snapshot.** {@link expectMutated} gets no
60
+ * pre-operation state, so an adopter that wants a delta rather than an
61
+ * absolute count records it here, in its own closure.
62
+ * - **Operations that need real element ids.** A `CreateEdgeOperation` names
63
+ * a source and target from the index, which do not exist until the model is
64
+ * loaded.
65
+ */
66
+ readonly action: (driver: TDriver) => TAction;
67
+ /** The action kind the server settles the operation with (e.g. a re-`RequestBounds` for client-laid-out diagrams). */
68
+ readonly expectedResponseKind: string;
69
+ /**
70
+ * Returns whether the source model gained the element — the adopter reads its
71
+ * concrete state off `driver`.
72
+ *
73
+ * **Receives no pre-operation snapshot of its own**, because the kit owns no
74
+ * source-model type and cannot capture one generically. An adopter wanting a
75
+ * delta rather than an absolute count records the before-state in
76
+ * {@link GlspCreateOperationSpec.action}, which does get the driver and does
77
+ * run after the model has loaded.
78
+ *
79
+ * Sticking to an absolute count is fine too, with one caveat: it couples the
80
+ * fixture to its input document, so a fixture whose input was MUTATED by an
81
+ * earlier check silently expects the wrong number. Give each check pristine
82
+ * input, or take the delta route above.
83
+ */
84
+ readonly expectMutated: (driver: TDriver) => boolean;
85
+ }
86
+
87
+ /**
88
+ * Per-diagram-type GLSP fixture, generic over the adopter action type and the
89
+ * concrete driver. The `prepare` hook covers both fidelities: LIGHT seeds a
90
+ * source root directly (`driver.seedSourceRoot(...)`); FAITHFUL no-ops and lets
91
+ * the `requestModel` action carry a source URI a real storage loads.
92
+ */
93
+ export interface GlspFixture<TAction, TDriver extends GlspConformanceDriver<TAction>> {
94
+ /**
95
+ * **Title only** — the label every check for this fixture is tagged with.
96
+ *
97
+ * It is NOT the diagram type the server runs: `connect` supplies that to the
98
+ * harness. Appending a fidelity or document suffix here is what makes two
99
+ * fixtures over ONE diagram type read apart in the report. Nothing validates
100
+ * this string, so treat it as free-form and make it descriptive; a value that
101
+ * is only ever displayed is checked by nothing.
102
+ */
103
+ readonly diagramType: string;
104
+ /** Seed (light) or no-op (faithful) after `start`, before the first `requestModel` dispatch. */
105
+ readonly prepare: (driver: TDriver) => void | Promise<void>;
106
+ /**
107
+ * Construct the `RequestModel` action (light: bare; faithful: carrying a
108
+ * source URI).
109
+ *
110
+ * A THUNK, called per check and always AFTER `connect` — so a faithful fixture
111
+ * may read a root that `connect` just created, which is how an adopter gives
112
+ * every check pristine on-disk input. The `/data` and `/lsp` slices get the
113
+ * same per-check resolution from `ConformanceModel`'s deferrable fields.
114
+ */
115
+ readonly requestModel: () => TAction;
116
+ /** The action kind the server responds to `requestModel` with (e.g. `RequestBoundsAction.KIND`). */
117
+ readonly expectedResponseKind: string;
118
+ /** Optional matcher over the response action — the adopter digs into the projected GModel (the kit owns no GModel types). */
119
+ readonly expectResponse?: (response: TAction) => boolean;
120
+ /** Opt-in: a create operation that must mutate the source model. */
121
+ readonly createOperation?: GlspCreateOperationSpec<TAction, TDriver>;
122
+ }
123
+
124
+ /** Options for `runGlspConformance`. */
125
+ export interface GlspConformanceOptions<TAction, TDriver extends GlspConformanceDriver<TAction>> {
126
+ /**
127
+ * Establish a freshly-wired GLSP driver (NOT started — the kit drives
128
+ * `start()` per check). Called once per check for isolation; the kit
129
+ * disposes it. The faithful path must build the workspace here first, so
130
+ * storage has something to load.
131
+ */
132
+ readonly connect: () => TDriver | Promise<TDriver>;
133
+ /** Per-diagram-type fixtures. */
134
+ readonly diagrams: ReadonlyArray<GlspFixture<TAction, TDriver>>;
135
+ /** Suite title override. Default `'conformance: glsp'`. */
136
+ readonly suiteTitle?: string;
137
+ }
138
+
139
+ /**
140
+ * Build the GLSP check battery — per diagram type: `start()` resolves;
141
+ * `RequestModel` responds with the expected kind (+ optional `expectResponse`
142
+ * matcher); and an OPT-IN create-operation check (absent ⇒ `it.skip` with a
143
+ * named reason). Each check connects a fresh driver, drives `start` + the
144
+ * fixture's `prepare`, and disposes the driver. Exported for the kit's own
145
+ * unit tests; adopters call `runGlspConformance`.
146
+ */
147
+ export function buildGlspChecks<TAction, TDriver extends GlspConformanceDriver<TAction>>(
148
+ options: GlspConformanceOptions<TAction, TDriver>
149
+ ): ConformanceCheck[] {
150
+ const { connect } = options;
151
+ const checks: ConformanceCheck[] = [];
152
+
153
+ for (const diagram of options.diagrams) {
154
+ const tag = `[${diagram.diagramType}]`;
155
+
156
+ checks.push({
157
+ title: `start() initialises a client session ${tag}`,
158
+ body: async () => {
159
+ const driver = await connect();
160
+ try {
161
+ await driver.start();
162
+ } finally {
163
+ driver.dispose();
164
+ }
165
+ }
166
+ });
167
+
168
+ checks.push({
169
+ title: `RequestModel responds with ${diagram.expectedResponseKind} ${tag}`,
170
+ body: async () => {
171
+ const driver = await connect();
172
+ try {
173
+ await driver.start();
174
+ await diagram.prepare(driver);
175
+ driver.dispatch(diagram.requestModel());
176
+ const response = await driver.nextAction(diagram.expectedResponseKind);
177
+ if (diagram.expectResponse) {
178
+ assert.ok(diagram.expectResponse(response), `expectResponse was false for the ${diagram.expectedResponseKind} response`);
179
+ }
180
+ } finally {
181
+ driver.dispose();
182
+ }
183
+ }
184
+ });
185
+
186
+ const operation = diagram.createOperation;
187
+ if (operation) {
188
+ checks.push({
189
+ title: `create operation mutates the source model ${tag}`,
190
+ body: async () => {
191
+ const driver = await connect();
192
+ try {
193
+ await driver.start();
194
+ await diagram.prepare(driver);
195
+ driver.dispatch(diagram.requestModel());
196
+ await driver.nextAction(diagram.expectedResponseKind);
197
+ driver.dispatch(operation.action(driver));
198
+ await driver.nextAction(operation.expectedResponseKind);
199
+ assert.ok(
200
+ operation.expectMutated(driver),
201
+ 'expectMutated was false — the create operation did not mutate the source model'
202
+ );
203
+ } finally {
204
+ driver.dispose();
205
+ }
206
+ }
207
+ });
208
+ } else {
209
+ checks.push({
210
+ title: `create operation mutates the source model ${tag}`,
211
+ skipReason: 'fixture supplied no createOperation'
212
+ });
213
+ }
214
+ }
215
+
216
+ return checks;
217
+ }