@omriashke/dynamico-validator 0.1.1

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 (47) hide show
  1. package/LICENSE +184 -0
  2. package/dist/events.d.ts +10 -0
  3. package/dist/events.d.ts.map +1 -0
  4. package/dist/events.js +35 -0
  5. package/dist/events.js.map +1 -0
  6. package/dist/expect.d.ts +18 -0
  7. package/dist/expect.d.ts.map +1 -0
  8. package/dist/expect.js +69 -0
  9. package/dist/expect.js.map +1 -0
  10. package/dist/index.d.ts +24 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +24 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/mocks/react-native.d.ts +385 -0
  15. package/dist/mocks/react-native.d.ts.map +1 -0
  16. package/dist/mocks/react-native.js +181 -0
  17. package/dist/mocks/react-native.js.map +1 -0
  18. package/dist/mocks/safe-area-context.d.ts +50 -0
  19. package/dist/mocks/safe-area-context.d.ts.map +1 -0
  20. package/dist/mocks/safe-area-context.js +13 -0
  21. package/dist/mocks/safe-area-context.js.map +1 -0
  22. package/dist/queries.d.ts +16 -0
  23. package/dist/queries.d.ts.map +1 -0
  24. package/dist/queries.js +65 -0
  25. package/dist/queries.js.map +1 -0
  26. package/dist/render.d.ts +39 -0
  27. package/dist/render.d.ts.map +1 -0
  28. package/dist/render.js +34 -0
  29. package/dist/render.js.map +1 -0
  30. package/dist/runTest.d.ts +48 -0
  31. package/dist/runTest.d.ts.map +1 -0
  32. package/dist/runTest.js +339 -0
  33. package/dist/runTest.js.map +1 -0
  34. package/dist/timing.d.ts +7 -0
  35. package/dist/timing.d.ts.map +1 -0
  36. package/dist/timing.js +14 -0
  37. package/dist/timing.js.map +1 -0
  38. package/package.json +53 -0
  39. package/src/events.ts +38 -0
  40. package/src/expect.ts +70 -0
  41. package/src/index.ts +23 -0
  42. package/src/mocks/react-native.ts +203 -0
  43. package/src/mocks/safe-area-context.ts +15 -0
  44. package/src/queries.ts +66 -0
  45. package/src/render.ts +64 -0
  46. package/src/runTest.ts +402 -0
  47. package/src/timing.ts +15 -0
