@selvajs/server 0.2.0-beta.2 → 0.2.0-beta.4

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 (155) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +32 -0
  3. package/dist/compute/client-cache.d.ts +12 -0
  4. package/dist/compute/client-cache.d.ts.map +1 -1
  5. package/dist/compute/client-cache.js +18 -3
  6. package/dist/compute/client-cache.js.map +1 -1
  7. package/dist/compute/definition-byte-cache.d.ts +85 -0
  8. package/dist/compute/definition-byte-cache.d.ts.map +1 -0
  9. package/dist/compute/definition-byte-cache.js +110 -0
  10. package/dist/compute/definition-byte-cache.js.map +1 -0
  11. package/dist/compute/index.d.ts +7 -2
  12. package/dist/compute/index.d.ts.map +1 -1
  13. package/dist/compute/index.js +6 -1
  14. package/dist/compute/index.js.map +1 -1
  15. package/dist/compute/limits.d.ts +52 -0
  16. package/dist/compute/limits.d.ts.map +1 -1
  17. package/dist/compute/limits.js +33 -1
  18. package/dist/compute/limits.js.map +1 -1
  19. package/dist/compute/memory-solve-cache.d.ts +58 -0
  20. package/dist/compute/memory-solve-cache.d.ts.map +1 -0
  21. package/dist/compute/memory-solve-cache.js +0 -0
  22. package/dist/compute/memory-solve-cache.js.map +1 -0
  23. package/dist/compute/safe-url.d.ts.map +1 -1
  24. package/dist/compute/safe-url.js +16 -0
  25. package/dist/compute/safe-url.js.map +1 -1
  26. package/dist/compute/solve-cache-envelope.d.ts +53 -0
  27. package/dist/compute/solve-cache-envelope.d.ts.map +1 -0
  28. package/dist/compute/solve-cache-envelope.js +66 -0
  29. package/dist/compute/solve-cache-envelope.js.map +1 -0
  30. package/dist/compute/solve-cache-key.d.ts +65 -0
  31. package/dist/compute/solve-cache-key.d.ts.map +1 -0
  32. package/dist/compute/solve-cache-key.js +50 -0
  33. package/dist/compute/solve-cache-key.js.map +1 -0
  34. package/dist/compute/solve-cache-single-flight.d.ts +42 -0
  35. package/dist/compute/solve-cache-single-flight.d.ts.map +1 -0
  36. package/dist/compute/solve-cache-single-flight.js +50 -0
  37. package/dist/compute/solve-cache-single-flight.js.map +1 -0
  38. package/dist/compute/solve-pipeline.d.ts +71 -8
  39. package/dist/compute/solve-pipeline.d.ts.map +1 -1
  40. package/dist/compute/solve-pipeline.js +180 -8
  41. package/dist/compute/solve-pipeline.js.map +1 -1
  42. package/dist/definitions/definition-service.d.ts +1 -1
  43. package/dist/definitions/definition-service.d.ts.map +1 -1
  44. package/dist/definitions/definition-service.js +8 -4
  45. package/dist/definitions/definition-service.js.map +1 -1
  46. package/package.json +14 -18
  47. package/dist/access/__tests__/project-access-input.test.d.ts +0 -2
  48. package/dist/access/__tests__/project-access-input.test.d.ts.map +0 -1
  49. package/dist/access/__tests__/project-access-input.test.js +0 -146
  50. package/dist/access/__tests__/project-access-input.test.js.map +0 -1
  51. package/dist/compute/__tests__/client-cache.test.d.ts +0 -11
  52. package/dist/compute/__tests__/client-cache.test.d.ts.map +0 -1
  53. package/dist/compute/__tests__/client-cache.test.js +0 -200
  54. package/dist/compute/__tests__/client-cache.test.js.map +0 -1
  55. package/dist/compute/__tests__/rate-limit.test.d.ts +0 -2
  56. package/dist/compute/__tests__/rate-limit.test.d.ts.map +0 -1
  57. package/dist/compute/__tests__/rate-limit.test.js +0 -98
  58. package/dist/compute/__tests__/rate-limit.test.js.map +0 -1
  59. package/dist/compute/__tests__/remote-definition.test.d.ts +0 -11
  60. package/dist/compute/__tests__/remote-definition.test.d.ts.map +0 -1
  61. package/dist/compute/__tests__/remote-definition.test.js +0 -114
  62. package/dist/compute/__tests__/remote-definition.test.js.map +0 -1
  63. package/dist/compute/__tests__/safe-url.test.d.ts +0 -2
  64. package/dist/compute/__tests__/safe-url.test.d.ts.map +0 -1
  65. package/dist/compute/__tests__/safe-url.test.js +0 -71
  66. package/dist/compute/__tests__/safe-url.test.js.map +0 -1
  67. package/dist/compute/__tests__/solve-pipeline.test.d.ts +0 -11
  68. package/dist/compute/__tests__/solve-pipeline.test.d.ts.map +0 -1
  69. package/dist/compute/__tests__/solve-pipeline.test.js +0 -292
  70. package/dist/compute/__tests__/solve-pipeline.test.js.map +0 -1
  71. package/dist/compute/__tests__/transform-input.test.d.ts +0 -14
  72. package/dist/compute/__tests__/transform-input.test.d.ts.map +0 -1
  73. package/dist/compute/__tests__/transform-input.test.js +0 -120
  74. package/dist/compute/__tests__/transform-input.test.js.map +0 -1
  75. package/dist/definitions/__tests__/load-for-render.test.d.ts +0 -8
  76. package/dist/definitions/__tests__/load-for-render.test.d.ts.map +0 -1
  77. package/dist/definitions/__tests__/load-for-render.test.js +0 -196
  78. package/dist/definitions/__tests__/load-for-render.test.js.map +0 -1
  79. package/dist/definitions/__tests__/schema-extraction.test.d.ts +0 -11
  80. package/dist/definitions/__tests__/schema-extraction.test.d.ts.map +0 -1
  81. package/dist/definitions/__tests__/schema-extraction.test.js +0 -115
  82. package/dist/definitions/__tests__/schema-extraction.test.js.map +0 -1
  83. package/dist/http/__tests__/body-size.test.d.ts +0 -2
  84. package/dist/http/__tests__/body-size.test.d.ts.map +0 -1
  85. package/dist/http/__tests__/body-size.test.js +0 -24
  86. package/dist/http/__tests__/body-size.test.js.map +0 -1
  87. package/dist/http/__tests__/route-classifier.test.d.ts +0 -2
  88. package/dist/http/__tests__/route-classifier.test.d.ts.map +0 -1
  89. package/dist/http/__tests__/route-classifier.test.js +0 -54
  90. package/dist/http/__tests__/route-classifier.test.js.map +0 -1
  91. package/dist/http/__tests__/safe-redirect.test.d.ts +0 -2
  92. package/dist/http/__tests__/safe-redirect.test.d.ts.map +0 -1
  93. package/dist/http/__tests__/safe-redirect.test.js +0 -44
  94. package/dist/http/__tests__/safe-redirect.test.js.map +0 -1
  95. package/dist/http/__tests__/security-headers.test.d.ts +0 -2
  96. package/dist/http/__tests__/security-headers.test.d.ts.map +0 -1
  97. package/dist/http/__tests__/security-headers.test.js +0 -24
  98. package/dist/http/__tests__/security-headers.test.js.map +0 -1
  99. package/dist/ops/__tests__/semver.test.d.ts +0 -2
  100. package/dist/ops/__tests__/semver.test.d.ts.map +0 -1
  101. package/dist/ops/__tests__/semver.test.js +0 -38
  102. package/dist/ops/__tests__/semver.test.js.map +0 -1
  103. package/dist/providers/__tests__/create-selva-providers.test.d.ts +0 -7
  104. package/dist/providers/__tests__/create-selva-providers.test.d.ts.map +0 -1
  105. package/dist/providers/__tests__/create-selva-providers.test.js +0 -135
  106. package/dist/providers/__tests__/create-selva-providers.test.js.map +0 -1
  107. package/dist/tokens/__tests__/token-codec.test.d.ts +0 -2
  108. package/dist/tokens/__tests__/token-codec.test.d.ts.map +0 -1
  109. package/dist/tokens/__tests__/token-codec.test.js +0 -50
  110. package/dist/tokens/__tests__/token-codec.test.js.map +0 -1
  111. package/src/access/__tests__/project-access-input.test.ts +0 -175
  112. package/src/access/index.ts +0 -9
  113. package/src/access/project-access-input.ts +0 -145
  114. package/src/compute/__tests__/client-cache.test.ts +0 -224
  115. package/src/compute/__tests__/rate-limit.test.ts +0 -106
  116. package/src/compute/__tests__/remote-definition.test.ts +0 -140
  117. package/src/compute/__tests__/safe-url.test.ts +0 -72
  118. package/src/compute/__tests__/solve-pipeline.test.ts +0 -341
  119. package/src/compute/__tests__/transform-input.test.ts +0 -162
  120. package/src/compute/client-cache.ts +0 -300
  121. package/src/compute/index.ts +0 -48
  122. package/src/compute/limits.ts +0 -159
  123. package/src/compute/rate-limit.ts +0 -110
  124. package/src/compute/remote-definition.ts +0 -149
  125. package/src/compute/safe-url.ts +0 -186
  126. package/src/compute/solve-pipeline.ts +0 -339
  127. package/src/compute/transform-input.ts +0 -66
  128. package/src/definitions/__tests__/load-for-render.test.ts +0 -244
  129. package/src/definitions/__tests__/schema-extraction.test.ts +0 -137
  130. package/src/definitions/definition-service.ts +0 -243
  131. package/src/definitions/index.ts +0 -22
  132. package/src/definitions/load-for-render.ts +0 -272
  133. package/src/definitions/schema-extraction.ts +0 -117
  134. package/src/errors/SentryErrorReporter.ts +0 -88
  135. package/src/errors/index.ts +0 -3
  136. package/src/http/__tests__/body-size.test.ts +0 -27
  137. package/src/http/__tests__/route-classifier.test.ts +0 -60
  138. package/src/http/__tests__/safe-redirect.test.ts +0 -47
  139. package/src/http/__tests__/security-headers.test.ts +0 -29
  140. package/src/http/body-size.ts +0 -24
  141. package/src/http/index.ts +0 -10
  142. package/src/http/redirect.ts +0 -16
  143. package/src/http/route-classifier.ts +0 -74
  144. package/src/http/security-headers.ts +0 -42
  145. package/src/index.ts +0 -27
  146. package/src/ops/__tests__/semver.test.ts +0 -42
  147. package/src/ops/index.ts +0 -3
  148. package/src/ops/semver.ts +0 -60
  149. package/src/providers/__tests__/create-selva-providers.test.ts +0 -172
  150. package/src/providers/__tests__/fixtures/override-config.mjs +0 -8
  151. package/src/providers/create-selva-providers.ts +0 -241
  152. package/src/providers/index.ts +0 -12
  153. package/src/tokens/__tests__/token-codec.test.ts +0 -63
  154. package/src/tokens/index.ts +0 -8
  155. package/src/tokens/token-codec.ts +0 -86
