@mikrojs/native 0.18.2 → 0.19.0-pr-352.20260829215534

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CMakeLists.txt +25 -2
  2. package/include/mikrojs/mem.h +4 -0
  3. package/include/mikrojs/ota_client.h +3 -3
  4. package/include/mikrojs/platform.h +8 -0
  5. package/include/mikrojs/private.h +28 -0
  6. package/package.json +4 -5
  7. package/prebuilds/darwin-arm64/mikrojs.napi.node +0 -0
  8. package/prebuilds/linux-arm64/mikrojs.napi.node +0 -0
  9. package/prebuilds/linux-x64/mikrojs.napi.node +0 -0
  10. package/runtime/http/helpers.ts +5 -0
  11. package/runtime/internal.d.ts +41 -2
  12. package/runtime/ota/client.ts +4 -0
  13. package/runtime/ota/ota.ts +4 -0
  14. package/runtime/ota/types.ts +75 -0
  15. package/runtime/schema/schema.ts +25 -3
  16. package/runtime/schema/types.ts +193 -15
  17. package/runtime/sys/types.ts +11 -1
  18. package/runtime/test/test.ts +6 -2
  19. package/runtime/wifi/types.ts +3 -0
  20. package/scripts/gen-schema-fixtures.js +317 -0
  21. package/src/mem.cpp +83 -37
  22. package/src/mik_app_config.cpp +9 -1
  23. package/src/mik_http_client.cpp +1357 -0
  24. package/src/mik_ota_client.cpp +3 -3
  25. package/src/mik_repl.cpp +44 -5
  26. package/src/mik_schema.cpp +892 -0
  27. package/src/mik_sys.cpp +9 -1
  28. package/src/mik_wifi_client.cpp +742 -0
  29. package/src/mikrojs.cpp +34 -8
  30. package/src/modules.cpp +66 -0
  31. package/dist/runtime/schema/core.d.ts +0 -115
  32. package/dist/runtime/schema/core.d.ts.map +0 -1
  33. package/dist/runtime/schema/core.js +0 -259
  34. package/dist/runtime/schema/core.js.map +0 -1
  35. package/dist/runtime/schema/shared.d.ts +0 -54
  36. package/dist/runtime/schema/shared.d.ts.map +0 -1
  37. package/dist/runtime/schema/shared.js +0 -489
  38. package/dist/runtime/schema/shared.js.map +0 -1
  39. package/runtime/http/native.ts +0 -160
  40. package/runtime/http/request.ts +0 -25
  41. package/runtime/schema/core.ts +0 -539
  42. package/runtime/schema/shared.ts +0 -494
  43. package/runtime/wifi/wifi.ts +0 -191
@@ -2,17 +2,153 @@ import type {Result} from '../result/types.js'
2
2
 
3
3
  type Primitive = string | number | boolean
4
4
 