package/src/runTest.ts ADDED
@@ -0,0 +1,402 @@
1
+ import * as React from "react";
2
+ import { loadModule, type Scope } from "@omriashke/dynamico-core";
3
+ import * as RNMock from "./mocks/react-native.js";
4
+ import * as SafeAreaMock from "./mocks/safe-area-context.js";
5
+
6
+ export interface RunTestInput {
7
+ /** Component name (e.g. "HomeScreen"). Only used in error messages. */
8
+ name: string;
9
+ /** Compiled CommonJS code for the component itself. */
10
+ componentCode: string;
11
+ /** Compiled CommonJS code for the .test.tsx file. */
12
+ testCode: string;
13
+ /**
14
+ * Optional explicit scope provided by the registry. The runner merges this
15
+ * over its built-in defaults (react, react-native, safe-area-context).
16
+ * Pass anything the registry knows the host will provide; everything else
17
+ * is auto-stubbed with an empty object.
18
+ */
19
+ hostScope?: Scope;
20
+ /**
21
+ * Optional whitelist of bare specifiers the production host will provide
22
+ * via DynamicoProvider scope. When set, any component import that resolves
23
+ * to a specifier OUTSIDE this list causes the test to fail with phase
24
+ * "scope" — mirroring the runtime error the user would see on device.
25
+ * Relative imports (./, ../) are not checked.
26
+ *
27
+ * Pass the keys of the host's scope object. If undefined, the test runner
28
+ * auto-stubs unknown modules (permissive — useful for v1 onboarding).
29
+ */
30
+ allowedScope?: readonly string[];
31
+ /**
32
+ * Maximum wall-clock time the test may take, in ms. Default 5000. The
33
+ * registry's worker enforces this by terminating the worker on timeout;
34
+ * this field exists so authors can opt INTO faster timeouts, not slower.
35
+ */
36
+ timeoutMs?: number;
37
+ }
38
+
39
+ export interface RunTestResult {
40
+ ok: boolean;
41
+ durationMs: number;
42
+ /** When ok=false, the message thrown by the test (or load failure). */
43
+ error?: {
44
+ message: string;
45
+ stack?: string;
46
+ /** Which phase failed: 'load' (require), 'test' (test threw), or 'no-default-export'. */
47
+ phase: "load" | "scope" | "test" | "no-default-export" | "no-test-export";
48
+ };
49
+ }
50
+
51
+ const BUILT_IN_SCOPE: Scope = {
52
+ react: React,
53
+ "react-native": RNMock,
54
+ "react-native-safe-area-context": SafeAreaMock,
55
+ };
56
+
57
+ /**
58
+ * Test-time scope override. Tests can call setHostScope({...}) at the top of
59
+ * their default async function to provide real return shapes for hooks that
60
+ * the auto-stub can't fake convincingly.
61
+ *
62
+ * Resets between tests because each test runs in a fresh worker thread.
63
+ */
64
+ let mutableHostScope: Scope = {};
65
+ export function setHostScope(scope: Scope): void {
66
+ mutableHostScope = { ...mutableHostScope, ...scope };
67
+ }
68
+ export function getHostScope(): Scope {
69
+ return mutableHostScope;
70
+ }
71
+
72
+ /**
73
+ * Auto-stub policy: if a component require()s a key that isn't in BUILT_IN_SCOPE
74
+ * and isn't in the registry-supplied hostScope, return an empty object. This
75
+ * keeps tests authoring lightweight: a component that imports
76
+ * `@newscast/app-hooks` and only ever calls `useFeed()` from it will get
77
+ * `useFeed === undefined`, which the test author surfaces explicitly when they
78
+ * pass a real stub via hostScope.
79
+ *
80
+ * The empty-object stub is wrapped in a Proxy so that `pkg.someThing` returns
81
+ * an empty function (not undefined), preventing "X is not a function" errors
82
+ * for callable shapes the test doesn't care about. This is the right default
83
+ * for a smoke test: "the component renders and doesn't throw, given that
84
+ * nothing in the host scope returns anything interesting".
85
+ */
86
+ /**
87
+ * Build a scope where each module is a Proxy that resolves *every* property
88
+ * access lazily. Resolution order at access time:
89
+ * 1. mutableHostScope[moduleName][key] (test-time override via setHostScope)
90
+ * 2. registry-supplied hostScope[moduleName][key]
91
+ * 3. BUILT_IN_SCOPE[moduleName][key] (react, react-native, ...)
92
+ * 4. deep no-op stub
93
+ *
94
+ * This means setHostScope() works even AFTER the component has been loaded,
95
+ * because the destructured `useFeed` (or whatever) is itself a Proxy-callable
96
+ * that re-resolves on every invocation.
97
+ */
98
+ function makeAutoStubScope(
99
+ _componentExports: unknown,
100
+ hostScope: Scope,
101
+ allowedScope?: readonly string[],
102
+ ): Scope {
103
+ const builtIn = BUILT_IN_SCOPE;
104
+ const supplied = hostScope;
105
+ // When allowedScope is provided, the runner mirrors the production loader's
106
+ // strictness: any specifier not in the union of {allowedScope, BUILT_IN_SCOPE,
107
+ // hostScope, '__component__'} causes the loader's `name in scope` check to
108
+ // return false and throw "is not in host scope" — exactly what the device
109
+ // would see at runtime.
110
+ const allowedSet = allowedScope
111
+ ? new Set<string>([
112
+ ...allowedScope,
113
+ ...Object.keys(builtIn),
114
+ ...Object.keys(supplied),
115
+ ])
116
+ : undefined;
117
+
118
+ const lookupModule = (moduleName: string): unknown =>
119
+ mutableHostScope[moduleName] ?? supplied[moduleName] ?? builtIn[moduleName];
120
+
121
+ // For modules that have a real value (e.g. 'react' = React), we want to
122
+ // read straight through. We only Proxy the *unknown* modules — modules that
123
+ // either don't have a hostScope entry or are user packages where hooks may
124
+ // be overridden via setHostScope at test time.
125
+ //
126
+ // Strategy: ALWAYS go through a Proxy. If the underlying module exists, the
127
+ // Proxy reads from it; if not, fall back to deep stubs. This lets a test
128
+ // override individual exports of even the built-in 'react-native' if it
129
+ // really wants to, while keeping the common case (no override) trivially
130
+ // pass-through.
131
+ const moduleProxyCache = new Map<string, unknown>();
132
+ const moduleProxyFor = (moduleName: string): unknown => {
133
+ const cached = moduleProxyCache.get(moduleName);
134
+ if (cached) return cached;
135
+
136
+ // Use a callable target so the Proxy is also callable (some modules are
137
+ // CommonJS exports that are themselves functions, e.g. some libraries).
138
+ const target = function moduleStub() { /* see apply trap */ };
139
+
140
+ const prop = (key: PropertyKey): unknown => {
141
+ // Symbol props (Symbol.iterator etc.) — only meaningful when the
142
+ // underlying real module has them; otherwise undefined.
143
+ if (typeof key === "symbol") {
144
+ const real = lookupModule(moduleName) as Record<PropertyKey, unknown> | undefined;
145
+ return real ? real[key] : undefined;
146
+ }
147
+
148
+ // Test-time override (setHostScope) wins over everything.
149
+ const override = (mutableHostScope[moduleName] as Record<string, unknown> | undefined);
150
+ if (override && Object.prototype.hasOwnProperty.call(override, key)) {
151
+ return override[key as string];
152
+ }
153
+
154
+ // Registry-supplied scope.
155
+ const fromSupplied = (supplied[moduleName] as Record<string, unknown> | undefined);
156
+ if (fromSupplied && Object.prototype.hasOwnProperty.call(fromSupplied, key)) {
157
+ return fromSupplied[key as string];
158
+ }
159
+
160
+ // Built-in scope (real react, react-native mock, etc.).
161
+ const fromBuiltIn = (builtIn[moduleName] as Record<string, unknown> | undefined);
162
+ if (fromBuiltIn && Object.prototype.hasOwnProperty.call(fromBuiltIn, key)) {
163
+ return fromBuiltIn[key as string];
164
+ }
165
+
166
+ // Special CommonJS interop properties.
167
+ if (key === "__esModule") return true;
168
+ if (key === "default") return moduleProxyFor(moduleName);
169
+
170
+ // Final fallback: a deep no-op stub. Crucially this is wrapped so it
171
+ // checks mutableHostScope on EACH call (so the test can override the
172
+ // *callable's* return value via setHostScope at any point).
173
+ return makeLiveStub(moduleName, String(key));
174
+ };
175
+
176
+ const proxy = new Proxy(target, {
177
+ get(_t, key: PropertyKey): unknown {
178
+ return prop(key);
179
+ },
180
+ has() { return true; },
181
+ });
182
+ moduleProxyCache.set(moduleName, proxy);
183
+ return proxy;
184
+ };
185
+
186
+ return new Proxy(
187
+ {} as Record<string, unknown>,
188
+ {
189
+ get(_t, key: string): unknown {
190
+ return moduleProxyFor(key);
191
+ },
192
+ has(_t, key: PropertyKey): boolean {
193
+ if (allowedSet && typeof key === "string") {
194
+ return allowedSet.has(key);
195
+ }
196
+ return true;
197
+ },
198
+ },
199
+ ) as Scope;
200
+ }
201
+
202
+ /**
203
+ * A stub function that, on each call, re-checks mutableHostScope so that
204
+ * `useFeed` (destructured at module-load time) still respects late
205
+ * setHostScope() overrides set inside the test body.
206
+ */
207
+ function makeLiveStub(moduleName: string, propName: string): unknown {
208
+ const fn = function liveStub(...args: unknown[]) {
209
+ const override = mutableHostScope[moduleName] as Record<string, unknown> | undefined;
210
+ if (override && Object.prototype.hasOwnProperty.call(override, propName)) {
211
+ const real = override[propName];
212
+ if (typeof real === "function") {
213
+ return (real as (...a: unknown[]) => unknown)(...args);
214
+ }
215
+ return real;
216
+ }
217
+ return makeStubModule(`${moduleName}.${propName}()`);
218
+ };
219
+ return new Proxy(fn, {
220
+ get(_t, key: PropertyKey) {
221
+ if (key === Symbol.toPrimitive) return (_hint: string) => "";
222
+ if (typeof key === "symbol") return undefined;
223
+ if (key === "__esModule") return true;
224
+ if (key === "default") return fn;
225
+ return makeStubModule(`${moduleName}.${propName}.${String(key)}`);
226
+ },
227
+ has() { return true; },
228
+ });
229
+ }
230
+
231
+ const stubCache = new Map<string, unknown>();
232
+
233
+ /**
234
+ * Make a Proxy that's plausibly anything: callable, indexable, iterable as an
235
+ * empty array, destructurable into more stubs.
236
+ *
237
+ * Why so flexible? Tests for screens never want to fully simulate the host's
238
+ * data layer — they want to know the screen MOUNTS without throwing given
239
+ * "boring" data. So `useFeed()` returns this stub; destructuring
240
+ * `{ articles }` gives back another stub; `articles.map(x => ...)` returns
241
+ * `[]`; `.length === 0`. Real return shapes can be supplied explicitly via
242
+ * setHostScope() when the test cares.
243
+ */
244
+ function makeStubModule(name: string): unknown {
245
+ if (stubCache.has(name)) return stubCache.get(name);
246
+ const noop = () => makeStubModule(`${name}()`);
247
+ const stub: unknown = new Proxy(noop as unknown as object, {
248
+ get(_t, key) {
249
+ if (key === "__esModule") return true;
250
+ if (key === "default") return stub;
251
+ // Iterable protocol: pretend to be an empty array so `.map(...)`,
252
+ // `for (const x of ...)`, and spread `[...]` all work.
253
+ if (key === Symbol.iterator) return function* () { /* empty */ };
254
+ if (key === Symbol.asyncIterator) return async function* () { /* empty */ };
255
+ if (key === "length") return 0;
256
+ if (key === "map" || key === "filter" || key === "forEach" || key === "reduce" || key === "flatMap") {
257
+ return () => [];
258
+ }
259
+ if (key === "find" || key === "findIndex" || key === "indexOf" || key === "lastIndexOf") {
260
+ return () => -1;
261
+ }
262
+ if (key === "some" || key === "every" || key === "includes") {
263
+ return () => false;
264
+ }
265
+ if (key === "join" || key === "toString" || key === "toJSON") {
266
+ return () => "";
267
+ }
268
+ if (key === "valueOf") {
269
+ return () => 0;
270
+ }
271
+ if (key === "then" || key === "catch" || key === "finally") {
272
+ // not a thenable — these are sometimes accessed by frameworks
273
+ return undefined;
274
+ }
275
+ // Allow string/number/default coercion (e.g. `${stub}`, +stub) so stubs
276
+ // can flow through StyleSheet.create and other code paths that touch
277
+ // primitives. Returning undefined would also work for hint==="number"
278
+ // but we prefer a stable empty string representation.
279
+ if (key === Symbol.toPrimitive) return (_hint: string) => "";
280
+ if (typeof key === "symbol") return undefined;
281
+ return makeStubModule(`${name}.${String(key)}`);
282
+ },
283
+ apply() {
284
+ return makeStubModule(`${name}()`);
285
+ },
286
+ construct() {
287
+ return makeStubModule(`new ${name}()`) as object;
288
+ },
289
+ has() {
290
+ return true;
291
+ },
292
+ });
293
+ stubCache.set(name, stub);
294
+ return stub;
295
+ }
296
+
297
+ export async function runTest(input: RunTestInput): Promise<RunTestResult> {
298
+ const start = performance.now();
299
+
300
+ // Phase 1: load the component module
301
+ let componentExports: unknown;
302
+ try {
303
+ componentExports = loadModule(
304
+ input.componentCode,
305
+ makeAutoStubScope(undefined, input.hostScope ?? {}, input.allowedScope),
306
+ (specifier) => {
307
+ // Components may relative-import sibling components (`./Foo`) or
308
+ // static assets (`../assets/loginImage.png`). The registry resolves
309
+ // sibling components against its store, but in tests we don't have
310
+ // the rest of the registry available, so we hand back a deep stub
311
+ // for both. The component's behavior with a missing sibling is then
312
+ // exactly the same as if the sibling rendered no UI.
313
+ return makeStubModule(`relative:${specifier}`);
314
+ },
315
+ );
316
+ } catch (err) {
317
+ const msg = err instanceof Error ? err.message : String(err);
318
+ // Surface scope-misses with a clearer phase so the registry log/CLI
319
+ // output makes it obvious the component needs a host scope addition.
320
+ const phase = /is not in host scope/.test(msg) ? "scope" : "load";
321
+ return {
322
+ ok: false,
323
+ durationMs: performance.now() - start,
324
+ error: {
325
+ phase,
326
+ message: msg,
327
+ stack: err instanceof Error ? err.stack : undefined,
328
+ },
329
+ };
330
+ }
331
+
332
+ const defaultExport = (componentExports as Record<string, unknown>)?.default ?? componentExports;
333
+ if (typeof defaultExport !== "function") {
334
+ return {
335
+ ok: false,
336
+ durationMs: performance.now() - start,
337
+ error: {
338
+ phase: "no-default-export",
339
+ message: `component '${input.name}' has no default export of a function/class`,
340
+ },
341
+ };
342
+ }
343
+
344
+ // Phase 2: load the test module. The test imports the component via the
345
+ // synthetic specifier '__component__' which we inject into scope.
346
+ const testScope = makeAutoStubScope(componentExports, {
347
+ ...(input.hostScope ?? {}),
348
+ __component__: componentExports,
349
+ "@omriashke/dynamico-validator": await import("./index.js"),
350
+ });
351
+
352
+ let testExports: unknown;
353
+ try {
354
+ testExports = loadModule(input.testCode, testScope, (specifier) => {
355
+ // Resolve relative imports to the component (the typical pattern is
356
+ // `import Foo from './Foo'` inside Foo.test.tsx).
357
+ if (specifier.startsWith("./") || specifier.startsWith("../")) {
358
+ return componentExports;
359
+ }
360
+ throw new Error(`unsupported relative import '${specifier}' in test`);
361
+ });
362
+ } catch (err) {
363
+ return {
364
+ ok: false,
365
+ durationMs: performance.now() - start,
366
+ error: {
367
+ phase: "load",
368
+ message: `test file failed to load: ${err instanceof Error ? err.message : String(err)}`,
369
+ stack: err instanceof Error ? err.stack : undefined,
370
+ },
371
+ };
372
+ }
373
+
374
+ const testFn = (testExports as Record<string, unknown>)?.default;
375
+ if (typeof testFn !== "function") {
376
+ return {
377
+ ok: false,
378
+ durationMs: performance.now() - start,
379
+ error: {
380
+ phase: "no-test-export",
381
+ message: `${input.name}.test.tsx must export default an async function (got ${typeof testFn})`,
382
+ },
383
+ };
384
+ }
385
+
386
+ // Phase 3: execute the test
387
+ try {
388
+ await (testFn as () => unknown | Promise<unknown>)();
389
+ } catch (err) {
390
+ return {
391
+ ok: false,
392
+ durationMs: performance.now() - start,
393
+ error: {
394
+ phase: "test",
395
+ message: err instanceof Error ? err.message : String(err),
396
+ stack: err instanceof Error ? err.stack : undefined,
397
+ },
398
+ };
399
+ }
400
+
401
+ return { ok: true, durationMs: performance.now() - start };
402
+ }
package/src/timing.ts ADDED
@@ -0,0 +1,15 @@
1
+ import TestRenderer from "react-test-renderer";
2
+
3
+ export function sleep(ms: number): Promise<void> {
4
+ return new Promise((resolve) => setTimeout(resolve, ms));
5
+ }
6
+
7
+ /**
8
+ * Yield to React so any pending state updates / useEffect / promises commit
9
+ * before the next assertion. Use after firing events that schedule work.
10
+ */
11
+ export async function flush(): Promise<void> {
12
+ await TestRenderer.act(async () => {
13
+ await Promise.resolve();
14
+ });
15
+ }