@kaiko.io/rescript-deser 6.0.1 → 7.0.0-alpha.2
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 +16 -5
- package/lib/bs/.compiler.log +2 -2
- package/lib/bs/build.ninja +0 -27
- package/lib/bs/compiler-info.json +8 -0
- package/lib/bs/src/Deser.ast +0 -0
- package/lib/bs/src/Deser.cmi +0 -0
- package/lib/bs/src/Deser.cmj +0 -0
- package/lib/bs/src/Deser.cmt +0 -0
- package/lib/bs/src/Deser.res +341 -0
- package/lib/bs/tests/QUnit.ast +0 -0
- package/lib/bs/tests/QUnit.cmi +0 -0
- package/lib/bs/tests/QUnit.cmj +0 -0
- package/lib/bs/tests/QUnit.cmt +0 -0
- package/lib/bs/tests/QUnit.res +72 -0
- package/lib/bs/tests/index.ast +0 -0
- package/lib/bs/tests/index.cmi +0 -0
- package/lib/bs/tests/index.cmj +0 -0
- package/lib/bs/tests/index.cmt +0 -0
- package/lib/bs/tests/index.res +212 -0
- package/lib/es6/src/Deser.js +302 -477
- package/lib/es6/tests/index.js +260 -241
- package/lib/js/src/Deser.js +298 -473
- package/lib/js/tests/index.js +261 -242
- package/lib/ocaml/.compiler.log +2 -0
- package/lib/ocaml/Deser.ast +0 -0
- package/lib/ocaml/Deser.cmi +0 -0
- package/lib/ocaml/Deser.cmj +0 -0
- package/lib/ocaml/Deser.cmt +0 -0
- package/lib/ocaml/Deser.res +341 -0
- package/lib/ocaml/QUnit.ast +0 -0
- package/lib/ocaml/QUnit.cmi +0 -0
- package/lib/ocaml/QUnit.cmj +0 -0
- package/lib/ocaml/QUnit.cmt +0 -0
- package/lib/ocaml/QUnit.res +72 -0
- package/lib/ocaml/index.ast +0 -0
- package/lib/ocaml/index.cmi +0 -0
- package/lib/ocaml/index.cmj +0 -0
- package/lib/ocaml/index.cmt +0 -0
- package/lib/ocaml/index.res +212 -0
- package/lib/rescript.lock +1 -0
- package/package.json +5 -6
- package/rescript.json +4 -6
- package/src/Deser.res +86 -97
- package/tests/QUnit.res +4 -4
- package/tests/index.res +34 -11
- package/tests/run-tests.js +192 -0
- package/yarn.lock +683 -0
- package/lib/bs/.bsbuild +0 -0
- package/lib/bs/.bsdeps +0 -9
- package/lib/bs/.ninja_log +0 -93
- package/lib/bs/.project-files-cache +0 -0
- package/lib/bs/.sourcedirs.json +0 -1
- package/lib/bs/install.ninja +0 -10
- package/lib/bs/src/Deser.d +0 -0
- package/lib/bs/tests/QUnit.d +0 -0
- package/lib/bs/tests/index.d +0 -1
package/rescript.json
CHANGED
|
@@ -10,16 +10,14 @@
|
|
|
10
10
|
"type": "dev"
|
|
11
11
|
}
|
|
12
12
|
],
|
|
13
|
-
"bs-dependencies": [
|
|
14
|
-
"@rescript/core"
|
|
15
|
-
],
|
|
16
|
-
"uncurried": true,
|
|
17
13
|
"package-specs": [
|
|
18
14
|
{
|
|
19
|
-
"module": "esmodule"
|
|
15
|
+
"module": "esmodule",
|
|
16
|
+
"in-source": false
|
|
20
17
|
},
|
|
21
18
|
{
|
|
22
|
-
"module": "commonjs"
|
|
19
|
+
"module": "commonjs",
|
|
20
|
+
"in-source": false
|
|
23
21
|
}
|
|
24
22
|
],
|
|
25
23
|
"warnings": {
|
package/src/Deser.res
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
open RescriptCore
|
|
2
|
-
|
|
3
1
|
module FieldValue = {
|
|
4
2
|
type t
|
|
5
3
|
external string: string => t = "%identity"
|
|
@@ -7,8 +5,8 @@ module FieldValue = {
|
|
|
7
5
|
external float: float => t = "%identity"
|
|
8
6
|
external boolean: bool => t = "%identity"
|
|
9
7
|
external array: array<t> => t = "%identity"
|
|
10
|
-
external object:
|
|
11
|
-
external mapping:
|
|
8
|
+
external object: dict<t> => t = "%identity"
|
|
9
|
+
external mapping: dict<t> => t = "%identity"
|
|
12
10
|
external any: 'a => t = "%identity"
|
|
13
11
|
@val external null: t = "undefined"
|
|
14
12
|
|
|
@@ -26,7 +24,7 @@ exception TypeError(string)
|
|
|
26
24
|
module type Deserializer = {
|
|
27
25
|
type t
|
|
28
26
|
let name: string
|
|
29
|
-
let fromJSON:
|
|
27
|
+
let fromJSON: JSON.t => result<t, string>
|
|
30
28
|
let checkFieldsSanity: unit => result<unit, string>
|
|
31
29
|
}
|
|
32
30
|
|
|
@@ -39,37 +37,32 @@ module Field = {
|
|
|
39
37
|
| Float
|
|
40
38
|
| Boolean
|
|
41
39
|
| Array(t)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
// These SHOULD strings in ISO format, but we only validate the string
|
|
41
|
+
// can be represented in Js.Date without spewing NaN all over the place;
|
|
42
|
+
// Js.Date.fromString("xxx") returns an object that is mostly unusable.
|
|
45
43
|
///
|
|
46
|
-
|
|
44
|
+
// We also allow floats and then use Date.fromTime
|
|
47
45
|
| Date
|
|
48
46
|
| Datetime // alias of Date
|
|
49
|
-
|
|
50
47
|
| Tuple(array<t>)
|
|
51
48
|
| Object(array<(string, t)>)
|
|
52
49
|
| Optional(t)
|
|
53
50
|
| OptionalWithDefault(t, FieldValue.t)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
// An arbitrary mapping from names to other arbitrary fields. The
|
|
52
|
+
// difference with Object, is that you don't know the names of the
|
|
53
|
+
// expected entries.
|
|
57
54
|
| Mapping(t)
|
|
58
|
-
|
|
59
55
|
| Deserializer(module(Deserializer))
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
/// and then post-process the list of items with 'Array.keepSome'
|
|
56
|
+
// A specialized Array of deserialized items that ignores unparsable
|
|
57
|
+
// items and returns the valid collection. This saves the user from
|
|
58
|
+
// writing 'Array(DefaultWhenInvalid(Optional(Deserializer(module(M)))))'
|
|
59
|
+
// and then post-process the list of items with 'Array.keepSome'
|
|
65
60
|
| Collection(module(Deserializer))
|
|
66
61
|
| DefaultWhenInvalid(t, FieldValue.t)
|
|
67
|
-
|
|
68
62
|
// FIXME: this is used to add additional restrictions like variadictInt or
|
|
69
63
|
// variadicString; but I find it too type-unsafe. I might consider having
|
|
70
64
|
// a Constraints for this in the future.
|
|
71
65
|
| Morphism(t, FieldValue.t => FieldValue.t)
|
|
72
|
-
|
|
73
66
|
| Self
|
|
74
67
|
|
|
75
68
|
let usingString = (f: string => 'a) => value => value->FieldValue.asString->f->FieldValue.any
|
|
@@ -85,7 +78,7 @@ module Field = {
|
|
|
85
78
|
switch i->fromJs {
|
|
86
79
|
| Some(internalValue) => internalValue
|
|
87
80
|
| None =>
|
|
88
|
-
|
|
81
|
+
throw(TypeError(`This Int(${i->Int.toString}) not a valid value here. Hint: ${hint}`))
|
|
89
82
|
}
|
|
90
83
|
}),
|
|
91
84
|
)
|
|
@@ -94,7 +87,7 @@ module Field = {
|
|
|
94
87
|
usingString(i => {
|
|
95
88
|
switch i->fromJs {
|
|
96
89
|
| Some(internalValue) => internalValue
|
|
97
|
-
| None =>
|
|
90
|
+
| None => throw(TypeError(`This String("${i}") not a valid value here. Hint: ${hint}`))
|
|
98
91
|
}
|
|
99
92
|
}),
|
|
100
93
|
)
|
|
@@ -135,104 +128,98 @@ module Field = {
|
|
|
135
128
|
| DefaultWhenInvalid(t, _) => `Protected ${t->toString}`
|
|
136
129
|
}
|
|
137
130
|
|
|
138
|
-
let _taggedToString = (tagged:
|
|
131
|
+
let _taggedToString = (tagged: JSON.t) => {
|
|
139
132
|
switch tagged {
|
|
140
|
-
|
|
|
141
|
-
|
|
|
142
|
-
|
|
|
143
|
-
|
|
|
144
|
-
|
|
|
145
|
-
|
|
|
146
|
-
|
|
|
133
|
+
| Boolean(false) => "Boolean(false)"
|
|
134
|
+
| Boolean(true) => "Boolean(true)"
|
|
135
|
+
| Null => "Null"
|
|
136
|
+
| String(text) => `String("${text}")`
|
|
137
|
+
| Number(number) => `Number(${number->Float.toString})`
|
|
138
|
+
| Object(obj) => `Object(${obj->JSON.stringifyAny->Option.getOr("...")})`
|
|
139
|
+
| Array(array) => `Array(${array->JSON.stringifyAny->Option.getOr("...")})`
|
|
147
140
|
}
|
|
148
141
|
}
|
|
149
142
|
|
|
150
|
-
let rec extractValue = (
|
|
151
|
-
values: Dict.t<Js.Json.t>,
|
|
152
|
-
field: string,
|
|
153
|
-
shape: t,
|
|
154
|
-
self: t,
|
|
155
|
-
): FieldValue.t => {
|
|
143
|
+
let rec extractValue = (values: dict<JSON.t>, field: string, shape: t, self: t): FieldValue.t => {
|
|
156
144
|
switch values->Dict.get(field) {
|
|
157
145
|
| Some(value) => value->fromUntagged(shape, self)
|
|
158
146
|
| None =>
|
|
159
147
|
switch shape {
|
|
160
|
-
| DefaultWhenInvalid(_, _) =>
|
|
161
|
-
| Optional(_) =>
|
|
148
|
+
| DefaultWhenInvalid(_, _) => JSON.Null->fromUntagged(shape, self)
|
|
149
|
+
| Optional(_) => JSON.Null->fromUntagged(shape, self)
|
|
162
150
|
| OptionalWithDefault(_, default) => default
|
|
163
|
-
| _ =>
|
|
151
|
+
| _ => throw(TypeError(`Missing non-optional field '${field}'`))
|
|
164
152
|
}
|
|
165
153
|
}
|
|
166
154
|
}
|
|
167
|
-
and fromUntagged
|
|
168
|
-
switch (shape, untagged
|
|
155
|
+
and fromUntagged: (JSON.t, t, t) => FieldValue.t = (untagged, shape, self) => {
|
|
156
|
+
switch (shape, untagged) {
|
|
169
157
|
| (Any, _) => untagged->FieldValue.any
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
| (Int,
|
|
178
|
-
|
|
179
|
-
| (
|
|
180
|
-
|
|
181
|
-
| (
|
|
158
|
+
|
|
159
|
+
| (Literal(expected), String(text)) if text == expected => FieldValue.string(text)
|
|
160
|
+
| (Literal(expected), String(text)) =>
|
|
161
|
+
throw(TypeError(`Expecting literal ${expected}, got ${text}`))
|
|
162
|
+
|
|
163
|
+
| (String, String(text)) => FieldValue.string(text)
|
|
164
|
+
|
|
165
|
+
| (Int, Number(number)) => FieldValue.int(number->Float.toInt)
|
|
166
|
+
|
|
167
|
+
| (Float, Number(number)) => FieldValue.float(number)
|
|
168
|
+
|
|
169
|
+
| (Boolean, Boolean(v)) => FieldValue.boolean(v)
|
|
170
|
+
|
|
171
|
+
| (Tuple(bases), Array(items)) => {
|
|
182
172
|
let lenbases = bases->Array.length
|
|
183
173
|
let lenitems = items->Array.length
|
|
184
174
|
if lenbases == lenitems {
|
|
185
175
|
let values = Belt.Array.zipBy(items, bases, (i, b) => fromUntagged(i, b, self))
|
|
186
176
|
values->FieldValue.array
|
|
187
177
|
} else {
|
|
188
|
-
|
|
178
|
+
throw(
|
|
189
179
|
TypeError(`Expecting ${lenbases->Int.toString} items, got ${lenitems->Int.toString}`),
|
|
190
180
|
)
|
|
191
181
|
}
|
|
192
182
|
}
|
|
193
183
|
|
|
194
|
-
| (Datetime,
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
raise(TypeError(`Invalid date ${s}`))
|
|
199
|
-
}
|
|
200
|
-
r->FieldValue.any
|
|
184
|
+
| (Datetime | Date, String(s)) =>
|
|
185
|
+
let r = Date.fromString(s)
|
|
186
|
+
if r->Date.getDate->Int.toFloat->Float.isNaN {
|
|
187
|
+
throw(TypeError(`Invalid date ${s}`))
|
|
201
188
|
}
|
|
189
|
+
r->FieldValue.any
|
|
202
190
|
|
|
203
|
-
| (Datetime,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
raise(TypeError(`Invalid date ${f->Js.Float.toString}`))
|
|
208
|
-
}
|
|
209
|
-
r->FieldValue.any
|
|
191
|
+
| (Datetime | Date, Number(f)) =>
|
|
192
|
+
let r = Date.fromTime(f)
|
|
193
|
+
if r->Date.getDate->Int.toFloat->Float.isNaN {
|
|
194
|
+
throw(TypeError(`Invalid date ${f->Float.toString}`))
|
|
210
195
|
}
|
|
196
|
+
r->FieldValue.any
|
|
211
197
|
|
|
212
|
-
| (Array(shape),
|
|
198
|
+
| (Array(shape), Array(items)) =>
|
|
213
199
|
FieldValue.array(items->Array.map(item => item->fromUntagged(shape, self)))
|
|
214
|
-
|
|
200
|
+
|
|
201
|
+
| (Mapping(f), Object(values)) =>
|
|
215
202
|
values->Dict.mapValues(v => v->fromUntagged(f, self))->FieldValue.mapping
|
|
216
|
-
| (Object(fields), Js.Json.JSONObject(values)) =>
|
|
217
|
-
FieldValue.object(
|
|
218
|
-
fields
|
|
219
|
-
->Array.map(((field, shape)) => {
|
|
220
|
-
let value = switch extractValue(values, field, shape, self) {
|
|
221
|
-
| value => value
|
|
222
|
-
| exception TypeError(msg) => raise(TypeError(`Field "${field}": ${msg}`))
|
|
223
|
-
}
|
|
224
|
-
(field, value)
|
|
225
|
-
})
|
|
226
|
-
->Dict.fromArray,
|
|
227
|
-
)
|
|
228
203
|
|
|
229
|
-
| (
|
|
204
|
+
| (Object(fields), Object(values)) =>
|
|
205
|
+
fields
|
|
206
|
+
->Array.map(((field, shape)) => {
|
|
207
|
+
let value = switch extractValue(values, field, shape, self) {
|
|
208
|
+
| value => value
|
|
209
|
+
| exception TypeError(msg) => throw(TypeError(`Field "${field}": ${msg}`))
|
|
210
|
+
}
|
|
211
|
+
(field, value)
|
|
212
|
+
})
|
|
213
|
+
->Dict.fromArray
|
|
214
|
+
->FieldValue.object
|
|
215
|
+
|
|
216
|
+
| (OptionalWithDefault(_, value), Null) => value
|
|
230
217
|
| (OptionalWithDefault(shape, _), _) => untagged->fromUntagged(shape, self)
|
|
231
|
-
| (Optional(_),
|
|
218
|
+
| (Optional(_), Null) => FieldValue.null
|
|
232
219
|
| (Optional(shape), _) => untagged->fromUntagged(shape, self)
|
|
233
220
|
| (Morphism(shape, f), _) => untagged->fromUntagged(shape, self)->f->FieldValue.any
|
|
234
221
|
|
|
235
|
-
| (Collection(m),
|
|
222
|
+
| (Collection(m), Array(items)) => {
|
|
236
223
|
module M = unpack(m: Deserializer)
|
|
237
224
|
items
|
|
238
225
|
->Array.map(M.fromJSON)
|
|
@@ -252,7 +239,7 @@ module Field = {
|
|
|
252
239
|
module M = unpack(m: Deserializer)
|
|
253
240
|
switch untagged->M.fromJSON {
|
|
254
241
|
| Ok(res) => res->FieldValue.any
|
|
255
|
-
| Error(msg) =>
|
|
242
|
+
| Error(msg) => throw(TypeError(msg))
|
|
256
243
|
}
|
|
257
244
|
}
|
|
258
245
|
|
|
@@ -260,13 +247,13 @@ module Field = {
|
|
|
260
247
|
switch untagged->fromUntagged(t, self) {
|
|
261
248
|
| res => res
|
|
262
249
|
| exception TypeError(msg) => {
|
|
263
|
-
|
|
250
|
+
Console.warn2("Detected and ignore (with default): ", msg)
|
|
264
251
|
default
|
|
265
252
|
}
|
|
266
253
|
}
|
|
267
254
|
| (Self, _) => untagged->fromUntagged(self, self)
|
|
268
255
|
| (expected, actual) =>
|
|
269
|
-
|
|
256
|
+
throw(TypeError(`Expected ${expected->toString}, but got ${actual->_taggedToString} instead`))
|
|
270
257
|
}
|
|
271
258
|
}
|
|
272
259
|
|
|
@@ -296,21 +283,23 @@ module Field = {
|
|
|
296
283
|
|
|
297
284
|
| (Object(fields), optional) =>
|
|
298
285
|
fields
|
|
299
|
-
->Array.map(((fieldName, field)) =>
|
|
300
|
-
checkFieldsSanity(`${name}::${fieldName}`, field, optional)
|
|
286
|
+
->Array.map(((fieldName, field)) =>
|
|
287
|
+
() => checkFieldsSanity(`${name}::${fieldName}`, field, optional)
|
|
288
|
+
)
|
|
301
289
|
->Array.reduce(Ok([]), (res, nextitem) =>
|
|
302
290
|
res->Result.flatMap(arr => nextitem()->Result.map(i => arr->Array.concat([i])))
|
|
303
291
|
)
|
|
304
292
|
->Result.map(_ => ())
|
|
305
293
|
|
|
306
|
-
|
|
307
|
-
|
|
294
|
+
// Mappings and arrays can be empty, so their payloads are
|
|
295
|
+
// automatically optional.
|
|
308
296
|
| (Mapping(field), _) | (Array(field), _) => checkFieldsSanity(name, field, true)
|
|
309
297
|
|
|
310
298
|
| (Tuple(fields), optional) =>
|
|
311
299
|
fields
|
|
312
|
-
->Array.mapWithIndex((field, index) =>
|
|
313
|
-
checkFieldsSanity(`${name}[${index->Int.toString}]`, field, optional)
|
|
300
|
+
->Array.mapWithIndex((field, index) =>
|
|
301
|
+
() => checkFieldsSanity(`${name}[${index->Int.toString}]`, field, optional)
|
|
302
|
+
)
|
|
314
303
|
->Array.reduce(Ok([]), (res, nextitem) =>
|
|
315
304
|
res->Result.flatMap(arr => nextitem()->Result.map(i => arr->Array.concat([i])))
|
|
316
305
|
)
|
|
@@ -342,8 +331,8 @@ module MakeDeserializer = (S: Serializable): (Deserializer with type t = S.t) =>
|
|
|
342
331
|
")
|
|
343
332
|
let checkFieldsSanity = () => Field.checkFieldsSanity(name, fields, false)
|
|
344
333
|
|
|
345
|
-
@doc("Parse a `
|
|
346
|
-
let fromJSON
|
|
334
|
+
@doc("Parse a `'a` into `result<t, string>`")
|
|
335
|
+
let fromJSON: JSON.t => result<t, _> = json => {
|
|
347
336
|
switch json->Field.fromUntagged(fields, fields) {
|
|
348
337
|
| res => Ok(res->_toNativeType)
|
|
349
338
|
| exception TypeError(e) => Error(e)
|
package/tests/QUnit.res
CHANGED
|
@@ -48,12 +48,12 @@ type done = unit => unit
|
|
|
48
48
|
|
|
49
49
|
@send external async: assertion => done = "async"
|
|
50
50
|
@send external asyncMany: (assertion, int) => done = "async"
|
|
51
|
-
@send external rejects: (assertion,
|
|
52
|
-
@send external rejectsM: (assertion,
|
|
51
|
+
@send external rejects: (assertion, promise<'a>, message) => unit = "rejects"
|
|
52
|
+
@send external rejectsM: (assertion, promise<'a>, message) => unit = "rejects"
|
|
53
53
|
@send
|
|
54
|
-
external rejectMatches: (assertion,
|
|
54
|
+
external rejectMatches: (assertion, promise<'a>, matcher<'a>, message) => unit = "rejects"
|
|
55
55
|
@send
|
|
56
|
-
external rejectMatchesM: (assertion,
|
|
56
|
+
external rejectMatchesM: (assertion, promise<'a>, matcher<'a>, message) => unit = "rejects"
|
|
57
57
|
@send external timeout: (assertion, int) => unit = "timeout"
|
|
58
58
|
|
|
59
59
|
// Exceptions
|
package/tests/index.res
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
open QUnit
|
|
2
|
-
open RescriptCore
|
|
3
2
|
|
|
4
3
|
module Appointment = {
|
|
5
4
|
type t = {
|
|
6
5
|
note: string,
|
|
7
|
-
date:
|
|
6
|
+
date: Date.t,
|
|
8
7
|
extra: option<string>,
|
|
9
8
|
}
|
|
10
9
|
module Deserializer = Deser.MakeDeserializer({
|
|
@@ -21,7 +20,7 @@ module_("Basic deserializer", _ => {
|
|
|
21
20
|
%raw(`{"note": "Bicentennial doctor's appointment", "date": "2100-01-01"}`),
|
|
22
21
|
{
|
|
23
22
|
note: "Bicentennial doctor's appointment",
|
|
24
|
-
date:
|
|
23
|
+
date: Date.fromString("2100-01-01"),
|
|
25
24
|
extra: None,
|
|
26
25
|
},
|
|
27
26
|
),
|
|
@@ -33,7 +32,7 @@ module_("Basic deserializer", _ => {
|
|
|
33
32
|
}`),
|
|
34
33
|
{
|
|
35
34
|
note: "Bicentennial doctor's appointment",
|
|
36
|
-
date:
|
|
35
|
+
date: Date.fromString("2100-01-01"),
|
|
37
36
|
extra: Some("Don't take your stop-aging pills the night before the appointment"),
|
|
38
37
|
},
|
|
39
38
|
),
|
|
@@ -43,10 +42,10 @@ module_("Basic deserializer", _ => {
|
|
|
43
42
|
qunit->expect(valid->Array.length)
|
|
44
43
|
valid->Array.forEach(
|
|
45
44
|
((data, expected)) => {
|
|
46
|
-
|
|
45
|
+
Console.log2("Running sample", data)
|
|
47
46
|
switch Appointment.Deserializer.fromJSON(data) {
|
|
48
47
|
| Ok(result) => qunit->deepEqual(result, expected, "result == expected")
|
|
49
|
-
| Error(msg) =>
|
|
48
|
+
| Error(msg) => Console.error(msg)
|
|
50
49
|
}
|
|
51
50
|
},
|
|
52
51
|
)
|
|
@@ -63,11 +62,11 @@ module_("Basic deserializer", _ => {
|
|
|
63
62
|
qunit->expect(invalid->Array.length)
|
|
64
63
|
invalid->Array.forEach(
|
|
65
64
|
((message, data)) => {
|
|
66
|
-
|
|
65
|
+
Console.log3("Running sample", message, data)
|
|
67
66
|
switch Appointment.Deserializer.fromJSON(data) {
|
|
68
|
-
| Ok(result) =>
|
|
67
|
+
| Ok(result) => Console.error2("Invalid being accepted: ", result)
|
|
69
68
|
| Error(msg) => {
|
|
70
|
-
|
|
69
|
+
Console.log2("Correctly detected:", msg)
|
|
71
70
|
qunit->ok(true, true)
|
|
72
71
|
}
|
|
73
72
|
}
|
|
@@ -147,10 +146,10 @@ module_("Recursive deserializer", _ => {
|
|
|
147
146
|
qunit->expect(valid->Array.length)
|
|
148
147
|
valid->Array.forEach(
|
|
149
148
|
((data, expected)) => {
|
|
150
|
-
|
|
149
|
+
Console.log2("Running sample", data)
|
|
151
150
|
switch TrivialTree.Deserializer.fromJSON(data) {
|
|
152
151
|
| Ok(result) => qunit->deepEqual(result, expected, "result == expected")
|
|
153
|
-
| Error(msg) =>
|
|
152
|
+
| Error(msg) => Console.error(msg)
|
|
154
153
|
}
|
|
155
154
|
},
|
|
156
155
|
)
|
|
@@ -187,3 +186,27 @@ module_("Recursive deserializer", _ => {
|
|
|
187
186
|
qunit->deepEqual(data->Ledger.fromJSON->Obj.magic, Ok(expected), "nice ledger")
|
|
188
187
|
})
|
|
189
188
|
})
|
|
189
|
+
|
|
190
|
+
module_("Type safety limits", _ => {
|
|
191
|
+
test("ill-defined deserializer can cheat the type system", qunit => {
|
|
192
|
+
module X = Deser.MakeDeserializer({
|
|
193
|
+
type t = string
|
|
194
|
+
let fields = Deser.Field.Array(Int)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
let data = %raw("[1]")
|
|
198
|
+
qunit->throws(
|
|
199
|
+
_ => {
|
|
200
|
+
let illString = data->X.fromJSON->Result.getOr("")
|
|
201
|
+
try {
|
|
202
|
+
Console.info3(__MODULE__, "This will fail with a type error", illString->String.charAt(0))
|
|
203
|
+
} catch {
|
|
204
|
+
| e =>
|
|
205
|
+
Console.error(e)
|
|
206
|
+
throw(e)
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
"Expected: TypeError: illString.charAt is not a function",
|
|
210
|
+
)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Automated test runner for rescript-deser using Playwright
|
|
5
|
+
*
|
|
6
|
+
* This script starts a Vite dev server, runs QUnit tests in a headless browser,
|
|
7
|
+
* and reports the results to the console.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { chromium } = require('@playwright/test');
|
|
11
|
+
const { spawn } = require('child_process');
|
|
12
|
+
const http = require('http');
|
|
13
|
+
|
|
14
|
+
const TEST_PORT = process.env.TEST_PORT || 4370;
|
|
15
|
+
const TEST_URL = `http://localhost:${TEST_PORT}`;
|
|
16
|
+
const STARTUP_TIMEOUT = 30000; // 30 seconds for server startup
|
|
17
|
+
const TEST_TIMEOUT = 120000; // 2 minutes for all tests to complete
|
|
18
|
+
|
|
19
|
+
async function waitForServer(url, timeout) {
|
|
20
|
+
const start = Date.now();
|
|
21
|
+
|
|
22
|
+
while (Date.now() - start < timeout) {
|
|
23
|
+
try {
|
|
24
|
+
await new Promise((resolve, reject) => {
|
|
25
|
+
const req = http.get(url, (res) => {
|
|
26
|
+
resolve();
|
|
27
|
+
});
|
|
28
|
+
req.on('error', reject);
|
|
29
|
+
req.setTimeout(1000);
|
|
30
|
+
});
|
|
31
|
+
return true;
|
|
32
|
+
} catch (err) {
|
|
33
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
throw new Error(`Server did not start within ${timeout}ms`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function runTests() {
|
|
41
|
+
let viteProcess = null;
|
|
42
|
+
let browser = null;
|
|
43
|
+
let exitCode = 1;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
// Start Vite dev server
|
|
47
|
+
console.log(`Starting Vite server on port ${TEST_PORT}...`);
|
|
48
|
+
viteProcess = spawn('npx', ['vite', '--port', String(TEST_PORT), '--strictPort'], {
|
|
49
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
50
|
+
env: { ...process.env }
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Capture server output for debugging
|
|
54
|
+
viteProcess.stdout.on('data', (data) => {
|
|
55
|
+
if (process.env.DEBUG) {
|
|
56
|
+
console.log('[vite]', data.toString());
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
viteProcess.stderr.on('data', (data) => {
|
|
61
|
+
if (process.env.DEBUG) {
|
|
62
|
+
console.error('[vite error]', data.toString());
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Wait for server to be ready
|
|
67
|
+
console.log('Waiting for server to start...');
|
|
68
|
+
await waitForServer(TEST_URL, STARTUP_TIMEOUT);
|
|
69
|
+
console.log('Server is ready!');
|
|
70
|
+
|
|
71
|
+
// Launch browser
|
|
72
|
+
console.log('Launching browser...');
|
|
73
|
+
browser = await chromium.launch({
|
|
74
|
+
headless: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const context = await browser.newContext();
|
|
78
|
+
const page = await context.newPage();
|
|
79
|
+
|
|
80
|
+
// Set up console message handling to show test output
|
|
81
|
+
page.on('console', msg => {
|
|
82
|
+
const type = msg.type();
|
|
83
|
+
if (process.env.DEBUG) {
|
|
84
|
+
console.log(`[browser ${type}]`, msg.text());
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Navigate to test page
|
|
89
|
+
console.log(`Navigating to ${TEST_URL}...`);
|
|
90
|
+
await page.goto(TEST_URL, { waitUntil: 'networkidle' });
|
|
91
|
+
|
|
92
|
+
// Wait for QUnit to complete and get results
|
|
93
|
+
console.log('Running tests...\n');
|
|
94
|
+
const results = await page.waitForFunction(
|
|
95
|
+
() => {
|
|
96
|
+
if (typeof QUnit !== 'undefined' && QUnit.config && QUnit.config.stats) {
|
|
97
|
+
const done = QUnit.config.current === null || QUnit.config.current === undefined;
|
|
98
|
+
if (done) {
|
|
99
|
+
return {
|
|
100
|
+
passed: QUnit.config.stats.all - QUnit.config.stats.bad,
|
|
101
|
+
failed: QUnit.config.stats.bad,
|
|
102
|
+
total: QUnit.config.stats.all,
|
|
103
|
+
runtime: QUnit.config.stats.runtime
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
},
|
|
109
|
+
{ timeout: TEST_TIMEOUT }
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const testResults = await results.jsonValue();
|
|
113
|
+
|
|
114
|
+
// Print results
|
|
115
|
+
console.log('========================================');
|
|
116
|
+
console.log(' Test Results');
|
|
117
|
+
console.log('========================================');
|
|
118
|
+
console.log(`Total: ${testResults.total}`);
|
|
119
|
+
console.log(`Passed: ${testResults.passed} ✓`);
|
|
120
|
+
console.log(`Failed: ${testResults.failed} ${testResults.failed > 0 ? '✗' : ''}`);
|
|
121
|
+
console.log(`Runtime: ${testResults.runtime}ms`);
|
|
122
|
+
console.log('========================================');
|
|
123
|
+
|
|
124
|
+
if (testResults.failed > 0) {
|
|
125
|
+
// Get detailed failure information
|
|
126
|
+
const failures = await page.evaluate(() => {
|
|
127
|
+
const failedTests = [];
|
|
128
|
+
const testItems = document.querySelectorAll('#qunit-tests > li.fail');
|
|
129
|
+
|
|
130
|
+
testItems.forEach(item => {
|
|
131
|
+
const testName = item.querySelector('.test-name')?.textContent || 'Unknown test';
|
|
132
|
+
const moduleName = item.querySelector('.module-name')?.textContent || '';
|
|
133
|
+
const assertions = [];
|
|
134
|
+
|
|
135
|
+
item.querySelectorAll('.fail').forEach(assertion => {
|
|
136
|
+
const message = assertion.querySelector('.test-message')?.textContent || '';
|
|
137
|
+
const expected = assertion.querySelector('.test-expected')?.textContent || '';
|
|
138
|
+
const actual = assertion.querySelector('.test-actual')?.textContent || '';
|
|
139
|
+
|
|
140
|
+
assertions.push({ message, expected, actual });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
failedTests.push({ module: moduleName, test: testName, assertions });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return failedTests;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
console.log('\nFailed Tests:');
|
|
150
|
+
failures.forEach((failure, index) => {
|
|
151
|
+
console.log(`\n${index + 1}. ${failure.module ? failure.module + ' > ' : ''}${failure.test}`);
|
|
152
|
+
failure.assertions.forEach(assertion => {
|
|
153
|
+
if (assertion.message) console.log(` ${assertion.message}`);
|
|
154
|
+
if (assertion.expected) console.log(` Expected: ${assertion.expected}`);
|
|
155
|
+
if (assertion.actual) console.log(` Actual: ${assertion.actual}`);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
exitCode = 1;
|
|
160
|
+
} else {
|
|
161
|
+
console.log('\n✓ All tests passed!');
|
|
162
|
+
exitCode = 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.error('\n✗ Test run failed:');
|
|
167
|
+
console.error(error.message);
|
|
168
|
+
exitCode = 1;
|
|
169
|
+
} finally {
|
|
170
|
+
// Cleanup
|
|
171
|
+
if (browser) {
|
|
172
|
+
await browser.close();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (viteProcess) {
|
|
176
|
+
viteProcess.kill('SIGTERM');
|
|
177
|
+
// Give it a moment to shut down gracefully
|
|
178
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
179
|
+
if (!viteProcess.killed) {
|
|
180
|
+
viteProcess.kill('SIGKILL');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
process.exit(exitCode);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Run the tests
|
|
189
|
+
runTests().catch(err => {
|
|
190
|
+
console.error('Unexpected error:', err);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
});
|