@onlook/capsule 0.1.0

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.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Apply universal browser API stubs for happy-dom.
3
+ * Call once before importing any components.
4
+ */
5
+ export function applyBrowserStubs(): void {
6
+ const g = globalThis as Record<string, unknown>;
7
+
8
+ // Ensure `window` exists (Node/happy-dom doesn't always set it)
9
+ if (!g.window) {
10
+ g.window = globalThis;
11
+ }
12
+
13
+ // Ensure `document` exists
14
+ if (!g.document) {
15
+ const stubElement = (): Record<string, unknown> => ({
16
+ style: {},
17
+ setAttribute: () => {},
18
+ getAttribute: () => null,
19
+ removeAttribute: () => {},
20
+ addEventListener: () => {},
21
+ removeEventListener: () => {},
22
+ appendChild: () => stubElement(),
23
+ removeChild: () => stubElement(),
24
+ insertBefore: () => stubElement(),
25
+ cloneNode: () => stubElement(),
26
+ contains: () => false,
27
+ querySelector: () => null,
28
+ querySelectorAll: () => [],
29
+ children: [],
30
+ childNodes: [],
31
+ parentNode: null,
32
+ nextSibling: null,
33
+ previousSibling: null,
34
+ textContent: '',
35
+ innerHTML: '',
36
+ outerHTML: '',
37
+ tagName: 'DIV',
38
+ nodeName: 'DIV',
39
+ nodeType: 1,
40
+ classList: {
41
+ add: () => {},
42
+ remove: () => {},
43
+ toggle: () => false,
44
+ contains: () => false,
45
+ },
46
+ dataset: {},
47
+ getBoundingClientRect: () => ({
48
+ top: 0,
49
+ right: 0,
50
+ bottom: 0,
51
+ left: 0,
52
+ width: 0,
53
+ height: 0,
54
+ x: 0,
55
+ y: 0,
56
+ toJSON: () => ({}),
57
+ }),
58
+ });
59
+ g.document = {
60
+ createElement: () => stubElement(),
61
+ createElementNS: () => stubElement(),
62
+ createTextNode: () => ({ textContent: '', nodeType: 3 }),
63
+ createDocumentFragment: () => stubElement(),
64
+ getElementById: () => null,
65
+ querySelector: () => null,
66
+ querySelectorAll: () => [],
67
+ addEventListener: () => {},
68
+ removeEventListener: () => {},
69
+ head: stubElement(),
70
+ body: stubElement(),
71
+ documentElement: stubElement(),
72
+ cookie: '',
73
+ title: '',
74
+ referrer: '',
75
+ };
76
+ }
77
+
78
+ // Ensure `location` exists
79
+ if (!g.location) {
80
+ g.location = {
81
+ href: 'http://localhost/',
82
+ origin: 'http://localhost',
83
+ protocol: 'http:',
84
+ host: 'localhost',
85
+ hostname: 'localhost',
86
+ port: '',
87
+ pathname: '/',
88
+ search: '',
89
+ hash: '',
90
+ assign: () => {},
91
+ replace: () => {},
92
+ reload: () => {},
93
+ };
94
+ }
95
+
96
+ // Ensure `localStorage` and `sessionStorage` exist
97
+ const createStorage = () => {
98
+ const store = new Map<string, string>();
99
+ return {
100
+ getItem: (k: string) => store.get(k) ?? null,
101
+ setItem: (k: string, v: string) => store.set(k, v),
102
+ removeItem: (k: string) => store.delete(k),
103
+ clear: () => store.clear(),
104
+ get length() {
105
+ return store.size;
106
+ },
107
+ key: (i: number) => [...store.keys()][i] ?? null,
108
+ };
109
+ };
110
+ if (!g.localStorage) g.localStorage = createStorage();
111
+ if (!g.sessionStorage) g.sessionStorage = createStorage();
112
+
113
+ // Ensure `history` exists
114
+ if (!g.history) {
115
+ g.history = {
116
+ pushState: () => {},
117
+ replaceState: () => {},
118
+ go: () => {},
119
+ back: () => {},
120
+ forward: () => {},
121
+ state: null,
122
+ length: 1,
123
+ };
124
+ }
125
+
126
+ // matchMedia
127
+ if (!g.matchMedia) {
128
+ g.matchMedia = (query: string) => ({
129
+ matches: false,
130
+ media: query,
131
+ onchange: null,
132
+ addListener: () => {},
133
+ removeListener: () => {},
134
+ addEventListener: () => {},
135
+ removeEventListener: () => {},
136
+ dispatchEvent: () => false,
137
+ });
138
+ }
139
+
140
+ // IntersectionObserver
141
+ if (!g.IntersectionObserver) {
142
+ g.IntersectionObserver = class IntersectionObserver {
143
+ observe() {}
144
+ unobserve() {}
145
+ disconnect() {}
146
+ takeRecords(): IntersectionObserverEntry[] {
147
+ return [];
148
+ }
149
+ get root() {
150
+ return null;
151
+ }
152
+ get rootMargin() {
153
+ return '0px';
154
+ }
155
+ get thresholds() {
156
+ return [0];
157
+ }
158
+ };
159
+ }
160
+
161
+ // ResizeObserver
162
+ if (!g.ResizeObserver) {
163
+ g.ResizeObserver = class ResizeObserver {
164
+ observe() {}
165
+ unobserve() {}
166
+ disconnect() {}
167
+ };
168
+ }
169
+
170
+ // scrollTo
171
+ if (!g.scrollTo) {
172
+ g.scrollTo = () => {};
173
+ }
174
+
175
+ // requestAnimationFrame
176
+ if (!g.requestAnimationFrame) {
177
+ g.requestAnimationFrame = (cb: FrameRequestCallback) => {
178
+ return setTimeout(() => cb(Date.now()), 0) as unknown as number;
179
+ };
180
+ }
181
+
182
+ // cancelAnimationFrame
183
+ if (!g.cancelAnimationFrame) {
184
+ g.cancelAnimationFrame = (id: number) => {
185
+ clearTimeout(id);
186
+ };
187
+ }
188
+
189
+ // Clipboard API
190
+ if (typeof navigator !== 'undefined' && !navigator.clipboard) {
191
+ Object.defineProperty(navigator, 'clipboard', {
192
+ value: {
193
+ writeText: async () => {},
194
+ readText: async () => '',
195
+ write: async () => {},
196
+ read: async () => [],
197
+ },
198
+ writable: true,
199
+ });
200
+ }
201
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { withMockServer } from './msw-setup.js';
3
+
4
+ describe('withMockServer', () => {
5
+ it('returns the callback result', async () => {
6
+ const result = await withMockServer([], () => 'hello');
7
+ expect(result).toBe('hello');
8
+ });
9
+
10
+ it('returns async callback result', async () => {
11
+ const result = await withMockServer([], async () => {
12
+ await new Promise((r) => setTimeout(r, 5));
13
+ return 42;
14
+ });
15
+ expect(result).toBe(42);
16
+ });
17
+
18
+ it('cleans up server even when callback throws', async () => {
19
+ const error = new Error('intentional');
20
+ let caught: Error | undefined;
21
+
22
+ try {
23
+ await withMockServer([], () => {
24
+ throw error;
25
+ });
26
+ } catch (e) {
27
+ caught = e as Error;
28
+ }
29
+
30
+ expect(caught).toBe(error);
31
+
32
+ // If server wasn't cleaned up, a second call would fail or hang.
33
+ // This call succeeding proves cleanup worked.
34
+ const result = await withMockServer([], () => 'ok');
35
+ expect(result).toBe('ok');
36
+ });
37
+
38
+ it('works with empty mock list', async () => {
39
+ const result = await withMockServer([], () => 'no mocks');
40
+ expect(result).toBe('no mocks');
41
+ });
42
+ });
@@ -0,0 +1,63 @@
1
+ import { HttpResponse, http } from 'msw';
2
+ import { type SetupServerApi, setupServer } from 'msw/node';
3
+ import type { NetworkMockSpec } from '../types.js';
4
+
5
+ /** Shared singleton MSW server — avoids "fetch already patched" errors */
6
+ let sharedServer: SetupServerApi | null = null;
7
+ let refCount = 0;
8
+
9
+ function getServer(): SetupServerApi {
10
+ if (!sharedServer) {
11
+ // Start with a catch-all handler; routes override via resetHandlers
12
+ sharedServer = setupServer(http.all('*', () => HttpResponse.json({})));
13
+ sharedServer.listen({ onUnhandledRequest: 'bypass' });
14
+ }
15
+ refCount++;
16
+ return sharedServer;
17
+ }
18
+
19
+ function releaseServer(): void {
20
+ refCount--;
21
+ if (refCount <= 0 && sharedServer) {
22
+ sharedServer.close();
23
+ sharedServer = null;
24
+ refCount = 0;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Create MSW handlers from a list of network mock specs.
30
+ */
31
+ function buildHandlers(mocks: NetworkMockSpec[]) {
32
+ const handlers = mocks.map((mock) => {
33
+ const method = mock.method.toLowerCase() as keyof typeof http;
34
+ const pattern = `*${mock.path}`;
35
+ return http[method](pattern, () => {
36
+ return HttpResponse.json(mock.response as Record<string, unknown>, {
37
+ status: mock.status ?? 200,
38
+ });
39
+ });
40
+ });
41
+
42
+ // Catch-all: return empty JSON for any unmatched request
43
+ handlers.push(http.all('*', () => HttpResponse.json({})));
44
+
45
+ return handlers;
46
+ }
47
+
48
+ /**
49
+ * Run a callback with MSW network mocks active.
50
+ * Uses a shared server to avoid patching fetch multiple times.
51
+ */
52
+ export async function withMockServer<T>(
53
+ mocks: NetworkMockSpec[],
54
+ fn: () => T | Promise<T>,
55
+ ): Promise<T> {
56
+ const server = getServer();
57
+ server.resetHandlers(...buildHandlers(mocks));
58
+ try {
59
+ return await fn();
60
+ } finally {
61
+ releaseServer();
62
+ }
63
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Safe proxy utilities for capsule hookStub virtual modules.
3
+ *
4
+ * These provide React-safe deep proxies and value resolution for
5
+ * hook stubs rendered in the capsule viewer. Imported at runtime
6
+ * by the virtual modules that stub-generators.ts emits.
7
+ */
8
+
9
+ /** No-op function — default for function-typed stub values. */
10
+ export function noop() {}
11
+
12
+ /** Identity function — passthrough stub values. */
13
+ export function identity<T>(x: T): T {
14
+ return x;
15
+ }
16
+
17
+ // React properties that must return undefined to prevent React from
18
+ // treating the proxy as a React element or component.
19
+ const REACT_UNDEFINED_PROPS = new Set([
20
+ '$$typeof',
21
+ '_owner',
22
+ '_store',
23
+ 'ref',
24
+ 'key',
25
+ 'type',
26
+ 'props',
27
+ '_self',
28
+ '_source',
29
+ 'getDefaultProps',
30
+ 'childContextTypes',
31
+ 'defaultProps',
32
+ 'getDerivedStateFromProps',
33
+ 'contextType',
34
+ 'contextTypes',
35
+ 'propTypes',
36
+ 'PropTypes',
37
+ 'displayName',
38
+ ]);
39
+
40
+ const dpHandler: ProxyHandler<(...args: unknown[]) => unknown> = {
41
+ get(_t, prop) {
42
+ if (typeof prop === 'string' && REACT_UNDEFINED_PROPS.has(prop)) {
43
+ return undefined;
44
+ }
45
+ if (
46
+ prop === Symbol.toPrimitive ||
47
+ prop === 'toString' ||
48
+ prop === 'valueOf' ||
49
+ prop === 'toJSON'
50
+ ) {
51
+ return () => '';
52
+ }
53
+ // Empty iterator for [...proxy], for...of, and destructuring
54
+ if (prop === Symbol.iterator) {
55
+ return () => ({ next: () => ({ done: true }) });
56
+ }
57
+ if (prop === Symbol.asyncIterator || typeof prop === 'symbol') {
58
+ return undefined;
59
+ }
60
+ // Prevent Promise auto-unwrapping
61
+ if (prop === 'then') return undefined;
62
+ if (prop === 'length') return 0;
63
+ if (prop === 'constructor') return Object;
64
+ // Any other property → recurse
65
+ return dp();
66
+ },
67
+ apply() {
68
+ return dp();
69
+ },
70
+ construct() {
71
+ return dp() as object;
72
+ },
73
+ };
74
+
75
+ /**
76
+ * Create a deep proxy safe for any property chain, function call,
77
+ * iteration, and string coercion. Returned for missing properties
78
+ * on hookStub objects so components never crash on access chains.
79
+ */
80
+ export function dp(): unknown {
81
+ const target = () => {};
82
+ target.toString = () => '';
83
+ target.valueOf = () => '';
84
+ // biome-ignore lint/suspicious/noExplicitAny: Symbol index
85
+ (target as any)[Symbol.toPrimitive] = () => '';
86
+ return new Proxy(target, dpHandler);
87
+ }
88
+
89
+ /**
90
+ * Proxy handler for hookStub return-value objects. Known properties
91
+ * return their real value; unknown properties return a deep proxy.
92
+ */
93
+ const safeHandler: ProxyHandler<Record<string, unknown>> = {
94
+ get(target, prop) {
95
+ if (typeof prop === 'symbol') return undefined;
96
+ if (prop in target) return target[prop];
97
+ return dp();
98
+ },
99
+ };
100
+
101
+ /**
102
+ * Resolve a JSON-serializable stub value into a runtime value.
103
+ *
104
+ * Sentinels:
105
+ * - `"__NOOP_FN__"` → noop function
106
+ * - `"__IDENTITY_FN__"` → identity function
107
+ * - `"__DEEP_PROXY__"` → deep proxy
108
+ *
109
+ * Arrays are resolved recursively. Plain objects are wrapped in
110
+ * the safe proxy so missing properties return a deep proxy.
111
+ */
112
+ export function rv(v: unknown): unknown {
113
+ if (v === '__NOOP_FN__') return noop;
114
+ if (v === '__IDENTITY_FN__') return identity;
115
+ if (v === '__DEEP_PROXY__') return dp();
116
+ if (Array.isArray(v)) return v.map(rv);
117
+ if (v && typeof v === 'object' && v.constructor === Object) {
118
+ const r: Record<string, unknown> = {};
119
+ for (const k in v) {
120
+ r[k] = rv((v as Record<string, unknown>)[k]);
121
+ }
122
+ return new Proxy(r, safeHandler);
123
+ }
124
+ return v;
125
+ }
126
+
127
+ /** Shallow merge of base and override objects. */
128
+ export function merge(base: unknown, over: unknown): Record<string, unknown> {
129
+ if (!over || typeof over !== 'object') {
130
+ return base as Record<string, unknown>;
131
+ }
132
+ const r = typeof base === 'object' && base ? { ...base } : {};
133
+ Object.assign(r, over);
134
+ return r as Record<string, unknown>;
135
+ }
136
+
137
+ /** Read a per-hook override from window.__CAPSULE_OVERRIDES__. */
138
+ export function getOverride(key: string): unknown {
139
+ const w =
140
+ typeof window !== 'undefined' &&
141
+ (window as unknown as Record<string, unknown>).__CAPSULE_OVERRIDES__;
142
+ return w ? (w as Record<string, unknown>)[key] : undefined;
143
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { setNestedValue } from './CapsuleShell.js';
3
+
4
+ describe('setNestedValue', () => {
5
+ it('sets a single key', () => {
6
+ const obj: Record<string, unknown> = {};
7
+ setNestedValue(obj, 'foo', 42);
8
+ expect(obj.foo).toBe(42);
9
+ });
10
+
11
+ it('sets a deep path, creating intermediates', () => {
12
+ const obj: Record<string, unknown> = {};
13
+ setNestedValue(obj, 'window.ENV.API_URL', 'http://test');
14
+ const win = obj.window as Record<string, Record<string, unknown>>;
15
+ expect(win.ENV?.API_URL).toBe('http://test');
16
+ });
17
+
18
+ it('overwrites existing values', () => {
19
+ const obj: Record<string, unknown> = { foo: 'old' };
20
+ setNestedValue(obj, 'foo', 'new');
21
+ expect(obj.foo).toBe('new');
22
+ });
23
+
24
+ it('overwrites non-object intermediate with object', () => {
25
+ const obj: Record<string, unknown> = { a: 'string' };
26
+ setNestedValue(obj, 'a.b', 'deep');
27
+ expect((obj.a as Record<string, unknown>).b).toBe('deep');
28
+ });
29
+
30
+ it('preserves sibling keys at intermediate levels', () => {
31
+ const obj: Record<string, unknown> = {
32
+ env: { EXISTING: true },
33
+ };
34
+ setNestedValue(obj, 'env.NEW', 'added');
35
+ const env = obj.env as Record<string, unknown>;
36
+ expect(env.EXISTING).toBe(true);
37
+ expect(env.NEW).toBe('added');
38
+ });
39
+
40
+ it('handles null intermediate by replacing with object', () => {
41
+ const obj: Record<string, unknown> = { a: null };
42
+ setNestedValue(obj, 'a.b', 'val');
43
+ expect((obj.a as Record<string, unknown>).b).toBe('val');
44
+ });
45
+ });