@hydranium/conformance 1.0.0-next.27 → 1.0.0-next.270
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 +79 -74
- package/lib/conformance-suite.js +1 -1
- package/lib/data/index.d.ts +35 -12
- package/lib/data/index.d.ts.map +1 -1
- package/lib/data/index.js +753 -32
- package/lib/data/index.js.map +1 -1
- package/lib/glsp/index.d.ts +24 -4
- package/lib/glsp/index.d.ts.map +1 -1
- package/lib/glsp/index.js +34 -2
- package/lib/glsp/index.js.map +1 -1
- package/lib/glsp/node/index.d.ts +64 -0
- package/lib/glsp/node/index.d.ts.map +1 -0
- package/lib/glsp/node/index.js +195 -0
- package/lib/glsp/node/index.js.map +1 -0
- package/lib/jest/index.js +3 -3
- package/lib/jest/index.js.map +1 -1
- package/lib/lsp/index.d.ts +23 -3
- package/lib/lsp/index.d.ts.map +1 -1
- package/lib/lsp/index.js +69 -3
- package/lib/lsp/index.js.map +1 -1
- package/lib/model.d.ts +131 -8
- package/lib/model.d.ts.map +1 -1
- package/lib/model.js.map +1 -1
- package/lib/vitest/index.js +3 -3
- package/lib/vitest/index.js.map +1 -1
- package/package.json +32 -45
- package/src/conformance-suite.ts +1 -1
- package/src/data/index.ts +965 -44
- package/src/glsp/index.ts +64 -6
- package/src/glsp/node/index.ts +274 -0
- package/src/jest/index.ts +3 -3
- package/src/lsp/index.ts +110 -7
- package/src/model.ts +134 -8
- package/src/vitest/index.ts +3 -3
package/src/data/index.ts
CHANGED
|
@@ -10,39 +10,70 @@
|
|
|
10
10
|
/**
|
|
11
11
|
* The `@hydranium/conformance/data` slice — protocol conformance for the
|
|
12
12
|
* data-server head. The driver port IS the protocol-native
|
|
13
|
-
* {@link DataServerProtocol} proxy (plus the captured
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* {@link DataServerProtocol} proxy (plus the captured client notifications),
|
|
14
|
+
* so no upstream wire-lib dep enters the kit and `DataServerHarness` satisfies
|
|
15
|
+
* the port structurally with no adapter.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import assert from 'node:assert/strict';
|
|
19
|
-
import {
|
|
20
|
-
|
|
19
|
+
import {
|
|
20
|
+
asModelVersion,
|
|
21
|
+
FRAMEWORK_CLIENT_IDS,
|
|
22
|
+
isConflictError,
|
|
23
|
+
isDocumentNotOpenError,
|
|
24
|
+
isDuplicateClientIdError,
|
|
25
|
+
isReservedClientIdError,
|
|
26
|
+
isSessionClosedError,
|
|
27
|
+
ReferenceSource,
|
|
28
|
+
SyntheticStep,
|
|
29
|
+
TransferDocument,
|
|
30
|
+
type TransferDiagnostic,
|
|
31
|
+
type TransferElement,
|
|
32
|
+
UNKNOWN_CLIENT_ID
|
|
33
|
+
} from '@hydranium/protocol';
|
|
34
|
+
import type {
|
|
35
|
+
DataServerProtocol,
|
|
36
|
+
ReferenceServerProtocol,
|
|
37
|
+
TransferDocumentsBuiltEvent,
|
|
38
|
+
TransferDocumentUpdatedEvent
|
|
39
|
+
} from '@hydranium/protocol/data';
|
|
21
40
|
import { type Harness, waitFor } from '@hydranium/protocol/testing';
|
|
22
41
|
import type { ConformanceCheck } from '../conformance-suite.js';
|
|
23
42
|
import { type LanguageFixture, resolveDeferred, resolveModel } from '../model.js';
|
|
24
43
|
|
|
25
44
|
/**
|
|
26
45
|
* The data-server driver port — a live, connected, READY data-server exposed
|
|
27
|
-
* through its protocol-native proxy plus the captured client-side
|
|
28
|
-
*
|
|
46
|
+
* through its protocol-native proxy plus the captured client-side
|
|
47
|
+
* notifications. The kit names only `@hydranium/protocol` types, so a
|
|
29
48
|
* `DataServerHarness` satisfies the port structurally (it has `proxy` +
|
|
30
|
-
* `events`
|
|
49
|
+
* `events`, `builds` and `dispose`) with NO adapter. `extends Harness` gives the kit the
|
|
31
50
|
* universal `dispose()` teardown.
|
|
32
51
|
*
|
|
33
|
-
* The kit seeds documents purely through the proxy:
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
52
|
+
* The kit seeds documents purely through the proxy, as client sessions: it
|
|
53
|
+
* registers one with `createSession`, opens the document, or creates it with
|
|
54
|
+
* `createModelDocument` when there is no file to open, and writes it. So the
|
|
55
|
+
* head under test must implement sessions, and the slice needs no
|
|
56
|
+
* services-level open hook. The adopter only has to stand the server up READY
|
|
57
|
+
* in its `connect` — see {@link DataConformanceOptions.connect}.
|
|
38
58
|
*/
|
|
39
59
|
export interface DataConformanceDriver<
|
|
40
60
|
TTransfer extends TransferElement,
|
|
41
61
|
TDiagnostic extends TransferDiagnostic = TransferDiagnostic
|
|
42
62
|
> extends Harness {
|
|
43
63
|
readonly proxy: DataServerProtocol<TTransfer, TDiagnostic>;
|
|
44
|
-
/** Captured `onDocumentUpdated` events, append order
|
|
64
|
+
/** Captured `onDocumentUpdated` events, in append order. */
|
|
45
65
|
readonly events: ReadonlyArray<TransferDocumentUpdatedEvent<TTransfer, TDiagnostic>>;
|
|
66
|
+
/** Captured `onDocumentsBuilt` events, append order — the cascade check's observation target. */
|
|
67
|
+
readonly builds: ReadonlyArray<TransferDocumentsBuiltEvent>;
|
|
68
|
+
/**
|
|
69
|
+
* The opt-in reference surface, when the head serves it.
|
|
70
|
+
*
|
|
71
|
+
* Separate from {@link proxy} because `ReferenceServerProtocol` is NOT part
|
|
72
|
+
* of `DataServerProtocol` — a head may serve documents and no references at
|
|
73
|
+
* all. Absent means the reference check reports skipped with a named reason
|
|
74
|
+
* rather than failing a head that never claimed the surface.
|
|
75
|
+
*/
|
|
76
|
+
readonly references?: ReferenceServerProtocol;
|
|
46
77
|
}
|
|
47
78
|
|
|
48
79
|
/** Options for `runDataConformance`. */
|
|
@@ -52,6 +83,10 @@ export interface DataConformanceOptions<TTransfer extends TransferElement, TDiag
|
|
|
52
83
|
* once per check for isolation; the kit disposes it after the check. The
|
|
53
84
|
* adopter must initialise the workspace before returning, so `waitForReady`
|
|
54
85
|
* resolves rather than hanging.
|
|
86
|
+
*
|
|
87
|
+
* Some checks save documents beside each fixture's `valid`, and nothing in
|
|
88
|
+
* the protocol deletes a file, so give each check a workspace the kit may
|
|
89
|
+
* write to and throw away.
|
|
55
90
|
*/
|
|
56
91
|
readonly connect: () => DataConformanceDriver<TTransfer, TDiagnostic> | Promise<DataConformanceDriver<TTransfer, TDiagnostic>>;
|
|
57
92
|
/** Per-language fixtures; the grammar-bearing checks run once per language. */
|
|
@@ -67,22 +102,141 @@ export interface DataConformanceOptions<TTransfer extends TransferElement, TDiag
|
|
|
67
102
|
* from "never implemented" without the adopter saying which it is.
|
|
68
103
|
*/
|
|
69
104
|
readonly expectsProjects?: boolean;
|
|
70
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Open another connection to the same server as `driver`, for the check
|
|
107
|
+
* that a connection's sessions end with it while the server lives on. That
|
|
108
|
+
* check disposes `driver` and needs its `dispose` to close the connection as
|
|
109
|
+
* the server sees it. Absent, the check reports skipped.
|
|
110
|
+
*/
|
|
111
|
+
readonly attach?: (
|
|
112
|
+
driver: DataConformanceDriver<TTransfer, TDiagnostic>
|
|
113
|
+
) => DataConformanceDriver<TTransfer, TDiagnostic> | Promise<DataConformanceDriver<TTransfer, TDiagnostic>>;
|
|
114
|
+
/** Suite title override. Default `'data-server conformance'`. */
|
|
71
115
|
readonly suiteTitle?: string;
|
|
72
116
|
}
|
|
73
117
|
|
|
74
|
-
/** Client id the kit
|
|
75
|
-
const AUTHOR = 'conformance-author';
|
|
76
|
-
/** Client id the kit subscribes under — distinct from {@link AUTHOR} so the echo is recognisable. */
|
|
118
|
+
/** Client id the kit subscribes under; a watch needs no session. */
|
|
77
119
|
const SUBSCRIBER = 'conformance-subscriber';
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A fresh session id per check. A fixed one would be refused as a duplicate
|
|
123
|
+
* by a head whose `connect` reuses one server across checks, whenever an
|
|
124
|
+
* earlier check failed before ending its session.
|
|
125
|
+
*/
|
|
126
|
+
function sessionId(label = 'conformance-session'): string {
|
|
127
|
+
return `${label}#${globalThis.crypto.randomUUID()}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Register a session under a fresh id labelled `label`, and answer the id. */
|
|
131
|
+
async function startSession<TTransfer extends TransferElement, TDiagnostic extends TransferDiagnostic>(
|
|
132
|
+
driver: DataConformanceDriver<TTransfer, TDiagnostic>,
|
|
133
|
+
label: string
|
|
134
|
+
): Promise<string> {
|
|
135
|
+
const clientId = sessionId(label);
|
|
136
|
+
await driver.proxy.createSession({ clientId, label });
|
|
137
|
+
return clientId;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create `model` for the session `clientId`, then open it, since a fixture may
|
|
142
|
+
* name a document that exists nowhere but in the kit. A failed create is
|
|
143
|
+
* dropped when the open succeeds, so a head whose create is broken fails only
|
|
144
|
+
* the create check; an open that fails reports both failures.
|
|
145
|
+
*/
|
|
146
|
+
async function openOrCreate<TTransfer extends TransferElement, TDiagnostic extends TransferDiagnostic>(
|
|
147
|
+
driver: DataConformanceDriver<TTransfer, TDiagnostic>,
|
|
148
|
+
clientId: string,
|
|
149
|
+
model: { readonly uri: string; readonly text: string }
|
|
150
|
+
): Promise<void> {
|
|
151
|
+
const created = await rejectionOf(driver.proxy.createModelDocument({ uri: model.uri, clientId, text: model.text }));
|
|
152
|
+
const opened = await rejectionOf(driver.proxy.openModelDocument({ uri: model.uri, clientId }));
|
|
153
|
+
if (opened !== undefined) {
|
|
154
|
+
throw new Error(`Could not seed ${model.uri}: ${String(opened)}; creating it failed with ${String(created)}`, { cause: opened });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Write `model` to its URI under a new session labelled `label`, and answer the
|
|
160
|
+
* session's id. The session keeps the document open until the driver is
|
|
161
|
+
* disposed: closing it would revert the document to its file, which holds
|
|
162
|
+
* another text or none.
|
|
163
|
+
*/
|
|
164
|
+
async function seed<TTransfer extends TransferElement, TDiagnostic extends TransferDiagnostic>(
|
|
165
|
+
driver: DataConformanceDriver<TTransfer, TDiagnostic>,
|
|
166
|
+
model: { readonly uri: string; readonly text: string },
|
|
167
|
+
label = 'conformance-seeder'
|
|
168
|
+
): Promise<string> {
|
|
169
|
+
const clientId = await startSession(driver, label);
|
|
170
|
+
await openOrCreate(driver, clientId, model);
|
|
171
|
+
await driver.proxy.updateModelDocument({ uri: model.uri, clientId, model: model.text, baseVersion: 'any' });
|
|
172
|
+
return clientId;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A new session labelled `label` with `uri` open, for a write after the seed; answers its id. */
|
|
176
|
+
async function openAs<TTransfer extends TransferElement, TDiagnostic extends TransferDiagnostic>(
|
|
177
|
+
driver: DataConformanceDriver<TTransfer, TDiagnostic>,
|
|
178
|
+
uri: string,
|
|
179
|
+
label = 'conformance-author'
|
|
180
|
+
): Promise<string> {
|
|
181
|
+
const clientId = await startSession(driver, label);
|
|
182
|
+
await driver.proxy.openModelDocument({ uri, clientId });
|
|
183
|
+
return clientId;
|
|
184
|
+
}
|
|
185
|
+
|
|
78
186
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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.
|
|
187
|
+
* Register `clientId` on `driver`, retrying while the server still refuses it
|
|
188
|
+
* as a duplicate, for up to `boundMs`; the last refusal is thrown.
|
|
84
189
|
*/
|
|
85
|
-
|
|
190
|
+
async function registerWithin<TTransfer extends TransferElement, TDiagnostic extends TransferDiagnostic>(
|
|
191
|
+
driver: DataConformanceDriver<TTransfer, TDiagnostic>,
|
|
192
|
+
clientId: string,
|
|
193
|
+
boundMs: number
|
|
194
|
+
): Promise<void> {
|
|
195
|
+
const deadline = Date.now() + boundMs;
|
|
196
|
+
for (;;) {
|
|
197
|
+
const refusal = await rejectionOf(driver.proxy.createSession({ clientId }));
|
|
198
|
+
if (refusal === undefined) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (!isDuplicateClientIdError(refusal) || Date.now() >= deadline) {
|
|
202
|
+
throw refusal;
|
|
203
|
+
}
|
|
204
|
+
await new Promise(resolveDelay => setTimeout(resolveDelay, 20));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A URI beside `uri` in its folder, its file name prefixed with `prefix`, so the language and project stay the fixture's. */
|
|
209
|
+
function siblingOf(uri: string, prefix: string): string {
|
|
210
|
+
const slash = uri.lastIndexOf('/') + 1;
|
|
211
|
+
return `${uri.slice(0, slash)}${prefix}${uri.slice(slash)}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Read `uri` until `settled` holds of the answer, for up to two seconds; fail
|
|
216
|
+
* with `message` after that. For an effect a head may finish after its request
|
|
217
|
+
* answered, such as the revert of a last close. A refused read reaches
|
|
218
|
+
* `settled` as `undefined`.
|
|
219
|
+
*/
|
|
220
|
+
async function readUntil<TTransfer extends TransferElement, TDiagnostic extends TransferDiagnostic>(
|
|
221
|
+
driver: DataConformanceDriver<TTransfer, TDiagnostic>,
|
|
222
|
+
uri: string,
|
|
223
|
+
settled: (document: TransferDocument<TTransfer, TDiagnostic> | undefined) => boolean,
|
|
224
|
+
message: string
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
const deadline = Date.now() + 2_000;
|
|
227
|
+
while (!settled(await driver.proxy.getModelDocument({ uri }).catch(() => undefined))) {
|
|
228
|
+
assert.ok(Date.now() < deadline, message);
|
|
229
|
+
await new Promise(resolveDelay => setTimeout(resolveDelay, 20));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Settle `promise` into its rejection, or `undefined` when it resolved. */
|
|
234
|
+
async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
|
|
235
|
+
return promise.then(
|
|
236
|
+
() => undefined,
|
|
237
|
+
(error: unknown) => error
|
|
238
|
+
);
|
|
239
|
+
}
|
|
86
240
|
|
|
87
241
|
/**
|
|
88
242
|
* Build the data-server check battery: server-level checks once, then the
|
|
@@ -91,13 +245,13 @@ const SEEDER = 'conformance-seeder';
|
|
|
91
245
|
* unit tests; adopters call `runDataConformance`.
|
|
92
246
|
*
|
|
93
247
|
* `LanguageFixture.edit` is read HERE and nowhere else in the kit, and is
|
|
94
|
-
* optional:
|
|
248
|
+
* optional: every check that needs one reports skipped when it is absent. See
|
|
95
249
|
* {@link LanguageFixture} for which slice reads which field.
|
|
96
250
|
*/
|
|
97
251
|
export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic extends TransferDiagnostic = TransferDiagnostic>(
|
|
98
252
|
options: DataConformanceOptions<TTransfer, TDiagnostic>
|
|
99
253
|
): ConformanceCheck[] {
|
|
100
|
-
const { connect, expectsProjects } = options;
|
|
254
|
+
const { connect, attach, expectsProjects } = options;
|
|
101
255
|
const checks: ConformanceCheck[] = [];
|
|
102
256
|
|
|
103
257
|
// Split from `waitForReady` so a failure names which of the two broke.
|
|
@@ -162,6 +316,46 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
|
|
|
162
316
|
: undefined
|
|
163
317
|
});
|
|
164
318
|
|
|
319
|
+
checks.push({
|
|
320
|
+
title: 'createSession refuses an id already live, and frees it once the session ends',
|
|
321
|
+
body: async () => {
|
|
322
|
+
const driver = await connect();
|
|
323
|
+
try {
|
|
324
|
+
const clientId = await startSession(driver, 'conformance-session');
|
|
325
|
+
const duplicate = await rejectionOf(driver.proxy.createSession({ clientId }));
|
|
326
|
+
assert.ok(isDuplicateClientIdError(duplicate), `a second createSession under a live id was not refused: ${String(duplicate)}`);
|
|
327
|
+
await driver.proxy.closeSession({ clientId });
|
|
328
|
+
// The other half: a head that refused every id would pass the
|
|
329
|
+
// assertion above on its own.
|
|
330
|
+
await driver.proxy.createSession({ clientId });
|
|
331
|
+
await driver.proxy.closeSession({ clientId });
|
|
332
|
+
} finally {
|
|
333
|
+
driver.dispose();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
checks.push({
|
|
339
|
+
title: 'createSession refuses every id the framework reserves, not as a duplicate',
|
|
340
|
+
body: async () => {
|
|
341
|
+
const driver = await connect();
|
|
342
|
+
try {
|
|
343
|
+
// Every id: a head that refuses only some passes a single case. A
|
|
344
|
+
// reserved id never frees up, so a caller retrying on a duplicate
|
|
345
|
+
// has to be able to tell this refusal apart.
|
|
346
|
+
for (const clientId of FRAMEWORK_CLIENT_IDS) {
|
|
347
|
+
const refusal = await rejectionOf(driver.proxy.createSession({ clientId }));
|
|
348
|
+
assert.ok(
|
|
349
|
+
isReservedClientIdError(refusal),
|
|
350
|
+
`createSession under the reserved id ${clientId} was not refused as reserved: ${String(refusal)}`
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
} finally {
|
|
354
|
+
driver.dispose();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
165
359
|
checks.push({
|
|
166
360
|
title: 'waitForReady resolves rather than hanging or rejecting',
|
|
167
361
|
body: async () => {
|
|
@@ -178,7 +372,7 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
|
|
|
178
372
|
});
|
|
179
373
|
|
|
180
374
|
for (const language of options.languages) {
|
|
181
|
-
const { valid, invalid, edit } = language;
|
|
375
|
+
const { valid, invalid, edit, dependent } = language;
|
|
182
376
|
const tag = `[${valid.languageId}]`;
|
|
183
377
|
|
|
184
378
|
checks.push({
|
|
@@ -189,7 +383,7 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
|
|
|
189
383
|
// Resolved AFTER `connect`, which is the whole point of allowing a
|
|
190
384
|
// thunk: the fixture may name a workspace `connect` just created.
|
|
191
385
|
const model = resolveModel(valid);
|
|
192
|
-
await driver
|
|
386
|
+
await seed(driver, model);
|
|
193
387
|
// `includeDiagnostics` for the same reason the invalid check
|
|
194
388
|
// passes it: a synchronous read settles at the integrity-settled
|
|
195
389
|
// phase, so an empty array without it can mean "validation has
|
|
@@ -200,7 +394,8 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
|
|
|
200
394
|
// a shaped-but-contentless envelope for a document it did parse
|
|
201
395
|
// would pass. `$type` is the one field every TransferElement
|
|
202
396
|
// carries, so it is assertable with no fixture knowledge.
|
|
203
|
-
const {
|
|
397
|
+
const { model: snapshot } = TransferDocument.assertLoaded(document);
|
|
398
|
+
const { root } = snapshot;
|
|
204
399
|
assert.ok(
|
|
205
400
|
typeof root.$type === 'string' && root.$type.length > 0,
|
|
206
401
|
`getModelDocument(valid) returned a root with no $type: ${JSON.stringify(root)}`
|
|
@@ -208,10 +403,11 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
|
|
|
208
403
|
// `version` is the conflict token every later update gates on, so
|
|
209
404
|
// an envelope that omits it is unusable however good the root is.
|
|
210
405
|
assert.ok(
|
|
211
|
-
Number.isInteger(
|
|
212
|
-
`getModelDocument(valid) returned a non-integer version: ${String(
|
|
406
|
+
Number.isInteger(snapshot.version),
|
|
407
|
+
`getModelDocument(valid) returned a non-integer version: ${String(snapshot.version)}`
|
|
213
408
|
);
|
|
214
|
-
assert.
|
|
409
|
+
assert.strictEqual(typeof snapshot.hash, 'string', 'getModelDocument(valid) returned a model with no hash');
|
|
410
|
+
assert.deepStrictEqual(snapshot.diagnostics, []);
|
|
215
411
|
} finally {
|
|
216
412
|
driver.dispose();
|
|
217
413
|
}
|
|
@@ -224,21 +420,454 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
|
|
|
224
420
|
const driver = await connect();
|
|
225
421
|
try {
|
|
226
422
|
const model = resolveModel(invalid);
|
|
227
|
-
await driver
|
|
423
|
+
await seed(driver, model);
|
|
228
424
|
// Diagnostics are a validation-phase product; a synchronous read settles at the
|
|
229
425
|
// integrity-settled phase by default, so request validation explicitly here.
|
|
230
426
|
// Safe despite `includeDiagnostics` waiting rather than forcing a build: the
|
|
231
427
|
// update above has already driven this document through validation.
|
|
232
428
|
const document = await driver.proxy.getModelDocument({ uri: model.uri, includeDiagnostics: true });
|
|
233
|
-
assert.ok(document.diagnostics
|
|
429
|
+
assert.ok((document.model?.diagnostics?.length ?? 0) >= 1, 'getModelDocument(invalid) reported no diagnostics');
|
|
234
430
|
} finally {
|
|
235
431
|
driver.dispose();
|
|
236
432
|
}
|
|
237
433
|
}
|
|
238
434
|
});
|
|
239
435
|
|
|
240
|
-
|
|
241
|
-
|
|
436
|
+
checks.push({
|
|
437
|
+
title: `a diagnostic carrying a framework message code also carries its params ${tag}`,
|
|
438
|
+
body: async () => {
|
|
439
|
+
const driver = await connect();
|
|
440
|
+
try {
|
|
441
|
+
const model = resolveModel(invalid);
|
|
442
|
+
await seed(driver, model);
|
|
443
|
+
const document = await driver.proxy.getModelDocument({ uri: model.uri, includeDiagnostics: true });
|
|
444
|
+
|
|
445
|
+
// Conditional rather than fixture-driven, and deliberately so: what
|
|
446
|
+
// an `invalid` fixture provokes is the adopter's choice, and a
|
|
447
|
+
// syntactic error legitimately carries no identity at all. The
|
|
448
|
+
// invariant is that the identity travels WHOLE or not — a `code`
|
|
449
|
+
// without `params` is the half-state that renders a translated
|
|
450
|
+
// template with its placeholders left standing, and it is
|
|
451
|
+
// reachable only by overriding `toTransferDiagnostic`.
|
|
452
|
+
const halfIdentities = (document.model?.diagnostics ?? []).filter(
|
|
453
|
+
diagnostic =>
|
|
454
|
+
typeof diagnostic.code === 'string' && diagnostic.code.startsWith('hydranium/') && diagnostic.params === undefined
|
|
455
|
+
);
|
|
456
|
+
assert.deepStrictEqual(
|
|
457
|
+
halfIdentities.map(diagnostic => diagnostic.code),
|
|
458
|
+
[],
|
|
459
|
+
'diagnostics carry a framework message code with no params, so a translating surface cannot render them'
|
|
460
|
+
);
|
|
461
|
+
} finally {
|
|
462
|
+
driver.dispose();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
checks.push({
|
|
468
|
+
title: `a session writes only a document it has open ${tag}`,
|
|
469
|
+
body: async () => {
|
|
470
|
+
const driver = await connect();
|
|
471
|
+
try {
|
|
472
|
+
const model = resolveModel(valid);
|
|
473
|
+
await seed(driver, model);
|
|
474
|
+
const clientId = await startSession(driver, 'conformance-session');
|
|
475
|
+
const write = { uri: model.uri, clientId, model: model.text, baseVersion: 'any' } as const;
|
|
476
|
+
|
|
477
|
+
const unopened = await rejectionOf(driver.proxy.updateModelDocument(write));
|
|
478
|
+
assert.ok(isDocumentNotOpenError(unopened), `a session wrote a document it never opened: ${String(unopened)}`);
|
|
479
|
+
await driver.proxy.openModelDocument({ uri: model.uri, clientId });
|
|
480
|
+
await driver.proxy.updateModelDocument(write);
|
|
481
|
+
await driver.proxy.closeModelDocument({ uri: model.uri, clientId });
|
|
482
|
+
const closed = await rejectionOf(driver.proxy.updateModelDocument(write));
|
|
483
|
+
assert.ok(isDocumentNotOpenError(closed), `a session wrote a document after closing it: ${String(closed)}`);
|
|
484
|
+
await driver.proxy.closeSession({ clientId });
|
|
485
|
+
} finally {
|
|
486
|
+
driver.dispose();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
checks.push({
|
|
492
|
+
title: `a session saves only a document it has open ${tag}`,
|
|
493
|
+
body: async () => {
|
|
494
|
+
const driver = await connect();
|
|
495
|
+
try {
|
|
496
|
+
const model = resolveModel(valid);
|
|
497
|
+
await seed(driver, model);
|
|
498
|
+
const clientId = await startSession(driver, 'conformance-session');
|
|
499
|
+
const save = { uri: model.uri, clientId, model: model.text, baseVersion: 'any' } as const;
|
|
500
|
+
|
|
501
|
+
const unopened = await rejectionOf(driver.proxy.saveModelDocument(save));
|
|
502
|
+
assert.ok(isDocumentNotOpenError(unopened), `a session saved a document it never opened: ${String(unopened)}`);
|
|
503
|
+
// The other half: a head refusing every session save would pass
|
|
504
|
+
// the assertion above on its own.
|
|
505
|
+
await driver.proxy.openModelDocument({ uri: model.uri, clientId });
|
|
506
|
+
await driver.proxy.saveModelDocument(save);
|
|
507
|
+
await driver.proxy.closeSession({ clientId });
|
|
508
|
+
} finally {
|
|
509
|
+
driver.dispose();
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
checks.push({
|
|
515
|
+
title: `a document request under an id no session was registered for fails ${tag}`,
|
|
516
|
+
body: async () => {
|
|
517
|
+
const driver = await connect();
|
|
518
|
+
try {
|
|
519
|
+
const model = resolveModel(valid);
|
|
520
|
+
await seed(driver, model);
|
|
521
|
+
const clientId = sessionId('conformance-unregistered');
|
|
522
|
+
|
|
523
|
+
// Opened or written under an id that is no session, a document
|
|
524
|
+
// would stay open for a client whose end nothing ever reports.
|
|
525
|
+
const open = await rejectionOf(driver.proxy.openModelDocument({ uri: model.uri, clientId }));
|
|
526
|
+
assert.ok(isSessionClosedError(open), `a document opened under an unregistered id: ${String(open)}`);
|
|
527
|
+
const write = await rejectionOf(
|
|
528
|
+
driver.proxy.updateModelDocument({ uri: model.uri, clientId, model: model.text, baseVersion: 'any' })
|
|
529
|
+
);
|
|
530
|
+
assert.ok(isSessionClosedError(write), `a document was written under an unregistered id: ${String(write)}`);
|
|
531
|
+
} finally {
|
|
532
|
+
driver.dispose();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
checks.push({
|
|
538
|
+
title: `ending a connection ends its sessions ${tag}`,
|
|
539
|
+
skipReason: attach ? undefined : 'options supply no `attach` (a second connection to the same server outlives the ended one)',
|
|
540
|
+
body: attach
|
|
541
|
+
? async () => {
|
|
542
|
+
const driver = await connect();
|
|
543
|
+
let driverOpen = true;
|
|
544
|
+
try {
|
|
545
|
+
const model = resolveModel(valid);
|
|
546
|
+
await seed(driver, model);
|
|
547
|
+
const clientId = await startSession(driver, 'conformance-session');
|
|
548
|
+
await driver.proxy.openModelDocument({ uri: model.uri, clientId });
|
|
549
|
+
const sibling = await attach(driver);
|
|
550
|
+
try {
|
|
551
|
+
driverOpen = false;
|
|
552
|
+
driver.dispose();
|
|
553
|
+
// Refused as a duplicate while the ended connection's
|
|
554
|
+
// session lives on, which over a socket it does until the
|
|
555
|
+
// server has seen the close; so retried, within a bound.
|
|
556
|
+
await registerWithin(sibling, clientId, 2_000);
|
|
557
|
+
const write = await rejectionOf(
|
|
558
|
+
sibling.proxy.updateModelDocument({ uri: model.uri, clientId, model: model.text, baseVersion: 'any' })
|
|
559
|
+
);
|
|
560
|
+
assert.ok(isDocumentNotOpenError(write), `a document stayed open after its connection ended: ${String(write)}`);
|
|
561
|
+
await sibling.proxy.closeSession({ clientId });
|
|
562
|
+
} finally {
|
|
563
|
+
sibling.dispose();
|
|
564
|
+
}
|
|
565
|
+
} finally {
|
|
566
|
+
if (driverOpen) {
|
|
567
|
+
driver.dispose();
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
: undefined
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
checks.push({
|
|
575
|
+
title: `closeSession closes every document the session had open ${tag}`,
|
|
576
|
+
body: async () => {
|
|
577
|
+
const driver = await connect();
|
|
578
|
+
try {
|
|
579
|
+
const model = resolveModel(valid);
|
|
580
|
+
await seed(driver, model);
|
|
581
|
+
const clientId = await startSession(driver, 'conformance-session');
|
|
582
|
+
await driver.proxy.openModelDocument({ uri: model.uri, clientId });
|
|
583
|
+
|
|
584
|
+
await driver.proxy.closeSession({ clientId });
|
|
585
|
+
|
|
586
|
+
// Observed through a new session under the same id: one that
|
|
587
|
+
// inherited the ended session's open would write without opening.
|
|
588
|
+
await driver.proxy.createSession({ clientId });
|
|
589
|
+
const write = await rejectionOf(
|
|
590
|
+
driver.proxy.updateModelDocument({ uri: model.uri, clientId, model: model.text, baseVersion: 'any' })
|
|
591
|
+
);
|
|
592
|
+
assert.ok(isDocumentNotOpenError(write), `a document stayed open after its session ended: ${String(write)}`);
|
|
593
|
+
await driver.proxy.closeSession({ clientId });
|
|
594
|
+
} finally {
|
|
595
|
+
driver.dispose();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
checks.push({
|
|
601
|
+
title: `createModelDocument creates a document open for the session, and refuses one that exists ${tag}`,
|
|
602
|
+
body: async () => {
|
|
603
|
+
const driver = await connect();
|
|
604
|
+
try {
|
|
605
|
+
const model = resolveModel(valid);
|
|
606
|
+
await seed(driver, model);
|
|
607
|
+
const clientId = await startSession(driver, 'conformance-session');
|
|
608
|
+
const createdUri = siblingOf(model.uri, 'conformance-created-');
|
|
609
|
+
|
|
610
|
+
const created = await driver.proxy.createModelDocument({ uri: createdUri, clientId, text: model.text });
|
|
611
|
+
const { model: createdModel } = TransferDocument.assertLoaded(created);
|
|
612
|
+
// Written without an open: only the create can have opened it.
|
|
613
|
+
await driver.proxy.updateModelDocument({ uri: createdUri, clientId, model: model.text, baseVersion: createdModel.version });
|
|
614
|
+
|
|
615
|
+
const existing = await rejectionOf(driver.proxy.createModelDocument({ uri: model.uri, clientId, text: model.text }));
|
|
616
|
+
assert.ok(existing !== undefined, `createModelDocument accepted ${model.uri}, which already exists`);
|
|
617
|
+
await driver.proxy.closeSession({ clientId });
|
|
618
|
+
} finally {
|
|
619
|
+
driver.dispose();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
checks.push({
|
|
625
|
+
title: `a write of the invalid model answers with its diagnostics ${tag}`,
|
|
626
|
+
body: async () => {
|
|
627
|
+
const driver = await connect();
|
|
628
|
+
try {
|
|
629
|
+
const model = resolveModel(invalid);
|
|
630
|
+
const clientId = await startSession(driver, 'conformance-session');
|
|
631
|
+
await openOrCreate(driver, clientId, model);
|
|
632
|
+
// Another text first, so that the invalid one is a change: the
|
|
633
|
+
// answer to a write that changes nothing can carry the
|
|
634
|
+
// diagnostics of an earlier validation. This does not prove the
|
|
635
|
+
// answer waited for this write's validation, only that it
|
|
636
|
+
// carries diagnostics at all.
|
|
637
|
+
const firstText = resolveDeferred(valid.text);
|
|
638
|
+
await driver.proxy.updateModelDocument({ uri: model.uri, clientId, model: firstText, baseVersion: 'any' });
|
|
639
|
+
// The answer alone, with no read after it: a caller that shows
|
|
640
|
+
// the problems of what it wrote should not need a second
|
|
641
|
+
// request, and a read could find a later write's state.
|
|
642
|
+
const answer = await driver.proxy.updateModelDocument({ uri: model.uri, clientId, model: model.text, baseVersion: 'any' });
|
|
643
|
+
assert.ok((answer.model?.diagnostics?.length ?? 0) >= 1, 'writing the invalid model answered with no diagnostics');
|
|
644
|
+
await driver.proxy.closeSession({ clientId });
|
|
645
|
+
} finally {
|
|
646
|
+
driver.dispose();
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
checks.push({
|
|
652
|
+
title: `a created document never saved leaves with its last close ${tag}`,
|
|
653
|
+
body: async () => {
|
|
654
|
+
const driver = await connect();
|
|
655
|
+
try {
|
|
656
|
+
const model = resolveModel(valid);
|
|
657
|
+
const uri = siblingOf(model.uri, 'conformance-unsaved-');
|
|
658
|
+
const clientId = await startSession(driver, 'conformance-session');
|
|
659
|
+
await driver.proxy.createModelDocument({ uri, clientId, text: model.text });
|
|
660
|
+
// Served before the close, so an envelope without a model after
|
|
661
|
+
// it is the close's doing and not the head's answer for every
|
|
662
|
+
// created document.
|
|
663
|
+
TransferDocument.assertLoaded(await driver.proxy.getModelDocument({ uri }));
|
|
664
|
+
await driver.proxy.closeSession({ clientId });
|
|
665
|
+
// It has no file to go back to, so no text is left to serve, and
|
|
666
|
+
// the protocol answers a URI with no document with an envelope
|
|
667
|
+
// that has no model, not with a refusal.
|
|
668
|
+
await readUntil(
|
|
669
|
+
driver,
|
|
670
|
+
uri,
|
|
671
|
+
document => document !== undefined && !TransferDocument.isLoaded(document),
|
|
672
|
+
`${uri}, created and never saved, was not answered with an envelope without a model after its last close`
|
|
673
|
+
);
|
|
674
|
+
} finally {
|
|
675
|
+
driver.dispose();
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
checks.push({
|
|
681
|
+
title: `a document is dirty while its text differs from its file, and clean once saved ${tag}`,
|
|
682
|
+
body: async () => {
|
|
683
|
+
const driver = await connect();
|
|
684
|
+
try {
|
|
685
|
+
const model = resolveModel(valid);
|
|
686
|
+
// Only `dirty` is asserted, so any second text serves.
|
|
687
|
+
const unsaved = resolveDeferred(invalid.text);
|
|
688
|
+
const uri = siblingOf(model.uri, `conformance-saved-${globalThis.crypto.randomUUID()}-`);
|
|
689
|
+
const clientId = await startSession(driver, 'conformance-session');
|
|
690
|
+
await driver.proxy.createModelDocument({ uri, clientId, text: model.text });
|
|
691
|
+
|
|
692
|
+
const saved = await driver.proxy.saveModelDocument({ uri, clientId, model: model.text, baseVersion: 'any' });
|
|
693
|
+
assert.strictEqual(saved.text?.dirty, false, `the save of ${uri} answered dirty: ${String(saved.text?.dirty)}, not false`);
|
|
694
|
+
assert.strictEqual(
|
|
695
|
+
saved.persisted?.version,
|
|
696
|
+
saved.text?.version,
|
|
697
|
+
`the save of ${uri} answered it wrote v${String(saved.persisted?.version)}, not the v${String(saved.text?.version)} it holds`
|
|
698
|
+
);
|
|
699
|
+
const edited = await driver.proxy.updateModelDocument({ uri, clientId, model: unsaved, baseVersion: 'any' });
|
|
700
|
+
assert.strictEqual(
|
|
701
|
+
edited.text?.dirty,
|
|
702
|
+
true,
|
|
703
|
+
`an unsaved write of ${uri} answered dirty: ${String(edited.text?.dirty)}, not true`
|
|
704
|
+
);
|
|
705
|
+
const resaved = await driver.proxy.saveModelDocument({ uri, clientId, model: unsaved, baseVersion: 'any' });
|
|
706
|
+
assert.strictEqual(
|
|
707
|
+
resaved.text?.dirty,
|
|
708
|
+
false,
|
|
709
|
+
`the second save of ${uri} answered dirty: ${String(resaved.text?.dirty)}, not false`
|
|
710
|
+
);
|
|
711
|
+
await driver.proxy.closeSession({ clientId });
|
|
712
|
+
} finally {
|
|
713
|
+
driver.dispose();
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
checks.push({
|
|
719
|
+
title: `persisting writes the text a document holds as it is, and refuses a stale base version ${tag}`,
|
|
720
|
+
body: async () => {
|
|
721
|
+
const driver = await connect();
|
|
722
|
+
try {
|
|
723
|
+
const model = resolveModel(valid);
|
|
724
|
+
// Trailing blank lines no serialiser writes, so a head that
|
|
725
|
+
// persists a re-serialised model rather than the text shows.
|
|
726
|
+
const held = `${model.text}\n\n`;
|
|
727
|
+
const uri = siblingOf(model.uri, `conformance-persisted-${globalThis.crypto.randomUUID()}-`);
|
|
728
|
+
const writer = await startSession(driver, 'conformance-writer');
|
|
729
|
+
const created = await driver.proxy.createModelDocument({ uri, clientId: writer, text: model.text });
|
|
730
|
+
const written = await driver.proxy.updateModelDocument({ uri, clientId: writer, model: held, baseVersion: 'any' });
|
|
731
|
+
const persister = await openAs(driver, uri, 'conformance-persister');
|
|
732
|
+
|
|
733
|
+
const stale = await rejectionOf(
|
|
734
|
+
driver.proxy.persistModelDocument({
|
|
735
|
+
uri,
|
|
736
|
+
clientId: persister,
|
|
737
|
+
baseVersion: TransferDocument.assertLoaded(created).model.version
|
|
738
|
+
})
|
|
739
|
+
);
|
|
740
|
+
assert.ok(isConflictError(stale), `persisting ${uri} on a stale base version did not conflict: ${String(stale)}`);
|
|
741
|
+
const base = TransferDocument.assertLoaded(written).model.version;
|
|
742
|
+
const persisted = await driver.proxy.persistModelDocument({ uri, clientId: persister, baseVersion: base });
|
|
743
|
+
assert.strictEqual(
|
|
744
|
+
persisted.persisted?.version,
|
|
745
|
+
base,
|
|
746
|
+
`the persist of ${uri} answered it wrote v${String(persisted.persisted?.version)}, not the v${base} it was based on`
|
|
747
|
+
);
|
|
748
|
+
assert.strictEqual(persisted.text?.dirty, false, `the persist of ${uri} answered dirty: ${String(persisted.text?.dirty)}`);
|
|
749
|
+
assert.strictEqual(persisted.text?.hash, written.text?.hash, `persisting ${uri} changed the text it holds`);
|
|
750
|
+
|
|
751
|
+
// Read after the last close, which serves the file: read before
|
|
752
|
+
// it, the held text answers and a file written wrong passes.
|
|
753
|
+
await driver.proxy.closeSession({ clientId: writer });
|
|
754
|
+
await driver.proxy.closeSession({ clientId: persister });
|
|
755
|
+
const reader = await openAs(driver, uri, 'conformance-reader');
|
|
756
|
+
const reread = await driver.proxy.getModelDocument({ uri });
|
|
757
|
+
assert.strictEqual(reread.text?.hash, written.text?.hash, `the file persisted for ${uri} is not the text it held`);
|
|
758
|
+
await driver.proxy.closeSession({ clientId: reader });
|
|
759
|
+
} finally {
|
|
760
|
+
driver.dispose();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
checks.push({
|
|
766
|
+
title: `a document's text hash is equal for equal text and differs for different text ${tag}`,
|
|
767
|
+
body: async () => {
|
|
768
|
+
const driver = await connect();
|
|
769
|
+
try {
|
|
770
|
+
const model = resolveModel(valid);
|
|
771
|
+
// Only the text is compared, so any second text serves.
|
|
772
|
+
const other = resolveDeferred(invalid.text);
|
|
773
|
+
const uri = siblingOf(model.uri, `conformance-hashed-${globalThis.crypto.randomUUID()}-`);
|
|
774
|
+
const clientId = await startSession(driver, 'conformance-session');
|
|
775
|
+
const created = await driver.proxy.createModelDocument({ uri, clientId, text: model.text });
|
|
776
|
+
const edited = await driver.proxy.updateModelDocument({ uri, clientId, model: other, baseVersion: 'any' });
|
|
777
|
+
// Back to the first text at a later version: the hash follows
|
|
778
|
+
// the text, which is what a client compares across a revert.
|
|
779
|
+
const restored = await driver.proxy.updateModelDocument({ uri, clientId, model: model.text, baseVersion: 'any' });
|
|
780
|
+
|
|
781
|
+
assert.strictEqual(typeof created.text?.hash, 'string', `the create of ${uri} answered with no text hash`);
|
|
782
|
+
assert.notStrictEqual(
|
|
783
|
+
edited.text?.hash,
|
|
784
|
+
created.text?.hash,
|
|
785
|
+
`${uri} hashed a different text alike: ${String(edited.text?.hash)}`
|
|
786
|
+
);
|
|
787
|
+
assert.strictEqual(
|
|
788
|
+
restored.text?.hash,
|
|
789
|
+
created.text?.hash,
|
|
790
|
+
`${uri} hashed its first text again as ${String(restored.text?.hash)}`
|
|
791
|
+
);
|
|
792
|
+
const read = await driver.proxy.getModelDocument({ uri });
|
|
793
|
+
assert.strictEqual(read.text?.hash, restored.text?.hash, `a read of ${uri} hashed its text as ${String(read.text?.hash)}`);
|
|
794
|
+
const absent = await driver.proxy.getModelDocument({
|
|
795
|
+
uri: siblingOf(model.uri, `conformance-absent-${globalThis.crypto.randomUUID()}-`)
|
|
796
|
+
});
|
|
797
|
+
assert.strictEqual(absent.text, undefined, `an envelope with no document carried text ${JSON.stringify(absent.text)}`);
|
|
798
|
+
await driver.proxy.closeSession({ clientId });
|
|
799
|
+
} finally {
|
|
800
|
+
driver.dispose();
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
// The create-dialog query, and the reason it is its own check: it is the
|
|
806
|
+
// only request a head receives whose URI names no file. Everything else
|
|
807
|
+
// in this battery addresses a document, so a head that routes purely by
|
|
808
|
+
// URI extension passes all of them and still cannot open a create dialog.
|
|
809
|
+
//
|
|
810
|
+
// Doubly opt-in — the fixture must name a query AND the driver must serve
|
|
811
|
+
// the reference surface — because either half missing means the head never
|
|
812
|
+
// claimed this behaviour. The reasons are reported separately so a skip
|
|
813
|
+
// says which half is absent.
|
|
814
|
+
const referenceQuery = language.referenceQuery;
|
|
815
|
+
const referenceSkipReason = !referenceQuery
|
|
816
|
+
? 'fixture supplies no `referenceQuery` (omit it if this language has no create-element dialog)'
|
|
817
|
+
: undefined;
|
|
818
|
+
|
|
819
|
+
checks.push({
|
|
820
|
+
title: `findReferenceCandidates answers for a synthetic source at a folder URI ${tag}`,
|
|
821
|
+
skipReason: referenceSkipReason,
|
|
822
|
+
body: referenceQuery
|
|
823
|
+
? async () => {
|
|
824
|
+
const driver = await connect();
|
|
825
|
+
try {
|
|
826
|
+
if (!driver.references) {
|
|
827
|
+
// Checked here rather than in `skipReason`: the driver only
|
|
828
|
+
// exists once `connect` has run, and skip reasons are
|
|
829
|
+
// computed while the battery is being planned.
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
const model = resolveModel(valid);
|
|
833
|
+
await seed(driver, model);
|
|
834
|
+
|
|
835
|
+
// Default to the folder holding the valid model: a sibling of
|
|
836
|
+
// it is where a create flow would put the new file.
|
|
837
|
+
const folderUri = referenceQuery.folderUri
|
|
838
|
+
? resolveDeferred(referenceQuery.folderUri)
|
|
839
|
+
: model.uri.slice(0, model.uri.lastIndexOf('/'));
|
|
840
|
+
assert.ok(
|
|
841
|
+
folderUri.length > 0 && folderUri !== model.uri,
|
|
842
|
+
`could not derive a folder URI from ${model.uri}; supply referenceQuery.folderUri`
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
const candidates = await driver.references.findReferenceCandidates({
|
|
846
|
+
source: ReferenceSource.synthetic(folderUri, referenceQuery.type),
|
|
847
|
+
syntheticPath: referenceQuery.path?.map(([containerProperty, type]) => SyntheticStep.of(containerProperty, type)),
|
|
848
|
+
property: referenceQuery.property
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
// The expected label, not merely a non-empty array: the defect
|
|
852
|
+
// this guards against answers `[]`, and an empty result is
|
|
853
|
+
// also what a head with a genuinely empty index answers, so
|
|
854
|
+
// only a named candidate tells the two apart.
|
|
855
|
+
const labels = candidates.map(candidate => candidate.label);
|
|
856
|
+
assert.ok(
|
|
857
|
+
labels.includes(referenceQuery.expectCandidate),
|
|
858
|
+
`findReferenceCandidates at the folder ${folderUri} did not offer ${JSON.stringify(
|
|
859
|
+
referenceQuery.expectCandidate
|
|
860
|
+
)}; got [${labels.join(', ')}]`
|
|
861
|
+
);
|
|
862
|
+
} finally {
|
|
863
|
+
driver.dispose();
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
: undefined
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
// Opt-in: a check that needs a second, observably different model text
|
|
870
|
+
// takes it from `edit`, and skips without one.
|
|
242
871
|
const editSkipReason = 'fixture supplies no `edit` (data-slice only; omit it if this language is not driven through the data head)';
|
|
243
872
|
|
|
244
873
|
checks.push({
|
|
@@ -249,10 +878,72 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
|
|
|
249
878
|
const driver = await connect();
|
|
250
879
|
try {
|
|
251
880
|
const model = resolveModel(valid);
|
|
252
|
-
|
|
253
|
-
await driver.proxy.updateModelDocument({
|
|
881
|
+
const seeder = await seed(driver, model);
|
|
882
|
+
await driver.proxy.updateModelDocument({
|
|
883
|
+
uri: model.uri,
|
|
884
|
+
clientId: seeder,
|
|
885
|
+
model: resolveDeferred(edit.to),
|
|
886
|
+
baseVersion: 'any'
|
|
887
|
+
});
|
|
254
888
|
const document = await driver.proxy.getModelDocument({ uri: model.uri });
|
|
255
|
-
assert.ok(
|
|
889
|
+
assert.ok(
|
|
890
|
+
edit.expect(document.model?.root),
|
|
891
|
+
'edit.expect(root) was false — the edit was not reflected by a follow-up get'
|
|
892
|
+
);
|
|
893
|
+
} finally {
|
|
894
|
+
driver.dispose();
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
: undefined
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
checks.push({
|
|
901
|
+
title: `updateModelDocument arms the conflict gate on a base version ${tag}`,
|
|
902
|
+
skipReason: edit ? undefined : editSkipReason,
|
|
903
|
+
body: edit
|
|
904
|
+
? async () => {
|
|
905
|
+
const driver = await connect();
|
|
906
|
+
try {
|
|
907
|
+
const model = resolveModel(valid);
|
|
908
|
+
const seeder = await seed(driver, model);
|
|
909
|
+
const author = await openAs(driver, model.uri);
|
|
910
|
+
|
|
911
|
+
// The version the gate is meant to accept, read BEFORE the
|
|
912
|
+
// foreign edit that supersedes it.
|
|
913
|
+
const stale = await driver.proxy.getModelDocument({ uri: model.uri });
|
|
914
|
+
await driver.proxy.updateModelDocument({
|
|
915
|
+
uri: model.uri,
|
|
916
|
+
clientId: seeder,
|
|
917
|
+
model: resolveDeferred(edit.to),
|
|
918
|
+
baseVersion: 'any'
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
let rejection: unknown;
|
|
922
|
+
await driver.proxy
|
|
923
|
+
.updateModelDocument({
|
|
924
|
+
uri: model.uri,
|
|
925
|
+
clientId: author,
|
|
926
|
+
model: model.text,
|
|
927
|
+
baseVersion: TransferDocument.assertLoaded(stale).model.version
|
|
928
|
+
})
|
|
929
|
+
.catch((error: unknown) => {
|
|
930
|
+
rejection = error;
|
|
931
|
+
});
|
|
932
|
+
assert.ok(
|
|
933
|
+
isConflictError(rejection),
|
|
934
|
+
`a write based on the superseded v${String(stale.model?.version)} was not refused: ${String(rejection)}`
|
|
935
|
+
);
|
|
936
|
+
|
|
937
|
+
// The other half, and it is not optional: a head that refused
|
|
938
|
+
// EVERY write would satisfy the assertion above on its own, so
|
|
939
|
+
// the gate has to be shown accepting a current snapshot too.
|
|
940
|
+
const fresh = await driver.proxy.getModelDocument({ uri: model.uri });
|
|
941
|
+
await driver.proxy.updateModelDocument({
|
|
942
|
+
uri: model.uri,
|
|
943
|
+
clientId: author,
|
|
944
|
+
model: resolveDeferred(edit.to),
|
|
945
|
+
baseVersion: TransferDocument.assertLoaded(fresh).model.version
|
|
946
|
+
});
|
|
256
947
|
} finally {
|
|
257
948
|
driver.dispose();
|
|
258
949
|
}
|
|
@@ -260,6 +951,221 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
|
|
|
260
951
|
: undefined
|
|
261
952
|
});
|
|
262
953
|
|
|
954
|
+
checks.push({
|
|
955
|
+
title: `the last close drops a document's unsaved text and keeps what its save wrote ${tag}`,
|
|
956
|
+
skipReason: edit ? undefined : editSkipReason,
|
|
957
|
+
body: edit
|
|
958
|
+
? async () => {
|
|
959
|
+
const driver = await connect();
|
|
960
|
+
try {
|
|
961
|
+
const model = resolveModel(valid);
|
|
962
|
+
const edited = resolveDeferred(edit.to);
|
|
963
|
+
const uri = siblingOf(model.uri, `conformance-reverted-${globalThis.crypto.randomUUID()}-`);
|
|
964
|
+
const writer = await startSession(driver, 'conformance-writer');
|
|
965
|
+
// Created with the edit, so that going back to the created
|
|
966
|
+
// text instead of the saved one shows.
|
|
967
|
+
await driver.proxy.createModelDocument({ uri, clientId: writer, text: edited });
|
|
968
|
+
const saved = await driver.proxy.saveModelDocument({ uri, clientId: writer, model: model.text, baseVersion: 'any' });
|
|
969
|
+
assert.ok(
|
|
970
|
+
!edit.expect(saved.model?.root),
|
|
971
|
+
'edit.expect holds of the valid text, so this check cannot tell the edit apart'
|
|
972
|
+
);
|
|
973
|
+
const reader = await openAs(driver, uri, 'conformance-reader');
|
|
974
|
+
await driver.proxy.updateModelDocument({ uri, clientId: writer, model: edited, baseVersion: 'any' });
|
|
975
|
+
|
|
976
|
+
// Not the last close: the reader still shows the writer's
|
|
977
|
+
// text. Read at once, so a head that reverts after answering
|
|
978
|
+
// could still pass this half.
|
|
979
|
+
await driver.proxy.closeSession({ clientId: writer });
|
|
980
|
+
const held = await driver.proxy.getModelDocument({ uri });
|
|
981
|
+
assert.ok(edit.expect(held.model?.root), `closing one of two sessions dropped the unsaved text of ${uri}`);
|
|
982
|
+
|
|
983
|
+
await driver.proxy.closeSession({ clientId: reader });
|
|
984
|
+
await readUntil(
|
|
985
|
+
driver,
|
|
986
|
+
uri,
|
|
987
|
+
document => document?.model !== undefined && !edit.expect(document.model.root),
|
|
988
|
+
`${uri} did not go back to the text its save wrote after its last close`
|
|
989
|
+
);
|
|
990
|
+
} finally {
|
|
991
|
+
driver.dispose();
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
: undefined
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
// Opt-in twice over: the cascade needs an edit to provoke it AND a second
|
|
998
|
+
// document that references the first to be provoked.
|
|
999
|
+
const cascadeSkipReason =
|
|
1000
|
+
'fixture supplies no `dependent` (a document referencing `valid`), so no cascade can be provoked over the protocol';
|
|
1001
|
+
|
|
1002
|
+
checks.push({
|
|
1003
|
+
title: `updateModelDocuments writes a set all or none ${tag}`,
|
|
1004
|
+
skipReason:
|
|
1005
|
+
edit && dependent
|
|
1006
|
+
? undefined
|
|
1007
|
+
: dependent
|
|
1008
|
+
? editSkipReason
|
|
1009
|
+
: 'fixture supplies no `dependent`, the second document a set needs besides `valid`',
|
|
1010
|
+
body:
|
|
1011
|
+
edit && dependent
|
|
1012
|
+
? async () => {
|
|
1013
|
+
const driver = await connect();
|
|
1014
|
+
try {
|
|
1015
|
+
const model = resolveModel(valid);
|
|
1016
|
+
const other = resolveModel(dependent);
|
|
1017
|
+
await seed(driver, model);
|
|
1018
|
+
await seed(driver, other);
|
|
1019
|
+
const clientId = await startSession(driver, 'conformance-session');
|
|
1020
|
+
const { model: first } = TransferDocument.assertLoaded(
|
|
1021
|
+
await driver.proxy.openModelDocument({ uri: model.uri, clientId })
|
|
1022
|
+
);
|
|
1023
|
+
const { model: second } = TransferDocument.assertLoaded(
|
|
1024
|
+
await driver.proxy.openModelDocument({ uri: other.uri, clientId })
|
|
1025
|
+
);
|
|
1026
|
+
const edited = resolveDeferred(edit.to);
|
|
1027
|
+
|
|
1028
|
+
// The stale member comes LAST, so a head that checks and
|
|
1029
|
+
// applies one document at a time has applied the first.
|
|
1030
|
+
const stale = await rejectionOf(
|
|
1031
|
+
driver.proxy.updateModelDocuments({
|
|
1032
|
+
clientId,
|
|
1033
|
+
updates: [
|
|
1034
|
+
{ uri: model.uri, model: edited, baseVersion: first.version },
|
|
1035
|
+
{ uri: other.uri, model: other.text, baseVersion: asModelVersion(second.version + 1) }
|
|
1036
|
+
]
|
|
1037
|
+
})
|
|
1038
|
+
);
|
|
1039
|
+
assert.ok(isConflictError(stale), `a set with a stale member was not refused: ${String(stale)}`);
|
|
1040
|
+
const untouched = await driver.proxy.getModelDocument({ uri: model.uri });
|
|
1041
|
+
assert.strictEqual(untouched.model?.version, first.version, 'a refused set applied its first document');
|
|
1042
|
+
|
|
1043
|
+
await driver.proxy.updateModelDocuments({
|
|
1044
|
+
clientId,
|
|
1045
|
+
updates: [
|
|
1046
|
+
{ uri: model.uri, model: edited, baseVersion: first.version },
|
|
1047
|
+
{ uri: other.uri, model: other.text, baseVersion: second.version }
|
|
1048
|
+
]
|
|
1049
|
+
});
|
|
1050
|
+
const applied = await driver.proxy.getModelDocument({ uri: model.uri });
|
|
1051
|
+
assert.ok(edit.expect(applied.model?.root), 'edit.expect(root) was false — a current set was not applied');
|
|
1052
|
+
await driver.proxy.closeSession({ clientId });
|
|
1053
|
+
} finally {
|
|
1054
|
+
driver.dispose();
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
: undefined
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
checks.push({
|
|
1061
|
+
title: `editing a document reports its unwatched dependent as built ${tag}`,
|
|
1062
|
+
skipReason: edit && dependent ? undefined : dependent ? editSkipReason : cascadeSkipReason,
|
|
1063
|
+
body:
|
|
1064
|
+
edit && dependent
|
|
1065
|
+
? async () => {
|
|
1066
|
+
const driver = await connect();
|
|
1067
|
+
try {
|
|
1068
|
+
const model = resolveModel(valid);
|
|
1069
|
+
const other = resolveModel(dependent);
|
|
1070
|
+
await seed(driver, model);
|
|
1071
|
+
await seed(driver, other);
|
|
1072
|
+
// Watch ONLY the referenced document. The dependent is left
|
|
1073
|
+
// unwatched on purpose: that is the state in which no other
|
|
1074
|
+
// channel can report it, and the state a workspace view is in
|
|
1075
|
+
// for every document it displays without opening.
|
|
1076
|
+
await driver.proxy.watchModelDocument({ uri: model.uri, clientId: SUBSCRIBER });
|
|
1077
|
+
const author = await openAs(driver, model.uri);
|
|
1078
|
+
const before = driver.builds.length;
|
|
1079
|
+
|
|
1080
|
+
await driver.proxy.updateModelDocument({
|
|
1081
|
+
uri: model.uri,
|
|
1082
|
+
clientId: author,
|
|
1083
|
+
model: resolveDeferred(edit.to),
|
|
1084
|
+
baseVersion: 'any'
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
await waitFor(() => driver.builds.slice(before).some(event => event.uris.includes(other.uri)), {
|
|
1088
|
+
message: `no onDocumentsBuilt event named ${other.uri} after editing the document it references`
|
|
1089
|
+
});
|
|
1090
|
+
const reported = driver.builds.slice(before).flatMap(event => [...event.uris]);
|
|
1091
|
+
// The watched document is excluded: its watcher already heard
|
|
1092
|
+
// about it on the update channel, and repeating it here would
|
|
1093
|
+
// be the bandwidth the per-URI gate exists to avoid.
|
|
1094
|
+
assert.ok(
|
|
1095
|
+
!reported.includes(model.uri),
|
|
1096
|
+
`onDocumentsBuilt named the WATCHED ${model.uri}; it is reported on the update channel instead`
|
|
1097
|
+
);
|
|
1098
|
+
} finally {
|
|
1099
|
+
driver.dispose();
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
: undefined
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
const breakingEdit = language.breakingEdit;
|
|
1106
|
+
checks.push({
|
|
1107
|
+
title: `an edit that changes a watched dependent credits the dependent's event to no client ${tag}`,
|
|
1108
|
+
skipReason: !dependent
|
|
1109
|
+
? cascadeSkipReason
|
|
1110
|
+
: breakingEdit === undefined
|
|
1111
|
+
? 'fixture supplies no `breakingEdit` (a text for `valid` that changes what `dependent` shows)'
|
|
1112
|
+
: undefined,
|
|
1113
|
+
body:
|
|
1114
|
+
dependent && breakingEdit !== undefined
|
|
1115
|
+
? async () => {
|
|
1116
|
+
const driver = await connect();
|
|
1117
|
+
try {
|
|
1118
|
+
const model = resolveModel(valid);
|
|
1119
|
+
const other = resolveModel(dependent);
|
|
1120
|
+
const eventsFor = (uri: string, from = 0): TransferDocumentUpdatedEvent<TTransfer, TDiagnostic>[] =>
|
|
1121
|
+
driver.events.slice(from).filter(event => event.document.uri === uri);
|
|
1122
|
+
await seed(driver, model);
|
|
1123
|
+
// The dependent's seeder keeps it open: an open document is
|
|
1124
|
+
// the one a head could wrongly credit to its opener.
|
|
1125
|
+
await seed(driver, other);
|
|
1126
|
+
// Watched after the seeds: a seed that changes nothing, over
|
|
1127
|
+
// a fixture file that holds its text already, sends a watcher
|
|
1128
|
+
// nothing to wait for.
|
|
1129
|
+
await driver.proxy.watchModelDocument({ uri: model.uri, clientId: SUBSCRIBER });
|
|
1130
|
+
await driver.proxy.watchModelDocument({ uri: other.uri, clientId: SUBSCRIBER });
|
|
1131
|
+
const author = await openAs(driver, model.uri);
|
|
1132
|
+
const before = driver.events.length;
|
|
1133
|
+
|
|
1134
|
+
const answer = await driver.proxy.updateModelDocument({
|
|
1135
|
+
uri: model.uri,
|
|
1136
|
+
clientId: author,
|
|
1137
|
+
model: resolveDeferred(breakingEdit),
|
|
1138
|
+
baseVersion: 'any'
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
const written = (): TransferDocumentUpdatedEvent<TTransfer, TDiagnostic> | undefined =>
|
|
1142
|
+
eventsFor(model.uri, before).find(event => event.document.model?.version === answer.model?.version);
|
|
1143
|
+
await waitFor(() => written() !== undefined && eventsFor(other.uri, before).length > 0, {
|
|
1144
|
+
message: `no onDocumentUpdated event for both ${model.uri} and its dependent ${other.uri} after the breaking edit`
|
|
1145
|
+
});
|
|
1146
|
+
assert.deepStrictEqual(
|
|
1147
|
+
{ reason: written()?.reason, sourceClientId: written()?.sourceClientId },
|
|
1148
|
+
{ reason: 'changed', sourceClientId: author },
|
|
1149
|
+
'the written document’s event is not the author’s change'
|
|
1150
|
+
);
|
|
1151
|
+
// A recipient drops an event naming its own id as its echo, so
|
|
1152
|
+
// a dependent's event credited to the author, or to the client
|
|
1153
|
+
// that has the dependent open, is lost to exactly the client
|
|
1154
|
+
// that would show it.
|
|
1155
|
+
for (const event of eventsFor(other.uri, before)) {
|
|
1156
|
+
assert.deepStrictEqual(
|
|
1157
|
+
{ reason: event.reason, sourceClientId: event.sourceClientId },
|
|
1158
|
+
{ reason: 'rebuilt', sourceClientId: UNKNOWN_CLIENT_ID },
|
|
1159
|
+
'the dependent’s event is not a rebuild that names no client'
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
} finally {
|
|
1163
|
+
driver.dispose();
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
: undefined
|
|
1167
|
+
});
|
|
1168
|
+
|
|
263
1169
|
checks.push({
|
|
264
1170
|
title: `subscribe + update delivers an onDocumentUpdated event with the originating clientId ${tag}`,
|
|
265
1171
|
skipReason: edit ? undefined : editSkipReason,
|
|
@@ -268,21 +1174,36 @@ export function buildDataChecks<TTransfer extends TransferElement, TDiagnostic e
|
|
|
268
1174
|
const driver = await connect();
|
|
269
1175
|
try {
|
|
270
1176
|
const model = resolveModel(valid);
|
|
271
|
-
|
|
1177
|
+
// The seeder's write comes BEFORE any subscription, under an id
|
|
1178
|
+
// distinct from the author's so an event it caused is
|
|
1179
|
+
// recognisable: a head that fans notifications out regardless
|
|
1180
|
+
// of its subscription table is otherwise indistinguishable from
|
|
1181
|
+
// one that honours the table, since both deliver something for
|
|
1182
|
+
// the post-subscribe write.
|
|
1183
|
+
const seeder = await seed(driver, model);
|
|
1184
|
+
const author = await openAs(driver, model.uri);
|
|
272
1185
|
await driver.proxy.watchModelDocument({ uri: model.uri, clientId: SUBSCRIBER });
|
|
273
|
-
await driver.proxy.updateModelDocument({
|
|
274
|
-
|
|
1186
|
+
await driver.proxy.updateModelDocument({
|
|
1187
|
+
uri: model.uri,
|
|
1188
|
+
clientId: author,
|
|
1189
|
+
model: resolveDeferred(edit.to),
|
|
1190
|
+
baseVersion: 'any'
|
|
1191
|
+
});
|
|
1192
|
+
await waitFor(() => driver.events.some(event => event.sourceClientId === author), {
|
|
275
1193
|
message: `no onDocumentUpdated event for ${model.uri} after the post-subscription update`
|
|
276
1194
|
});
|
|
277
1195
|
const last = driver.events[driver.events.length - 1];
|
|
278
1196
|
assert.strictEqual(last.document.uri, model.uri);
|
|
279
|
-
assert.strictEqual(last.sourceClientId,
|
|
1197
|
+
assert.strictEqual(last.sourceClientId, author);
|
|
1198
|
+
// The first delivery of the author's version is a change;
|
|
1199
|
+
// reported as rebuilt, it says the event echoes no write.
|
|
1200
|
+
assert.strictEqual(last.reason, 'changed');
|
|
280
1201
|
// Nothing from before the subscription. Waiting for the
|
|
281
1202
|
// post-subscribe event first is what makes this provable: the
|
|
282
1203
|
// two notifications share one ordered connection, so a seeding
|
|
283
1204
|
// event that was ever going to arrive has arrived by now.
|
|
284
1205
|
assert.ok(
|
|
285
|
-
!driver.events.some(event => event.sourceClientId ===
|
|
1206
|
+
!driver.events.some(event => event.sourceClientId === seeder),
|
|
286
1207
|
'an onDocumentUpdated event arrived for the update made BEFORE watchModelDocument'
|
|
287
1208
|
);
|
|
288
1209
|
} finally {
|