5
- export interface StringSchema {
5
+ export type Format = 'url' | 'hostname' | 'ipv4' | 'mac' | 'email'
6
+
7
+ export type Unit =
8
+ | 'm'
9
+ | 'kg'
10
+ | 's'
11
+ | 'A'
12
+ | 'K'
13
+ | 'cd'
14
+ | 'mol'
15
+ | 'Hz'
16
+ | 'rad'
17
+ | 'sr'
18
+ | 'N'
19
+ | 'Pa'
20
+ | 'J'
21
+ | 'W'
22
+ | 'C'
23
+ | 'V'
24
+ | 'F'
25
+ | 'Ohm'
26
+ | 'S'
27
+ | 'Wb'
28
+ | 'T'
29
+ | 'H'
30
+ | 'Cel'
31
+ | 'lm'
32
+ | 'lx'
33
+ | 'Bq'
34
+ | 'Gy'
35
+ | 'Sv'
36
+ | 'kat'
37
+ | 'm2'
38
+ | 'm3'
39
+ | 'm/s'
40
+ | 'm/s2'
41
+ | 'm3/s'
42
+ | 'W/m2'
43
+ | 'cd/m2'
44
+ | 'bit'
45
+ | 'bit/s'
46
+ | 'lat'
47
+ | 'lon'
48
+ | 'pH'
49
+ | 'dB'
50
+ | 'dBW'
51
+ | 'count'
52
+ | '/'
53
+ | '%RH'
54
+ | '%EL'
55
+ | 'EL'
56
+ | '1/s'
57
+ | 'S/m'
58
+ | 'B'
59
+ | 'VA'
60
+ | 'VAs'
61
+ | 'var'
62
+ | 'vars'
63
+ | 'J/m'
64
+ | 'kg/m3'
65
+ | 'deg'
66
+ | 'NTU'
67
+ | 'ms'
68
+ | 'min'
69
+ | 'h'
70
+ | 'MHz'
71
+ | 'kW'
72
+ | 'kVA'
73
+ | 'kvar'
74
+ | 'Ah'
75
+ | 'Wh'
76
+ | 'kWh'
77
+ | 'varh'
78
+ | 'kvarh'
79
+ | 'kVAh'
80
+ | 'Wh/km'
81
+ | 'KiB'
82
+ | 'GB'
83
+ | 'Mbit/s'
84
+ | 'B/s'
85
+ | 'MB/s'
86
+ | 'mV'
87
+ | 'mA'
88
+ | 'dBm'
89
+ | 'ug/m3'
90
+ | 'mm/h'
91
+ | 'm/h'
92
+ | 'ppm'
93
+ | '/100'
94
+ | '/1000'
95
+ | 'hPa'
96
+ | 'mm'
97
+ | 'cm'
98
+ | 'km'
99
+ | 'km/h'
100
+ | 'ppb'
101
+ | 'ppt'
102
+ | 'VAh'
103
+ | 'mg/l'
104
+ | 'ug/l'
105
+ | 'g/l'
106
+ | 'us'
107
+ | 'kHz'
108
+ | 'GHz'
109
+ | 'mW'
110
+ | 'uA'
111
+ | 'uV'
112
+ | 'mAh'
113
+ | 'MiB'
114
+ | 'kB'
115
+ | 'MB'
116
+ | 'kbit/s'
117
+ | 'KiB/s'
118
+ | 'kohm'
119
+ | 'Mohm'
120
+ | 'kPa'
121
+ | 'bar'
122
+ | 'Bd'
123
+
124
+ /* Display annotations, carried by every node a form can render. They never
125
+ * change what validates. Not on optional(): the wrapper expresses absence, the
126
+ * node it wraps expresses identity, so annotations go on the inner. */
127
+ interface Annotated {
128
+ readonly title?: string
129
+ readonly description?: string
130
+ }
131
+
132
+ export interface StringSchema extends Annotated {
6
133
  readonly kind: 'string'
7
134
  readonly default?: string
135
+ readonly mask?: boolean
136
+ readonly minLength?: number
137
+ readonly maxLength?: number
138
+ readonly format?: Format
8
139
  }
9
140
 
10
- export interface NumberSchema {
141
+ export interface NumberSchema extends Annotated {
11
142
  readonly kind: 'number'
12
143
  readonly default?: number
144
+ readonly mask?: boolean
145
+ readonly min?: number
146
+ readonly max?: number
147
+ readonly integer?: boolean
148
+ readonly unit?: Unit
13
149
  }
14
150
 
15
- export interface BooleanSchema {
151
+ export interface BooleanSchema extends Annotated {
16
152
  readonly kind: 'boolean'
17
153
  readonly default?: boolean
18
154
  }
@@ -21,19 +157,23 @@ export interface UnknownSchema {
21
157
  readonly kind: 'unknown'
22
158
  }
23
159
 
24
- export interface LiteralSchema<T extends Primitive = Primitive> {
160
+ export interface LiteralSchema<T extends Primitive = Primitive> extends Annotated {
25
161
  readonly kind: 'literal'
26
162
  readonly value: T
27
163
  readonly default?: T
28
164
  }
29
165
 
30
- export interface ArraySchema<S extends Schema = Schema> {
166
+ export interface ArraySchema<S extends Schema = Schema> extends Annotated {
31
167
  readonly kind: 'array'
32
168
  readonly element: S
33
169
  readonly default?: unknown
170
+ readonly minItems?: number
171
+ readonly maxItems?: number
34
172
  }
35
173
 
36
- export interface ObjectSchema<Shape extends Record<string, Schema> = Record<string, Schema>> {
174
+ export interface ObjectSchema<
175
+ Shape extends Record<string, Schema> = Record<string, Schema>,
176
+ > extends Annotated {
37
177
  readonly kind: 'object'
38
178
  readonly shape: Shape
39
179
  }
@@ -43,13 +183,17 @@ export interface OptionalSchema<S extends Schema = Schema> {
43
183
  readonly inner: S
44
184
  }
45
185
 
46
- export interface TupleSchema<Elements extends readonly Schema[] = readonly Schema[]> {
186
+ export interface TupleSchema<
187
+ Elements extends readonly Schema[] = readonly Schema[],
188
+ > extends Annotated {
47
189
  readonly kind: 'tuple'
48
190
  readonly elements: Elements
49
191
  readonly default?: unknown
50
192
  }
51
193
 
52
- export interface UnionSchema<Members extends readonly Schema[] = readonly Schema[]> {
194
+ export interface UnionSchema<
195
+ Members extends readonly Schema[] = readonly Schema[],
196
+ > extends Annotated {
53
197
  readonly kind: 'union'
54
198
  readonly members: Members
55
199
  readonly default?: unknown
@@ -58,7 +202,7 @@ export interface UnionSchema<Members extends readonly Schema[] = readonly Schema
58
202
  export interface TaggedUnionSchema<
59
203
  Key extends string = string,
60
204
  Branches extends Record<string, ObjectSchema> = Record<string, ObjectSchema>,
61
- > {
205
+ > extends Annotated {
62
206
  readonly kind: 'taggedUnion'
63
207
  readonly key: Key
64
208
  readonly branches: Branches
@@ -137,7 +281,7 @@ export type InferRead<S> =
137
281
  : Simplify<InferReadObject<Shape>>
138
282
  : Infer<S>
139
283
 
140
- /* Kept in lockstep with core.ts (and materializeDefaults in shared.ts). */
284
+ /* Kept in lockstep with @mikrojs/schema (and its materializeDefaults). */
141
285
  type Filled<S> = S extends {default: unknown}
142
286
  ? true
143
287
  : S extends OptionalSchema
@@ -162,22 +306,52 @@ type InferReadObject<Shape> = {
162
306
 
163
307
  export type SchemaError = {name: 'ValidationFailed'; message: string; path: string}
164
308
 
165
- export interface ScalarOptions<T> {
309
+ export interface DisplayOptions {
310
+ readonly title?: string
311
+ readonly description?: string
312
+ }
313
+ export interface ScalarOptions<T> extends DisplayOptions {
166
314
  readonly default?: T
167
315
  }
168
- export interface DefaultOption<T> {
316
+ export interface DefaultOption<T> extends DisplayOptions {
169
317
  readonly default?: T
170
318
  }
319
+ export interface MaskableOptions<T> extends ScalarOptions<T> {
320
+ readonly mask?: boolean
321
+ }
322
+ export interface StringOptions<T> extends MaskableOptions<T> {
323
+ readonly minLength?: number
324
+ readonly maxLength?: number
325
+ readonly format?: Format
326
+ }
327
+ export interface NumberOptions<T> extends MaskableOptions<T> {
328
+ readonly min?: number
329
+ readonly max?: number
330
+ readonly integer?: boolean
331
+ readonly unit?: Unit
332
+ }
333
+ export interface ArrayOptions<T> extends DefaultOption<T> {
334
+ readonly minItems?: number
335
+ readonly maxItems?: number
336
+ }
337
+ export interface EnumEntry<T extends Primitive> {
338
+ readonly value: T
339
+ readonly title?: string
340
+ readonly description?: string
341
+ }
342
+ type EnumMembers<Entries extends readonly EnumEntry<Primitive>[]> = {
343
+ [K in keyof Entries]: LiteralSchema<Entries[K]['value']>
344
+ }
171
345
 
172
346
  /* Mirrors core.ts: the constructors record a `default` annotation in their
173
347
  * return type, which is what lets InferRead see it. */
174
348
  type Defaulted<S, D> = [D] extends [undefined] ? S : S & {readonly default: unknown}
175
349
 
176
350
  export declare function string<D extends string | undefined = undefined>(
177
- options?: ScalarOptions<D>,
351
+ options?: StringOptions<D>,
178
352
  ): Defaulted<StringSchema, D>
179
353
  export declare function number<D extends number | undefined = undefined>(
180
- options?: ScalarOptions<D>,
354
+ options?: NumberOptions<D>,
181
355
  ): Defaulted<NumberSchema, D>
182
356
  export declare function boolean<D extends boolean | undefined = undefined>(
183
357
  options?: ScalarOptions<D>,
@@ -190,7 +364,7 @@ export declare function literal<T extends Primitive, D extends T | undefined = u
190
364
  export declare function array<
191
365
  S extends Schema,
192
366
  D extends NoInfer<Infer<S>>[] | undefined = undefined,
193
- >(element: S, options?: DefaultOption<D>): Defaulted<ArraySchema<S>, D>
367
+ >(element: S, options?: ArrayOptions<D>): Defaulted<ArraySchema<S>, D>
194
368
  export declare function object<Shape extends Record<string, Schema>>(
195
369
  shape: Shape,
196
370
  options?: DefaultOption<never>,
@@ -204,6 +378,10 @@ export declare function union<
204
378
  Members extends readonly Schema[],
205
379
  D extends NoInfer<Infer<UnionSchema<Members>>> | undefined = undefined,
206
380
  >(members: [...Members], options?: DefaultOption<D>): Defaulted<UnionSchema<Members>, D>
381
+ export declare function enumOf<
382
+ const Entries extends readonly EnumEntry<Primitive>[],
383
+ D extends Entries[number]['value'] | undefined = undefined,
384
+ >(entries: Entries, options?: DefaultOption<D>): Defaulted<UnionSchema<EnumMembers<Entries>>, D>
207
385
  export declare function taggedUnion<
208
386
  Key extends string,
209
387
  Branches extends Record<string, ObjectSchema>,
@@ -1,7 +1,12 @@
1
1
  import type {Result} from '../result/types.js'
2
2
 
3
3
  export interface MemoryUsage {
4
+ /** Bytes allocated from the JS heap. On device this counts real
5
+ * allocator block sizes (requested bytes rounded up to the heap's
6
+ * granularity), so it reflects what the heap actually spends; small
7
+ * run-to-run variation is normal. */
4
8
  heapUsed: number
9
+ /** The JS heap's configured memory limit in bytes. */
5
10
  heapTotal: number
6
11
  /** Free system heap in bytes (0 on host) */
7
12
  systemFree: number
@@ -87,6 +92,11 @@ export declare function gc(): void
87
92
  /** Firmware version string (e.g. "0.1.0") */
88
93
  export declare const version: string
89
94
 
95
+ /** Values {@link BoardInfo.features} can contain. `wifi`, `ble` and `bt` need
96
+ * both the silicon and the matching stack compiled into the firmware;
97
+ * `ieee802154` reports silicon alone. */
98
+ export type BoardFeature = 'wifi' | 'ble' | 'bt' | 'ieee802154'
99
+
90
100
  export interface BoardInfo {
91
101
  /** Board name from the firmware build (e.g. "xiao-esp32c6") or "generic" */
92
102
  name: string
@@ -97,7 +107,7 @@ export interface BoardInfo {
97
107
  /** Silicon revision (major * 100 + minor on ESP32, 0 on host) */
98
108
  revision: number
99
109
  /** Supported features (e.g. ["wifi", "ble"]) */
100
- features: string[]
110
+ features: BoardFeature[]
101
111
  /** Flash size in bytes (0 on host) */
102
112
  flash: number
103
113
  /** PSRAM size in bytes (0 if unavailable) */
@@ -510,13 +510,17 @@ async function run(): Promise<void> {
510
510
  } catch (e) {
511
511
  const msg = formatThrown(e)
512
512
  emit({e: 7, s: suite.name, m: msg})
513
+ // Fail the suite's tests rather than skip them: a broken beforeAll is
514
+ // a broken run, and skipping made it indistinguishable from a
515
+ // deliberate gate. The e:7 above carries the real error; the per-test
516
+ // message just points at it.
513
517
  for (const t of suite.tests) {
514
518
  if (t.todo) {
515
519
  emit({e: 9, s: suite.name, t: t.name})
516
520
  todo++
517
521
  } else {
518
- emit({e: 4, s: suite.name, t: t.name})
519
- skipped++
522
+ emit({e: 3, s: suite.name, t: t.name, d: 0, m: 'beforeAll failed'})
523
+ failed++
520
524
  }
521
525
  }
522
526
  emit({e: 5, s: suite.name})
@@ -1,3 +1,6 @@
1
+ /* Type surface for mikro/wifi. On-device the module is the native
2
+ * implementation in src/mik_wifi_client.cpp (behavior pinned by
3
+ * test/wifi_client_test.cpp). */
1
4
  import type {Observable} from '../observable/types.js'
2
5
  import type {Result} from '../result/types.js'
3
6
 
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Schema conformance fixtures: every case is run through the TypeScript
3
+ * implementation here and its outcome recorded, then replayed through the
4
+ * native module by test/schema_conformance_test.cpp.
5
+ *
6
+ * `mikro/schema` has two implementations of one contract: @mikrojs/schema
7
+ * (host: the CLI evaluating mikro.config.ts, the registry, vitest) and
8
+ * src/mik_schema.cpp (device). Two independent suites are not enough to keep
9
+ * them together — the check-in wire had exactly this shape and drifted while
10
+ * both suites stayed green (see test/ota_wire_fixtures_test.cpp). These
11
+ * fixtures make the C++ answer the same questions the TypeScript answered.
12
+ *
13
+ * The recorded outcome is whatever core.ts does, bugs included. That is the
14
+ * point: this file proves the two agree, and the vitest suite proves core.ts is
15
+ * right. Regenerated on every test build so it cannot lag.
16
+ *
17
+ * Usage: node gen-schema-fixtures.js <outdir>
18
+ */
19
+
20
+ import {mkdirSync, writeFileSync} from 'node:fs'
21
+ import {join} from 'node:path'
22
+
23
+ /* The source, not the package specifier: Node would resolve @mikrojs/schema
24
+ * through its `import` condition to dist and make this need a build first. */
25
+ const s = await import('../../schema/src/core.ts')
26
+
27
+ const outDir = process.argv[2]
28
+ if (!outDir) {
29
+ // eslint-disable-next-line no-console
30
+ console.error('Usage: gen-schema-fixtures.js <outdir>')
31
+ process.exit(1)
32
+ }
33
+
34
+ /* ── Cases ─────────────────────────────────────────────────────────── */
35
+
36
+ /* Constructor calls: the driver does S[call](...args) and compares the node it
37
+ * builds, or the TypeError message it throws. Args are plain data because a
38
+ * schema is plain data — that is what makes the same list drivable from C++. */
39
+ const CONSTRUCT_CASES = [
40
+ ['string()', 'string', []],
41
+ ['string with annotations', 'string', [{title: 'Name', description: 'Who', default: 'a'}]],
42
+ ['string with constraints', 'string', [{minLength: 1, maxLength: 8, format: 'email'}]],
43
+ ['string masked', 'string', [{mask: true}]],
44
+ ['number()', 'number', []],
45
+ ['number with bounds', 'number', [{min: 0, max: 10, integer: true, unit: 'ms'}]],
46
+ ['number with default', 'number', [{default: 5}]],
47
+ ['boolean with default', 'boolean', [{default: true}]],
48
+ ['unknown()', 'unknown', []],
49
+ ['literal string', 'literal', ['on']],
50
+ ['literal number with title', 'literal', [3, {title: 'Three'}]],
51
+ ['literal boolean', 'literal', [false]],
52
+ ['array of numbers', 'array', [{kind: 'number'}]],
53
+ ['array with item bounds', 'array', [{kind: 'string'}, {minItems: 1, maxItems: 4}]],
54
+ ['array with default', 'array', [{kind: 'number'}, {default: [1, 2]}]],
55
+ ['object empty', 'object', [{}]],
56
+ ['object with fields', 'object', [{a: {kind: 'number'}, b: {kind: 'string'}}]],
57
+ ['object with title', 'object', [{a: {kind: 'number'}}, {title: 'Group'}]],
58
+ ['optional wraps', 'optional', [{kind: 'number'}]],
59
+ ['tuple', 'tuple', [[{kind: 'number'}, {kind: 'string'}]]],
60
+ ['union', 'union', [[{kind: 'number'}, {kind: 'string'}]]],
61
+ [
62
+ 'enumOf',
63
+ 'enumOf',
64
+ [
65
+ [
66
+ {value: 1, title: 'One'},
67
+ {value: 2, description: 'Two'},
68
+ ],
69
+ ],
70
+ ],
71
+ ['enumOf with default', 'enumOf', [[{value: 'a'}, {value: 'b'}], {default: 'b'}]],
72
+ [
73
+ 'taggedUnion',
74
+ 'taggedUnion',
75
+ ['type', {a: {kind: 'object', shape: {}}, b: {kind: 'object', shape: {}}}],
76
+ ],
77
+
78
+ // Authoring rejections. These are TypeErrors thrown where the schema is
79
+ // written, and they are as much a part of the contract as validation is.
80
+ ['object rejects a default', 'object', [{}, {default: {}}]],
81
+ ['optional rejects a defaulted inner', 'optional', [{kind: 'number', default: 1}]],
82
+ ['array rejects an inner default', 'array', [{kind: 'number', default: 1}]],
83
+ [
84
+ 'array rejects a nested inner default',
85
+ 'array',
86
+ [{kind: 'object', shape: {a: {kind: 'number', default: 1}}}],
87
+ ],
88
+ ['tuple rejects an inner default', 'tuple', [[{kind: 'number', default: 1}]]],
89
+ ['union rejects an inner default', 'union', [[{kind: 'number', default: 1}]]],
90
+ [
91
+ 'taggedUnion rejects an inner default',
92
+ 'taggedUnion',
93
+ ['t', {a: {kind: 'object', shape: {x: {kind: 'number', default: 1}}}}],
94
+ ],
95
+ ['default must match the node', 'number', [{default: 'nope'}]],
96
+ ['array default must match', 'array', [{kind: 'number'}, {default: ['nope']}]],
97
+ ['default must satisfy its own bound', 'number', [{min: 10, default: 5}]],
98
+ ['default within its bound is fine', 'number', [{min: 1, max: 10, default: 5}]],
99
+ ['default must satisfy minLength', 'string', [{minLength: 3, default: 'ab'}]],
100
+ ]
101
+
102
+ /* Validation: schemas are built with the constructors for readability and
103
+ * serialized as data, which is what the device receives anyway. */
104
+ const V = {
105
+ str: s.string(),
106
+ num: s.number(),
107
+ bool: s.boolean(),
108
+ unk: s.unknown(),
109
+ litOn: s.literal('on'),
110
+ lit3: s.literal(3),
111
+ arrNum: s.array(s.number()),
112
+ obj: s.object({a: s.number(), b: s.string()}),
113
+ objOpt: s.object({a: s.number(), b: s.optional(s.string())}),
114
+ nested: s.object({outer: s.object({inner: s.array(s.number())})}),
115
+ tup: s.tuple([s.number(), s.string()]),
116
+ uni: s.union([s.number(), s.string()]),
117
+ tagged: s.taggedUnion('type', {
118
+ move: s.object({x: s.number()}),
119
+ stop: s.object({}),
120
+ }),
121
+ optNum: s.optional(s.number()),
122
+ bounded: s.number({min: 0, max: 10}),
123
+ whole: s.number({integer: true}),
124
+ fractional: s.number({min: 0.5, max: 2.5}),
125
+ sized: s.string({minLength: 2, maxLength: 4}),
126
+ items: s.array(s.number(), {minItems: 1, maxItems: 3}),
127
+ emailed: s.string({format: 'email'}),
128
+ boundedObj: s.object({pin: s.number({min: 0, max: 30, integer: true})}),
129
+ /* The rule the two-pass arrangement got wrong: a union accepts what ANY
130
+ * member accepts, so 150 has to pass on the second member even though it
131
+ * matches the first member's shape and fails its bound. */
132
+ splitRange: s.union([s.number({max: 10}), s.number({min: 100})]),
133
+ }
134
+
135
+ const VALIDATE_CASES = [
136
+ ['string accepts', V.str, 'hello'],
137
+ ['string rejects a number', V.str, 42],
138
+ ['string rejects null', V.str, null],
139
+ ['string rejects undefined', V.str, undefined],
140
+ ['number accepts', V.num, 1.5],
141
+ ['number accepts a negative', V.num, -7],
142
+ ['number rejects NaN', V.num, NaN],
143
+ ['number rejects a string', V.num, '1'],
144
+ ['boolean accepts', V.bool, false],
145
+ ['boolean rejects 0', V.bool, 0],
146
+ ['unknown accepts a string', V.unk, 'x'],
147
+ ['unknown accepts undefined', V.unk, undefined],
148
+ ['unknown accepts an object', V.unk, {a: 1}],
149
+ ['literal accepts its value', V.litOn, 'on'],
150
+ ['literal rejects another string', V.litOn, 'off'],
151
+ ['literal number accepts', V.lit3, 3],
152
+ ['literal number rejects', V.lit3, 4],
153
+ ['literal reports undefined', V.lit3, undefined],
154
+ ['array accepts', V.arrNum, [1, 2, 3]],
155
+ ['array accepts empty', V.arrNum, []],
156
+ ['array rejects a non-array', V.arrNum, 'no'],
157
+ ['array rejects an object', V.arrNum, {}],
158
+ ['array reports the bad index', V.arrNum, [1, 'two', 3]],
159
+ ['object accepts', V.obj, {a: 1, b: 'x'}],
160
+ ['object ignores extra keys', V.obj, {a: 1, b: 'x', extra: true}],
161
+ ['object reports a missing field', V.obj, {a: 1}],
162
+ ['object reports a bad field', V.obj, {a: 1, b: 2}],
163
+ ['object rejects an array', V.obj, [1, 2]],
164
+ ['object rejects null', V.obj, null],
165
+ ['optional field may be absent', V.objOpt, {a: 1}],
166
+ ['optional field may be present', V.objOpt, {a: 1, b: 'x'}],
167
+ ['optional field still typechecked', V.objOpt, {a: 1, b: 2}],
168
+ ['nested reports a deep path', V.nested, {outer: {inner: [1, 'no']}}],
169
+ ['nested accepts', V.nested, {outer: {inner: []}}],
170
+ ['tuple accepts', V.tup, [1, 'x']],
171
+ ['tuple rejects a short one', V.tup, [1]],
172
+ ['tuple rejects a long one', V.tup, [1, 'x', 3]],
173
+ ['tuple reports a bad position', V.tup, ['x', 'x']],
174
+ ['union accepts the first', V.uni, 1],
175
+ ['union accepts the second', V.uni, 'x'],
176
+ ['union rejects neither', V.uni, true],
177
+ ['taggedUnion accepts a branch', V.tagged, {type: 'move', x: 1}],
178
+ ['taggedUnion accepts the empty branch', V.tagged, {type: 'stop'}],
179
+ ['taggedUnion reports a missing tag', V.tagged, {x: 1}],
180
+ ['taggedUnion reports an unknown tag', V.tagged, {type: 'spin'}],
181
+ ['taggedUnion reports a non-primitive tag', V.tagged, {type: {}}],
182
+ ['taggedUnion validates the branch', V.tagged, {type: 'move', x: 'no'}],
183
+ ['taggedUnion rejects an array', V.tagged, []],
184
+ ['bare optional accepts undefined', V.optNum, undefined],
185
+ ['bare optional accepts a value', V.optNum, 1],
186
+ ['bare optional rejects a bad value', V.optNum, 'x'],
187
+
188
+ /* Bounds. These used to be host-only; parse() now enforces everything except
189
+ * format and unit wherever it runs. */
190
+ ['min accepts the boundary', V.bounded, 0],
191
+ ['max accepts the boundary', V.bounded, 10],
192
+ ['below min rejected', V.bounded, -1],
193
+ ['above max rejected', V.bounded, 11],
194
+ ['integer accepts a whole number', V.whole, 4],
195
+ ['integer rejects a fraction', V.whole, 4.5],
196
+ ['integer rejects Infinity', V.whole, Infinity],
197
+ ['fractional bounds accept', V.fractional, 1.25],
198
+ ['fractional bounds report the bound as written', V.fractional, 0.25],
199
+ ['minLength accepts the boundary', V.sized, 'ab'],
200
+ ['maxLength accepts the boundary', V.sized, 'abcd'],
201
+ ['shorter than minLength rejected', V.sized, 'a'],
202
+ ['longer than maxLength rejected', V.sized, 'abcde'],
203
+ ['length counts UTF-16 units', V.sized, '\u{1F600}'],
204
+ ['minItems accepts the boundary', V.items, [1]],
205
+ ['maxItems accepts the boundary', V.items, [1, 2, 3]],
206
+ ['fewer than minItems rejected', V.items, []],
207
+ ['more than maxItems rejected', V.items, [1, 2, 3, 4]],
208
+ ['length is checked before elements', V.items, [1, 2, 3, 'no']],
209
+ ['bounds report the field path', V.boundedObj, {pin: 200}],
210
+ ['format is not checked here', V.emailed, 'not-an-email'],
211
+ ['a union member failing its bound falls through', V.splitRange, 150],
212
+ ['a union with no member in range is rejected', V.splitRange, 50],
213
+
214
+ /* Prototype-chain hazards. core.ts uses Object.hasOwn at three sites so an
215
+ * inherited member cannot stand in for a field or a branch; JS_GetPropertyStr
216
+ * walks the prototype chain and would reopen exactly this. */
217
+ ['object does not read an inherited field', s.object({constructor: s.number()}), {}],
218
+ [
219
+ 'object accepts a real constructor field',
220
+ s.object({constructor: s.number()}),
221
+ {constructor: 1},
222
+ ],
223
+ ['taggedUnion does not dispatch on an inherited tag', V.tagged, {type: 'constructor'}],
224
+ ['taggedUnion does not dispatch on toString', V.tagged, {type: 'toString'}],
225
+ ['object does not read an inherited __proto__', s.object({a: s.number()}), {}],
226
+ ]
227
+
228
+ const APPLY_DEFAULTS_CASES = [
229
+ ['fills a scalar default', s.object({a: s.number({default: 5})}), undefined],
230
+ ['keeps a present value', s.object({a: s.number({default: 5})}), {a: 9}],
231
+ ['drops unknown keys', s.object({a: s.number({default: 5})}), {a: 1, z: 2}],
232
+ ['leaves an optional absent', s.object({a: s.optional(s.number())}), {}],
233
+ ['keeps a present optional', s.object({a: s.optional(s.number())}), {a: 1}],
234
+ ['recurses into nested objects', s.object({o: s.object({a: s.number({default: 2})})}), {}],
235
+ ['array default fills', s.object({a: s.array(s.number(), {default: [1]})}), {}],
236
+ ['array without a default is empty', s.object({a: s.array(s.number())}), {}],
237
+ ['a present non-object stays as-is', s.object({a: s.number()}), 42],
238
+ ['does not fill through an inherited key', s.object({constructor: s.number({default: 1})}), {}],
239
+ ['unknown passes through', s.unknown(), undefined],
240
+ ['scalar default at the root', s.number({default: 3}), undefined],
241
+ /* Hand-built AST: the constructors reject a default that breaks its own
242
+ * bound, but a schema that arrived as JSON ran none of them, and
243
+ * applyDefaults must still fill without validating. */
244
+ [
245
+ 'a default outside its own bound still fills',
246
+ {kind: 'object', shape: {a: {kind: 'number', min: 10, default: 5}}},
247
+ {},
248
+ ],
249
+ ]
250
+
251
+ /* ── Recording ─────────────────────────────────────────────────────── */
252
+
253
+ function recordConstruct([name, call, args]) {
254
+ try {
255
+ return {name, kind: 'construct', call, args, expect: s[call](...args)}
256
+ } catch (e) {
257
+ return {name, kind: 'construct', call, args, throws: e.message}
258
+ }
259
+ }
260
+
261
+ function recordValidate([name, schema, value]) {
262
+ const result = s.validate(schema, value, '')
263
+ return {
264
+ name,
265
+ kind: 'validate',
266
+ schema,
267
+ value,
268
+ expect: result === null ? null : {message: result.error.message, path: result.error.path},
269
+ }
270
+ }
271
+
272
+ function recordApplyDefaults([name, schema, value]) {
273
+ return {name, kind: 'applyDefaults', schema, value, expect: s.applyDefaults(schema, value)}
274
+ }
275
+
276
+ /* ── Emitting ──────────────────────────────────────────────────────── */
277
+
278
+ /* JS source, not JSON: the cases carry undefined and NaN, which JSON cannot
279
+ * represent and which are exactly the values the edge cases are about. */
280
+ function js(value) {
281
+ if (value === undefined) return 'undefined'
282
+ if (value === null) return 'null'
283
+ if (typeof value === 'number') {
284
+ if (Number.isNaN(value)) return 'NaN'
285
+ if (value === Infinity) return 'Infinity'
286
+ if (value === -Infinity) return '-Infinity'
287
+ return String(value)
288
+ }
289
+ if (typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value)
290
+ if (Array.isArray(value)) return `[${value.map(js).join(',')}]`
291
+ if (typeof value === 'object') {
292
+ const body = Object.keys(value)
293
+ .map((k) => `${JSON.stringify(k)}:${js(value[k])}`)
294
+ .join(',')
295
+ return `{${body}}`
296
+ }
297
+ throw new TypeError(`cannot serialize ${typeof value}`)
298
+ }
299
+
300
+ const cases = [
301
+ ...CONSTRUCT_CASES.map(recordConstruct),
302
+ ...VALIDATE_CASES.map(recordValidate),
303
+ ...APPLY_DEFAULTS_CASES.map(recordApplyDefaults),
304
+ ]
305
+
306
+ const body = cases.map((c) => ` ${js(c)},`).join('\n')
307
+ const source = `/* GENERATED by scripts/gen-schema-fixtures.js. Do not edit. */
308
+ const cases = [
309
+ ${body}
310
+ ]
311
+ `
312
+
313
+ mkdirSync(outDir, {recursive: true})
314
+ writeFileSync(join(outDir, 'schema-fixtures.js'), source)
315
+
316
+ // eslint-disable-next-line no-console
317
+ console.log(`schema fixtures: ${cases.length} cases -> ${join(outDir, 'schema-fixtures.js')}`)