1
+ import { PRELOAD_UID_PATTERN } from '@getodk/common/constants/regex.ts' ;
2
+ import {
3
+ OPENROSA_XFORMS_NAMESPACE_URI ,
4
+ XFORMS_NAMESPACE_URI ,
5
+ } from '@getodk/common/constants/xmlns.ts' ;
1
6
import { assertUnknownArray } from '@getodk/common/lib/type-assertions/assertUnknownArray.ts' ;
2
7
import { assertUnknownObject } from '@getodk/common/lib/type-assertions/assertUnknownObject.ts' ;
3
8
import { getBlobText } from '@getodk/common/lib/web-compat/blob.ts' ;
9
+ import type * as CommonAssertionHelpers from '@getodk/common/test/assertions/helpers.ts' ;
4
10
import type { DeriveStaticVitestExpectExtension } from '@getodk/common/test/assertions/helpers.ts' ;
5
11
import {
6
12
ArbitraryConditionExpectExtension ,
@@ -11,6 +17,7 @@ import {
11
17
} from '@getodk/common/test/assertions/helpers.ts' ;
12
18
import type { SimpleAssertionResult } from '@getodk/common/test/assertions/vitest/shared-extension-types.ts' ;
13
19
import type { AssertIs } from '@getodk/common/types/assertions/AssertIs.ts' ;
20
+ import type { ExpandUnion } from '@getodk/common/types/helpers.js' ;
14
21
import type { InstanceFile , InstancePayload , InstancePayloadType } from '@getodk/xforms-engine' ;
15
22
import { constants } from '@getodk/xforms-engine' ;
16
23
import { assert , expect } from 'vitest' ;
@@ -87,6 +94,159 @@ const getInstanceFile = (payload: AnyInstancePayload): InstanceFile => {
87
94
return file ;
88
95
} ;
89
96
97
+ const META_NAMESPACE_URIS = [ OPENROSA_XFORMS_NAMESPACE_URI , XFORMS_NAMESPACE_URI ] as const ;
98
+
99
+ type MetaNamespaceURI = ( typeof META_NAMESPACE_URIS ) [ number ] ;
100
+
101
+ type AssertEnumeratedString < T extends string > = ( actual : unknown ) => asserts actual is T ;
102
+
103
+ /**
104
+ * @todo This is probably general enough to be exported from {@link CommonAssertionHelpers}
105
+ */
106
+ const enumeratedStringAssertion = < T extends string > (
107
+ expected : readonly T [ ]
108
+ ) : AssertEnumeratedString < T > => {
109
+ return ( actual ) => {
110
+ assertString ( actual ) ;
111
+
112
+ expect ( expected ) . toContain ( actual ) ;
113
+ } ;
114
+ } ;
115
+
116
+ const assertMetaNamespaceURI : AssertEnumeratedString < MetaNamespaceURI > =
117
+ enumeratedStringAssertion ( META_NAMESPACE_URIS ) ;
118
+
119
+ interface SerializedMetaChildValues {
120
+ readonly instanceID : string | null ;
121
+ readonly deprecatedID : string | null ;
122
+ }
123
+
124
+ type MetaChildLocalName = ExpandUnion < keyof SerializedMetaChildValues > ;
125
+
126
+ interface SerializedMeta extends SerializedMetaChildValues {
127
+ readonly meta : Element | null ;
128
+ }
129
+
130
+ type MetaElementLocalName = ExpandUnion < keyof SerializedMeta > ;
131
+
132
+ /**
133
+ * @todo this is general enough to go in `common` package, and would probably
134
+ * find reuse pretty quickly. Holding off for now because it has overlap with
135
+ * several even more general tuple-length narrowing cases:
136
+ *
137
+ * - Unbounded length -> 1-ary tuple
138
+ * - Unbounded length -> parameterized N-ary tuple
139
+ * - Unbounded length -> partially bounded (min-N, max-N) tuple
140
+ * - Type guards (predicate) and `asserts` equivalents of each
141
+ *
142
+ * Each of these cases comes up frequently! I've written them at least a few
143
+ * dozen times, and always back out to more specific logic for pragmatic
144
+ * reasons. But having these generalizations would allow pretty significant
145
+ * simplification of a lot of their use cases.
146
+ */
147
+ const findExclusiveMatch = < T > (
148
+ values : readonly T [ ] ,
149
+ predicate : ( value : T ) => boolean
150
+ ) : T | null => {
151
+ const results = values . filter ( predicate ) ;
152
+
153
+ expect ( results . length ) . toBeLessThanOrEqual ( 1 ) ;
154
+
155
+ return results [ 0 ] ?? null ;
156
+ } ;
157
+
158
+ const getMetaElement = (
159
+ parent : ParentNode | null ,
160
+ namespaceURI : MetaNamespaceURI ,
161
+ localName : MetaElementLocalName
162
+ ) : Element | null => {
163
+ if ( parent == null ) {
164
+ return null ;
165
+ }
166
+
167
+ const children = Array . from ( parent . children ) ;
168
+
169
+ return findExclusiveMatch ( children , ( child ) => {
170
+ return child . namespaceURI === namespaceURI && child . localName === localName ;
171
+ } ) ;
172
+ } ;
173
+
174
+ const getMetaChildValue = (
175
+ metaElement : Element | null ,
176
+ namespaceURI : MetaNamespaceURI ,
177
+ localName : MetaChildLocalName
178
+ ) : string | null => {
179
+ const element = getMetaElement ( metaElement , namespaceURI , localName ) ;
180
+
181
+ if ( element == null ) {
182
+ return null ;
183
+ }
184
+
185
+ expect ( element . childElementCount ) . toBe ( 0 ) ;
186
+
187
+ const { textContent } = element ;
188
+
189
+ assert ( typeof textContent === 'string' ) ;
190
+
191
+ return textContent ;
192
+ } ;
193
+
194
+ interface MetaNamespaceOptions {
195
+ readonly [ key : string ] : unknown ;
196
+ readonly metaNamespaceURI : MetaNamespaceURI ;
197
+ }
198
+
199
+ type AssertMetaNamespaceOptions = ( value : unknown ) => asserts value is MetaNamespaceOptions ;
200
+
201
+ const assertMetaNamespaceOptions : AssertMetaNamespaceOptions = ( value ) => {
202
+ assertUnknownObject ( value ) ;
203
+ assertMetaNamespaceURI ( value . metaNamespaceURI ) ;
204
+ } ;
205
+
206
+ const getSerializedMeta = ( scenario : Scenario , namespaceURI : MetaNamespaceURI ) : SerializedMeta => {
207
+ const serializedInstanceBody = scenario . proposed_serializeInstance ( ) ;
208
+ /**
209
+ * Important: we intentionally omit the default namespace when serializing instance XML. We need to restore it here to reliably traverse nodes when {@link metaNamespaceURI} is {@link XFORMS_NAMESPACE_URI}.
210
+ */
211
+ const instanceXML = `<instance xmlns="${ XFORMS_NAMESPACE_URI } ">${ serializedInstanceBody } </instance>` ;
212
+
213
+ const parser = new DOMParser ( ) ;
214
+ const instanceDocument = parser . parseFromString ( instanceXML , 'text/xml' ) ;
215
+ const instanceElement = instanceDocument . documentElement ;
216
+ const instanceRoot = instanceElement . firstElementChild ;
217
+
218
+ assert (
219
+ instanceRoot != null ,
220
+ `Failed to find instance root element.\n\nActual serialized XML: ${ serializedInstanceBody } \n\nActual instance DOM state: ${ instanceElement . outerHTML } `
221
+ ) ;
222
+
223
+ const meta = getMetaElement ( instanceRoot , namespaceURI , 'meta' ) ;
224
+ const instanceID = getMetaChildValue ( meta , namespaceURI , 'instanceID' ) ;
225
+ const deprecatedID = getMetaChildValue ( meta , namespaceURI , 'deprecatedID' ) ;
226
+
227
+ return {
228
+ meta,
229
+ instanceID,
230
+ deprecatedID,
231
+ } ;
232
+ } ;
233
+
234
+ const assertPreloadUIDValue = ( actual : string | null ) => {
235
+ assert ( actual != null , 'Expected preload uid value to be serialized' ) ;
236
+ expect ( actual , 'Expected preload uid value to match pattern' ) . toMatch ( PRELOAD_UID_PATTERN ) ;
237
+ } ;
238
+
239
+ interface EditedMetaOptions extends MetaNamespaceOptions {
240
+ readonly sourceScenario : Scenario ;
241
+ }
242
+
243
+ type AssertEditedMetaOptions = ( value : unknown ) => asserts value is EditedMetaOptions ;
244
+
245
+ const assertEditedMetaOptions : AssertEditedMetaOptions = ( value ) => {
246
+ assertMetaNamespaceOptions ( value ) ;
247
+ assertScenario ( value . sourceScenario ) ;
248
+ } ;
249
+
90
250
export const submissionExtensions = extendExpect ( expect , {
91
251
toHaveSerializedSubmissionXML : new AsymmetricTypedExpectExtension (
92
252
assertScenario ,
@@ -152,6 +312,88 @@ export const submissionExtensions = extendExpect(expect, {
152
312
return compareSubmissionXML ( actualText , expected ) ;
153
313
}
154
314
) ,
315
+
316
+ toHaveComputedPreloadInstanceID : new AsymmetricTypedExpectExtension (
317
+ assertScenario ,
318
+ assertMetaNamespaceOptions ,
319
+ ( scenario , options ) : SimpleAssertionResult => {
320
+ try {
321
+ const meta = getSerializedMeta ( scenario , options . metaNamespaceURI ) ;
322
+
323
+ assertPreloadUIDValue ( meta . instanceID ) ;
324
+
325
+ return true ;
326
+ } catch ( error ) {
327
+ if ( error instanceof Error ) {
328
+ return error ;
329
+ }
330
+
331
+ // eslint-disable-next-line no-console
332
+ console . error ( error ) ;
333
+ return new Error ( 'Unknown error' ) ;
334
+ }
335
+ }
336
+ ) ,
337
+
338
+ toHaveEditedPreloadInstanceID : new AsymmetricTypedExpectExtension (
339
+ assertScenario ,
340
+ assertEditedMetaOptions ,
341
+ ( editedScenario , options ) : SimpleAssertionResult => {
342
+ try {
343
+ const { metaNamespaceURI, sourceScenario } = options ;
344
+ const sourceMeta = getSerializedMeta ( sourceScenario , metaNamespaceURI ) ;
345
+ const editedMeta = getSerializedMeta ( editedScenario , metaNamespaceURI ) ;
346
+
347
+ assertPreloadUIDValue ( sourceMeta . instanceID ) ;
348
+ assertPreloadUIDValue ( editedMeta . instanceID ) ;
349
+
350
+ expect (
351
+ editedMeta . instanceID ,
352
+ 'Expected preloaded instanceID metadata to be recomputed on edit'
353
+ ) . not . toBe ( sourceMeta . instanceID ) ;
354
+
355
+ return true ;
356
+ } catch ( error ) {
357
+ if ( error instanceof Error ) {
358
+ return error ;
359
+ }
360
+
361
+ // eslint-disable-next-line no-console
362
+ console . error ( error ) ;
363
+ return new Error ( 'Unknown error' ) ;
364
+ }
365
+ }
366
+ ) ,
367
+
368
+ toHaveDeprecatedIDFromSource : new AsymmetricTypedExpectExtension (
369
+ assertScenario ,
370
+ assertEditedMetaOptions ,
371
+ ( editedScenario , options ) : SimpleAssertionResult => {
372
+ try {
373
+ const { metaNamespaceURI, sourceScenario } = options ;
374
+ const sourceMeta = getSerializedMeta ( sourceScenario , metaNamespaceURI ) ;
375
+ const editedMeta = getSerializedMeta ( editedScenario , metaNamespaceURI ) ;
376
+
377
+ assertPreloadUIDValue ( sourceMeta . instanceID ) ;
378
+ assertPreloadUIDValue ( editedMeta . deprecatedID ) ;
379
+
380
+ expect (
381
+ editedMeta . deprecatedID ,
382
+ 'Expected edited deprecatedID metadata to be assigned from source instanceID'
383
+ ) . toBe ( sourceMeta . instanceID ) ;
384
+
385
+ return true ;
386
+ } catch ( error ) {
387
+ if ( error instanceof Error ) {
388
+ return error ;
389
+ }
390
+
391
+ // eslint-disable-next-line no-console
392
+ console . error ( error ) ;
393
+ return new Error ( 'Unknown error' ) ;
394
+ }
395
+ }
396
+ ) ,
155
397
} ) ;
156
398
157
399
type SubmissionExtensions = typeof submissionExtensions ;
0 commit comments