@hydranium/conformance 1.0.0-next.28 → 1.0.0-next.281

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