@@ -1,341 +0,0 @@
1
- /**
2
- * Tests for `runSolvePipeline` — the transport-agnostic solve core (K3).
3
- *
4
- * `@selvajs/compute` is mocked so the pipeline runs without a live compute
5
- * server: `TreeBuilder.fromInputParams` returns a marker tree and the scheduler
6
- * is a stub whose `solve` we script per test (resolve a response, throw an
7
- * AbortError, etc.). That isolates the pipeline's own behavior — outcome
8
- * classification, size cap, gzip, and the Server-Timing / version envelope.
9
- */
10
-
11
- import { describe, it, expect, vi } from 'vitest';
12
- import { gunzipSync } from 'node:zlib';
13
- import type { GrasshopperClient, InputParam, SolveScheduler } from '@selvajs/compute';
14
-
15
- // --- Mock @selvajs/compute -------------------------------------------------
16
- vi.mock('@selvajs/compute', () => {
17
- return {
18
- TreeBuilder: {
19
- fromInputParams: (params: unknown[]) => ({ __tree: true, count: params.length })
20
- },
21
- // transform-input.ts (imported by the pipeline) calls processInput; return
22
- // the raw shape as an InputParam so filtering/mapping runs end-to-end.
23
- processInput: (raw: InputParam) => raw
24
- };
25
- });
26
-
27
- import {
28
- runSolvePipeline,
29
- COMPUTE_CONTRACT_VERSION,
30
- COMPUTE_VERSION_HEADER,
31
- type SolvePipelineArgs,
32
- type PipelineInput,
33
- type CachedClient
34
- } from '../index.js';
35
-
36
- /**
37
- * A scripted solve stub. The pipeline only JSON-serializes and reads
38
- * `errors`/`warnings`, so tests return arbitrary fake payloads — the return is a
39
- * loose record rather than the full `GrasshopperComputeResponse`.
40
- */
41
- type SolveStub = (
42
- def: string | Uint8Array,
43
- tree: unknown,
44
- opts: { signal?: AbortSignal }
45
- ) => Promise<Record<string, unknown>>;
46
-
47
- /** A CachedClient stub whose scheduler.solve is scripted per test. */
48
- function fakeClient(solve: SolveStub): CachedClient {
49
- return {
50
- client: {} as GrasshopperClient,
51
- scheduler: { solve } as unknown as SolveScheduler,
52
- rhinoTiming: { last: null, seq: 0 },
53
- solveMeta: { last: null, seq: 0 }
54
- };
55
- }
56
-
57
- function baseArgs(over: Partial<SolvePipelineArgs> = {}): SolvePipelineArgs {
58
- return {
59
- definitionSource: new Uint8Array([1, 2, 3]),
60
- inputs: [{ id: 'a', paramType: 'number', nickname: 'A' } as PipelineInput],
61
- values: { a: 5 },
62
- client: fakeClient(async () => ({ values: [], errors: [], warnings: [] })),
63
- responseMaxBytes: 10 * 1024 * 1024,
64
- maxSolveDurationMs: 100_000,
65
- acceptEncoding: '',
66
- signal: new AbortController().signal,
67
- loadStartMs: performance.now(),
68
- defLoadMs: 1.5,
69
- prepMarks: [['body', 0.2]],
70
- ...over
71
- };
72
- }
73
-
74
- describe('runSolvePipeline — success', () => {
75
- it('returns an ok envelope with a JSON body and the version header', async () => {
76
- const result = { values: [{ id: 'out' }], errors: [], warnings: ['w'] };
77
- const outcome = await runSolvePipeline(baseArgs({ client: fakeClient(async () => result) }));
78
- expect(outcome.kind).toBe('ok');
79
- if (outcome.kind !== 'ok') return;
80
- expect(outcome.errorCount).toBe(0);
81
- expect(outcome.warningCount).toBe(1);
82
- expect(outcome.envelope.encoding).toBeUndefined();
83
- expect(JSON.parse(outcome.envelope.body as string)).toEqual(result);
84
- expect(outcome.envelope.headers[COMPUTE_VERSION_HEADER]).toBe(String(COMPUTE_CONTRACT_VERSION));
85
- expect(outcome.envelope.headers['Content-Type']).toBe('application/json');
86
- expect(outcome.envelope.headers['Content-Length']).toBe(
87
- String(Buffer.byteLength(outcome.envelope.body as string))
88
- );
89
- });
90
-
91
- it('only sends inputs with a paramType to the tree builder', async () => {
92
- let seenTree: unknown;
93
- const outcome = await runSolvePipeline(
94
- baseArgs({
95
- inputs: [
96
- { id: 'a', paramType: 'number', nickname: 'A' } as PipelineInput,
97
- { id: 'b', nickname: 'B' } as PipelineInput // no paramType → filtered out
98
- ],
99
- client: fakeClient(async (_def, tree) => {
100
- seenTree = tree;
101
- return { errors: [], warnings: [] };
102
- })
103
- })
104
- );
105
- expect(outcome.kind).toBe('ok');
106
- expect(seenTree).toEqual({ __tree: true, count: 1 });
107
- });
108
-
109
- it('forwards the abort signal to the scheduler', async () => {
110
- const controller = new AbortController();
111
- let seenSignal: AbortSignal | undefined;
112
- await runSolvePipeline(
113
- baseArgs({
114
- signal: controller.signal,
115
- client: fakeClient(async (_d, _t, opts) => {
116
- seenSignal = opts.signal;
117
- return { errors: [], warnings: [] };
118
- })
119
- })
120
- );
121
- expect(seenSignal).toBe(controller.signal);
122
- });
123
- });
124
-
125
- describe('runSolvePipeline — gzip', () => {
126
- it('compresses when Accept-Encoding allows and the body is over 1 KB', async () => {
127
- // A payload comfortably over the 1 KB gzip threshold.
128
- const big = { values: Array.from({ length: 500 }, (_, i) => ({ i, s: 'xxxxxxxxxx' })) };
129
- const outcome = await runSolvePipeline(
130
- baseArgs({ acceptEncoding: 'gzip, br', client: fakeClient(async () => big) })
131
- );
132
- expect(outcome.kind).toBe('ok');
133
- if (outcome.kind !== 'ok') return;
134
- expect(outcome.envelope.encoding).toBe('gzip');
135
- expect(outcome.envelope.headers['Content-Encoding']).toBe('gzip');
136
- expect(outcome.envelope.headers['Vary']).toBe('Accept-Encoding');
137
- // The gzip body round-trips back to the original JSON.
138
- const inflated = gunzipSync(Buffer.from(outcome.envelope.body as Uint8Array)).toString();
139
- expect(JSON.parse(inflated)).toEqual(big);
140
- expect(outcome.envelope.metrics.compressedBytes).toBeGreaterThan(0);
141
- });
142
-
143
- it('does NOT compress when Accept-Encoding lacks gzip', async () => {
144
- const big = { values: Array.from({ length: 500 }, (_, i) => ({ i, s: 'xxxxxxxxxx' })) };
145
- const outcome = await runSolvePipeline(
146
- baseArgs({ acceptEncoding: 'br', client: fakeClient(async () => big) })
147
- );
148
- expect(outcome.kind).toBe('ok');
149
- if (outcome.kind !== 'ok') return;
150
- expect(outcome.envelope.encoding).toBeUndefined();
151
- expect(outcome.envelope.headers['Content-Encoding']).toBeUndefined();
152
- expect(outcome.envelope.metrics.compressedBytes).toBeNull();
153
- });
154
-
155
- it('does NOT compress a tiny body even with gzip advertised', async () => {
156
- const outcome = await runSolvePipeline(
157
- baseArgs({ acceptEncoding: 'gzip', client: fakeClient(async () => ({ ok: 1 })) })
158
- );
159
- expect(outcome.kind).toBe('ok');
160
- if (outcome.kind !== 'ok') return;
161
- expect(outcome.envelope.encoding).toBeUndefined();
162
- });
163
- });
164
-
165
- describe('runSolvePipeline — failure outcomes', () => {
166
- it('classifies the scheduler deadline (AbortError, signal NOT aborted) as timeout', async () => {
167
- const outcome = await runSolvePipeline(
168
- baseArgs({
169
- maxSolveDurationMs: 42_000,
170
- client: fakeClient(async () => {
171
- const e = new Error('aborted');
172
- e.name = 'AbortError';
173
- throw e;
174
- })
175
- })
176
- );
177
- expect(outcome.kind).toBe('timeout');
178
- if (outcome.kind !== 'timeout') return;
179
- expect(outcome.message).toContain('42s');
180
- expect(outcome.durationMs).toBeGreaterThanOrEqual(0);
181
- });
182
-
183
- it('classifies AbortError with the request signal aborted as client_abort', async () => {
184
- const controller = new AbortController();
185
- controller.abort();
186
- const outcome = await runSolvePipeline(
187
- baseArgs({
188
- signal: controller.signal,
189
- client: fakeClient(async () => {
190
- const e = new Error('aborted');
191
- e.name = 'AbortError';
192
- throw e;
193
- })
194
- })
195
- );
196
- expect(outcome.kind).toBe('client_abort');
197
- });
198
-
199
- it('classifies any other throw as compute_error carrying the original error', async () => {
200
- const boom = new TypeError('fetch failed');
201
- const outcome = await runSolvePipeline(
202
- baseArgs({
203
- client: fakeClient(async () => {
204
- throw boom;
205
- })
206
- })
207
- );
208
- expect(outcome.kind).toBe('compute_error');
209
- if (outcome.kind !== 'compute_error') return;
210
- expect(outcome.error).toBe(boom);
211
- });
212
-
213
- it('returns too_large when the serialized result exceeds responseMaxBytes', async () => {
214
- const outcome = await runSolvePipeline(
215
- baseArgs({
216
- responseMaxBytes: 10, // tiny cap
217
- client: fakeClient(async () => ({ values: 'a bigger than ten bytes payload' }))
218
- })
219
- );
220
- expect(outcome.kind).toBe('too_large');
221
- });
222
- });
223
-
224
- describe('runSolvePipeline — Server-Timing', () => {
225
- it('emits the core phases and echoes prep marks as p_*', async () => {
226
- const outcome = await runSolvePipeline(
227
- baseArgs({
228
- defLoadMs: 12.3,
229
- prepMarks: [
230
- ['body', 0.5],
231
- ['client', 3.1]
232
- ],
233
- client: fakeClient(async () => ({ errors: [], warnings: [] }))
234
- })
235
- );
236
- expect(outcome.kind).toBe('ok');
237
- if (outcome.kind !== 'ok') return;
238
- const st = outcome.envelope.headers['Server-Timing'];
239
- expect(st).toContain('load;dur=12.3');
240
- expect(st).toContain('tree;dur=');
241
- expect(st).toContain('solve;dur=');
242
- expect(st).toContain('serialize;dur=');
243
- expect(st).toContain('total;dur=');
244
- expect(st).toContain('p_body;dur=0.5');
245
- expect(st).toContain('p_client;dur=3.1');
246
- });
247
-
248
- it('splits solve into rhino_* + compute_link when the client reported server timing', async () => {
249
- // The real callbacks fire DURING the solve: onServerTiming per compute
250
- // call, onSettle per settle — each bumps its seq. Mimic both so the
251
- // pipeline's attribution guard (exactly one write since the snapshot,
252
- // settle not fromCache) sees this request's own telemetry.
253
- const client: CachedClient = fakeClient(async () => {
254
- client.rhinoTiming.seq += 1;
255
- client.rhinoTiming.last = { decode: 5, solve: 20, encode: 3 };
256
- client.solveMeta.seq += 1;
257
- client.solveMeta.last = { fromCache: false, definitionReuploaded: false };
258
- return { errors: [], warnings: [] };
259
- });
260
- const outcome = await runSolvePipeline(baseArgs({ client }));
261
- expect(outcome.kind).toBe('ok');
262
- if (outcome.kind !== 'ok') return;
263
- const st = outcome.envelope.headers['Server-Timing'];
264
- expect(st).toContain('rhino_decode;dur=5.0');
265
- expect(st).toContain('rhino_solve;dur=20.0');
266
- expect(st).toContain('rhino_encode;dur=3.0');
267
- expect(st).toContain('compute_link;dur=');
268
- });
269
-
270
- it('surfaces selva_cache + def_reupload verdicts as 0/1 flags', async () => {
271
- const client: CachedClient = fakeClient(async () => {
272
- client.solveMeta.seq += 1;
273
- client.solveMeta.last = { fromCache: true, definitionReuploaded: false };
274
- return { errors: [], warnings: [] };
275
- });
276
- const outcome = await runSolvePipeline(baseArgs({ client }));
277
- expect(outcome.kind).toBe('ok');
278
- if (outcome.kind !== 'ok') return;
279
- const st = outcome.envelope.headers['Server-Timing'];
280
- expect(st).toContain('selva_cache;dur=1');
281
- expect(st).toContain('def_reupload;dur=0');
282
- });
283
-
284
- it('does not attribute stale telemetry a previous request left on the warm client', async () => {
285
- // The holders are shared per-server. Data written before this solve's
286
- // snapshot (seq unchanged during the solve) must not leak into this
287
- // request's Server-Timing.
288
- const client = fakeClient(async () => ({ errors: [], warnings: [] }));
289
- client.rhinoTiming = { last: { decode: 99, solve: 99, encode: 99 }, seq: 3 };
290
- client.solveMeta = { last: { fromCache: true }, seq: 5 };
291
- const outcome = await runSolvePipeline(baseArgs({ client }));
292
- expect(outcome.kind).toBe('ok');
293
- if (outcome.kind !== 'ok') return;
294
- expect(outcome.envelope.headers['Server-Timing']).not.toContain('rhino_decode');
295
- expect(outcome.envelope.headers['Server-Timing']).not.toContain('selva_cache');
296
- });
297
-
298
- it('drops telemetry when a concurrent request also settled in the window (audit B6)', async () => {
299
- // With maxConcurrentSolves > 1 two solves can interleave. More than one
300
- // settle since this request's snapshot makes the shared slots ambiguous —
301
- // the pipeline must omit the segments rather than report another
302
- // request's cache verdict / rhino timing as ours.
303
- const client: CachedClient = fakeClient(async () => {
304
- // Our own solve's callbacks…
305
- client.rhinoTiming.seq += 1;
306
- client.rhinoTiming.last = { decode: 5, solve: 20, encode: 3 };
307
- client.solveMeta.seq += 1;
308
- client.solveMeta.last = { fromCache: false, definitionReuploaded: false };
309
- // …then a concurrent request settles before we read.
310
- client.rhinoTiming.seq += 1;
311
- client.rhinoTiming.last = { decode: 1, solve: 1, encode: 1 };
312
- client.solveMeta.seq += 1;
313
- client.solveMeta.last = { fromCache: true };
314
- return { errors: [], warnings: [] };
315
- });
316
- const outcome = await runSolvePipeline(baseArgs({ client }));
317
- expect(outcome.kind).toBe('ok');
318
- if (outcome.kind !== 'ok') return;
319
- const st = outcome.envelope.headers['Server-Timing'];
320
- expect(st).not.toContain('selva_cache');
321
- expect(st).not.toContain('rhino_decode');
322
- });
323
-
324
- it('never attributes rhino timing to a Selva-cache hit (no compute call of ours)', async () => {
325
- // Our request was served from the in-process cache while a concurrent
326
- // request's compute call produced the only rhino timing in the window.
327
- const client: CachedClient = fakeClient(async () => {
328
- client.rhinoTiming.seq += 1; // the OTHER request's compute call
329
- client.rhinoTiming.last = { decode: 7, solve: 70, encode: 7 };
330
- client.solveMeta.seq += 1; // our own settle — a cache hit
331
- client.solveMeta.last = { fromCache: true };
332
- return { errors: [], warnings: [] };
333
- });
334
- const outcome = await runSolvePipeline(baseArgs({ client }));
335
- expect(outcome.kind).toBe('ok');
336
- if (outcome.kind !== 'ok') return;
337
- const st = outcome.envelope.headers['Server-Timing'];
338
- expect(st).toContain('selva_cache;dur=1');
339
- expect(st).not.toContain('rhino_decode');
340
- });
341
- });
@@ -1,162 +0,0 @@
1
- /**
2
- * Tests for `transformInputParameter` — the adapter that turns a persisted `SchemaInput`
3
- * (plus the user's chosen value) into the `@selvajs/compute` `InputParam` that
4
- * `TreeBuilder.fromInputParams` serializes for the solve.
5
- *
6
- * The adapter delegates to the package's `processInput`, so these assert the
7
- * package-aligned behavior: correct `paramType` for every type (not the old
8
- * coerce-everything-to-Text), and `default` carrying the user's value. Where a value/
9
- * default is absent, `processInput` leaves `default` undefined and `fromInputParams`
10
- * omits the input entirely — letting Grasshopper use its own internal default rather
11
- * than forcing `''`/`false`.
12
- */
13
-
14
- import { describe, it, expect } from 'vitest';
15
- import { transformInputParameter } from '../transform-input.js';
16
- import type { SchemaInput } from '@selvajs/schemas';
17
-
18
- type Input = SchemaInput & { minimum?: number; maximum?: number; stepSize?: number };
19
-
20
- function input(partial: Partial<Input>): Input {
21
- return {
22
- id: 'id-1',
23
- nickname: 'Param',
24
- paramType: 'text',
25
- ...partial
26
- } as Input;
27
- }
28
-
29
- describe('transformInputParameter — identity fields', () => {
30
- it('carries id, name, nickname, description through', () => {
31
- const out = transformInputParameter(
32
- input({ id: 'guid-x', nickname: 'Width', description: 'the width' }),
33
- 5
34
- );
35
- expect(out.id).toBe('guid-x');
36
- expect(out.name).toBe('Width');
37
- expect(out.nickname).toBe('Width');
38
- expect(out.description).toBe('the width');
39
- });
40
-
41
- it('defaults a blank nickname to null and an absent description to empty string', () => {
42
- const out = transformInputParameter(input({ nickname: '', description: undefined }), 'v');
43
- expect(out.nickname).toBeNull();
44
- expect(out.description).toBe('');
45
- });
46
- });
47
-
48
- describe('transformInputParameter — number / integer', () => {
49
- it('maps number with the user value winning over the schema default', () => {
50
- const out = transformInputParameter(
51
- input({ paramType: 'number', default: 1, minimum: 0, maximum: 10, stepSize: 0.5 }),
52
- 7
53
- );
54
- expect(out).toMatchObject({ paramType: 'Number', default: 7, minimum: 0, maximum: 10 });
55
- });
56
-
57
- it('falls back to the schema default when no value is supplied', () => {
58
- const out = transformInputParameter(input({ paramType: 'number', default: 3 }), undefined);
59
- expect(out.default).toBe(3);
60
- });
61
-
62
- it('honors a schema-authored stepSize verbatim for integers (compute ≥3.1.0-beta.5)', () => {
63
- // The package used to force integer stepSize to 1; since beta.5 a
64
- // server-authored stepSize wins and the 1-heuristic is only the fallback.
65
- const out = transformInputParameter(
66
- input({ paramType: 'integer', stepSize: 0.5, default: 2 }),
67
- 4
68
- );
69
- expect(out).toMatchObject({ paramType: 'Integer', stepSize: 0.5, default: 4 });
70
- });
71
-
72
- it('falls back to stepSize 1 for integers without an authored stepSize', () => {
73
- const out = transformInputParameter(input({ paramType: 'integer', default: 2 }), 4);
74
- expect(out).toMatchObject({ paramType: 'Integer', stepSize: 1, default: 4 });
75
- });
76
- });
77
-
78
- describe('transformInputParameter — text', () => {
79
- it('maps text and keeps the user value', () => {
80
- const out = transformInputParameter(input({ paramType: 'text', default: 'd' }), 'hello');
81
- expect(out).toMatchObject({ paramType: 'Text', default: 'hello' });
82
- });
83
-
84
- it('falls back to the schema default when no value is supplied', () => {
85
- expect(
86
- transformInputParameter(input({ paramType: 'text', default: 'd' }), undefined).default
87
- ).toBe('d');
88
- });
89
-
90
- it('leaves default undefined when neither value nor schema default exist (input is then omitted)', () => {
91
- // processInput does not force ''. fromInputParams filters undefined defaults, so the
92
- // param is simply not sent and Grasshopper uses its own internal default.
93
- expect(
94
- transformInputParameter(input({ paramType: 'text', default: undefined }), undefined).default
95
- ).toBeUndefined();
96
- });
97
- });
98
-
99
- describe('transformInputParameter — boolean', () => {
100
- it('maps boolean and preserves an explicit false (the bug-prone case)', () => {
101
- const out = transformInputParameter(input({ paramType: 'boolean', default: true }), false);
102
- expect(out).toMatchObject({ paramType: 'Boolean', default: false });
103
- });
104
-
105
- it('falls back to the schema default when no value is supplied', () => {
106
- expect(
107
- transformInputParameter(input({ paramType: 'boolean', default: true }), undefined).default
108
- ).toBe(true);
109
- });
110
-
111
- it('leaves default undefined when neither value nor schema default exist (input is then omitted)', () => {
112
- expect(
113
- transformInputParameter(input({ paramType: 'boolean', default: undefined }), undefined)
114
- .default
115
- ).toBeUndefined();
116
- });
117
- });
118
-
119
- describe('transformInputParameter — value-list / file / color / generic', () => {
120
- // The regression that motivated the swap: these used to be silently coerced to Text.
121
- // processInput now types them correctly. (The options map for ValueList isn't carried
122
- // on SchemaInput, so `values` is empty and the selected value rides on `default` —
123
- // which is exactly the string a contextual ValueList param consumes.)
124
- it('maps valueList to ValueList, carrying the selected value', () => {
125
- const out = transformInputParameter(input({ paramType: 'valueList', default: '0' }), '1');
126
- expect(out).toMatchObject({ paramType: 'ValueList', default: '1' });
127
- });
128
-
129
- // Regression: processInput has no dynamicValueList handler, so without the explicit
130
- // mapping it fell to the Geometry fallback with a null default — and fromInputParams
131
- // then DROPPED the input, so the user's selection never reached the definition.
132
- // The plugin's GetDynamicValueListParameter reuses the ValueList wire contract
133
- // (TypeName "ValueList"), so ValueList is the correct target type.
134
- it('maps dynamicValueList to ValueList, carrying the selected value', () => {
135
- const out = transformInputParameter(
136
- input({ paramType: 'dynamicValueList', default: '0' }),
137
- '3.5'
138
- );
139
- expect(out).toMatchObject({ paramType: 'ValueList', default: '3.5' });
140
- });
141
-
142
- it('maps dynamicValueList checklist (array value) to ValueList, carrying all selections', () => {
143
- const out = transformInputParameter(input({ paramType: 'dynamicValueList' }), ['a', 'b']);
144
- expect(out.paramType).toBe('ValueList');
145
- expect(out.default).toEqual(['a', 'b']);
146
- });
147
-
148
- it('maps file to File', () => {
149
- const out = transformInputParameter(input({ paramType: 'file' }), { fileName: 'a.3dm' });
150
- expect(out.paramType).toBe('File');
151
- });
152
-
153
- it('maps color to Color, carrying the value', () => {
154
- const out = transformInputParameter(input({ paramType: 'color' }), '255,0,0');
155
- expect(out).toMatchObject({ paramType: 'Color', default: '255,0,0' });
156
- });
157
-
158
- it('maps generic to the package fallback type (Geometry), not Text', () => {
159
- const out = transformInputParameter(input({ paramType: 'generic', default: 'd' }), undefined);
160
- expect(out.paramType).toBe('Geometry');
161
- });
162
- });