@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.
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@onlook/capsule",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "description": "Automatic isolated route previews — no stories, no manual curation",
9
+ "license": "ISC",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./types": {
16
+ "types": "./dist/types.d.ts",
17
+ "default": "./dist/types.js"
18
+ },
19
+ "./runtime": {
20
+ "types": "./src/runtime/CapsuleShell.tsx",
21
+ "default": "./src/runtime/CapsuleShell.tsx"
22
+ },
23
+ "./safe-proxy": {
24
+ "types": "./src/runtime/safe-proxy.ts",
25
+ "default": "./src/runtime/safe-proxy.ts"
26
+ }
27
+ },
28
+ "bin": {
29
+ "capsule": "./dist/cli.js"
30
+ },
31
+ "main": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "files": [
34
+ "dist",
35
+ "src/runtime",
36
+ "src/types.ts"
37
+ ],
38
+ "scripts": {
39
+ "build": "tsup",
40
+ "dev": "tsup --watch",
41
+ "test": "bun test",
42
+ "typecheck": "tsc --noEmit",
43
+ "dev:capsule": "bun run src/cli.ts seal",
44
+ "pack:sandbox": "tsup && npm pack",
45
+ "clean": "git clean -xdf dist node_modules",
46
+ "dev:evergreen": "./test-e2b/run-test-dev.sh"
47
+ },
48
+ "dependencies": {
49
+ "@drizzle-team/brocli": "^0.11.0",
50
+ "@openrouter/ai-sdk-provider": "^1.5.0",
51
+ "ai": "^6.0.0",
52
+ "esbuild": "^0.25.0",
53
+ "glob": "^10.3.10",
54
+ "happy-dom": "^20.8.3",
55
+ "minimatch": "^9.0.3",
56
+ "msw": "^2.7.0",
57
+ "oxc-parser": "^0.123.0",
58
+ "oxc-resolver": "^11.19.1",
59
+ "ts-morph": "^21.0.1",
60
+ "unenv": "^1.10.0",
61
+ "unplugin": "^3.0.0",
62
+ "zod": "^4.1.13"
63
+ },
64
+ "devDependencies": {
65
+ "@onbook/tsconfig": "workspace:*",
66
+ "@types/node": "^22.15.32",
67
+ "@types/react": "^19.0.12",
68
+ "@types/react-dom": "^19.0.4",
69
+ "tsup": "^8.5.1",
70
+ "typescript": "5.8.3",
71
+ "vite": "^6.3.5"
72
+ },
73
+ "peerDependencies": {
74
+ "react": "^18.0.0 || ^19.0.0",
75
+ "react-dom": "^18.0.0 || ^19.0.0"
76
+ }
77
+ }
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { renderToString } from 'react-dom/server';
3
+ import type { CapsuleManifest } from '../types.js';
4
+ import { CapsuleShell } from './CapsuleShell.js';
5
+
6
+ function makeManifest(overrides: Partial<CapsuleManifest> = {}): CapsuleManifest {
7
+ return {
8
+ providers: overrides.providers ?? [],
9
+ hookStubs: overrides.hookStubs ?? [],
10
+ networkMocks: overrides.networkMocks ?? [],
11
+ globals: overrides.globals ?? {},
12
+ };
13
+ }
14
+
15
+ describe('CapsuleShell', () => {
16
+ it('renders children when no errors', () => {
17
+ const html = renderToString(
18
+ <CapsuleShell route="/test" manifest={makeManifest()}>
19
+ <div data-capsule="ok">Hello</div>
20
+ </CapsuleShell>,
21
+ );
22
+ expect(html).toContain('Hello');
23
+ expect(html).not.toContain('data-capsule-error');
24
+ });
25
+
26
+ it('applies globals from manifest', () => {
27
+ const g = globalThis as Record<string, unknown>;
28
+ const manifest = makeManifest({
29
+ globals: { TEST_CAPSULE_GLOBAL: 'applied' },
30
+ });
31
+
32
+ renderToString(
33
+ <CapsuleShell route="/test" manifest={manifest}>
34
+ <div />
35
+ </CapsuleShell>,
36
+ );
37
+
38
+ expect(g.TEST_CAPSULE_GLOBAL).toBe('applied');
39
+ delete g.TEST_CAPSULE_GLOBAL;
40
+ });
41
+
42
+ it('applies nested globals from manifest', () => {
43
+ const g = globalThis as Record<string, unknown>;
44
+ const manifest = makeManifest({
45
+ globals: { 'CAPSULE_TEST.nested.key': 42 },
46
+ });
47
+
48
+ renderToString(
49
+ <CapsuleShell route="/test" manifest={manifest}>
50
+ <div />
51
+ </CapsuleShell>,
52
+ );
53
+
54
+ const obj = g.CAPSULE_TEST as Record<string, Record<string, unknown>>;
55
+ expect(obj.nested?.key).toBe(42);
56
+ delete g.CAPSULE_TEST;
57
+ });
58
+
59
+ it('passes children through MockProvider unchanged', () => {
60
+ const manifest = makeManifest({
61
+ providers: [
62
+ {
63
+ id: 'auth',
64
+ import: '@/contexts/auth',
65
+ contextName: 'AuthContext',
66
+ mockValue: { user: null },
67
+ },
68
+ ],
69
+ });
70
+
71
+ const html = renderToString(
72
+ <CapsuleShell route="/test" manifest={manifest}>
73
+ <div data-capsule="ok">With provider</div>
74
+ </CapsuleShell>,
75
+ );
76
+
77
+ expect(html).toContain('With provider');
78
+ expect(html).not.toContain('data-capsule-error');
79
+ });
80
+
81
+ it('handles multiple providers without crashing', () => {
82
+ const manifest = makeManifest({
83
+ providers: [
84
+ {
85
+ id: 'auth',
86
+ import: '@/contexts/auth',
87
+ contextName: 'AuthContext',
88
+ mockValue: { user: null },
89
+ },
90
+ {
91
+ id: 'theme',
92
+ import: '@/contexts/theme',
93
+ contextName: 'ThemeContext',
94
+ mockValue: { theme: 'dark' },
95
+ },
96
+ ],
97
+ });
98
+
99
+ const html = renderToString(
100
+ <CapsuleShell route="/test" manifest={manifest}>
101
+ <div>Content</div>
102
+ </CapsuleShell>,
103
+ );
104
+
105
+ expect(html).toContain('Content');
106
+ expect(html).not.toContain('data-capsule-error');
107
+ });
108
+
109
+ it('handles empty manifest gracefully', () => {
110
+ const html = renderToString(
111
+ <CapsuleShell route="/" manifest={makeManifest()}>
112
+ <span>Minimal</span>
113
+ </CapsuleShell>,
114
+ );
115
+ expect(html).toContain('Minimal');
116
+ });
117
+
118
+ it('applies multiple globals', () => {
119
+ const g = globalThis as Record<string, unknown>;
120
+ const manifest = makeManifest({
121
+ globals: {
122
+ CAPSULE_A: 'first',
123
+ CAPSULE_B: 'second',
124
+ },
125
+ });
126
+
127
+ renderToString(
128
+ <CapsuleShell route="/test" manifest={manifest}>
129
+ <div />
130
+ </CapsuleShell>,
131
+ );
132
+
133
+ expect(g.CAPSULE_A).toBe('first');
134
+ expect(g.CAPSULE_B).toBe('second');
135
+ delete g.CAPSULE_A;
136
+ delete g.CAPSULE_B;
137
+ });
138
+ });
139
+
140
+ // Note: Error boundary categorization is tested through the seal loop
141
+ // integration tests (seal.test.ts) because React SSR's renderToString
142
+ // re-throws errors instead of catching them in error boundaries.
143
+ // The categorizeError function is exercised when components render
144
+ // in happy-dom via the seal pipeline.
@@ -0,0 +1,189 @@
1
+ import React from 'react';
2
+ import type { CapsuleManifest } from '../types.js';
3
+
4
+ type CapsuleShellProps = {
5
+ route: string;
6
+ manifest: CapsuleManifest;
7
+ children: React.ReactNode;
8
+ };
9
+
10
+ /**
11
+ * Generic wrapper that reads a CapsuleManifest and builds the render environment.
12
+ *
13
+ * 1. Applies global assignments from manifest.globals
14
+ * 2. Builds a provider tree from manifest.providers
15
+ * 3. Wraps children in an error boundary
16
+ *
17
+ * Note: MSW network mocks are set up externally by the seal loop,
18
+ * not inside this component (they need to be active before render).
19
+ * Hook stubs are also applied externally via module mocking.
20
+ */
21
+ export function CapsuleShell({ manifest, children }: CapsuleShellProps) {
22
+ // Apply globals
23
+ for (const [key, value] of Object.entries(manifest.globals)) {
24
+ setNestedValue(globalThis, key, value);
25
+ }
26
+
27
+ // Build provider tree (innermost last in the array = innermost in the tree)
28
+ let tree = <>{children}</>;
29
+
30
+ // Providers are applied outermost-first, so iterate in reverse
31
+ // so that providers[0] is the outermost wrapper
32
+ for (let i = manifest.providers.length - 1; i >= 0; i--) {
33
+ const provider = manifest.providers[i];
34
+ if (!provider) continue;
35
+
36
+ // At runtime, we can't dynamically resolve context imports.
37
+ // The CapsuleShell is used in two modes:
38
+ // 1. Static shell files (generated code does the actual imports)
39
+ // 2. Seal loop testing (contexts are stubbed via module mocking)
40
+ //
41
+ // For now, we wrap with a simple value-providing context stub.
42
+ // The seal loop's esbuild step handles actual context resolution.
43
+ tree = (
44
+ <MockProvider contextName={provider.contextName} value={provider.mockValue}>
45
+ {tree}
46
+ </MockProvider>
47
+ );
48
+ }
49
+
50
+ return <CapsuleErrorBoundary>{tree}</CapsuleErrorBoundary>;
51
+ }
52
+
53
+ /**
54
+ * Simple mock provider that creates a React context on the fly.
55
+ * Used when the actual context isn't importable at bundle time.
56
+ */
57
+ function MockProvider({
58
+ children,
59
+ }: {
60
+ contextName: string;
61
+ value: Record<string, unknown>;
62
+ children: React.ReactNode;
63
+ }) {
64
+ // In the sealed environment, hook stubs handle context access.
65
+ // This component just passes children through — the real provider
66
+ // wrapping happens via hook stubs in the seal loop.
67
+ return <>{children}</>;
68
+ }
69
+
70
+ /**
71
+ * Error boundary that catches render errors, categorizes them,
72
+ * and shows diagnostic information for the seal loop and viewer.
73
+ */
74
+ class CapsuleErrorBoundary extends React.Component<
75
+ { children: React.ReactNode },
76
+ { hasError: boolean; error: Error | null; componentStack: string }
77
+ > {
78
+ constructor(props: { children: React.ReactNode }) {
79
+ super(props);
80
+ this.state = { hasError: false, error: null, componentStack: '' };
81
+ }
82
+
83
+ static getDerivedStateFromError(error: Error) {
84
+ return { hasError: true, error };
85
+ }
86
+
87
+ componentDidCatch(_error: Error, errorInfo: React.ErrorInfo) {
88
+ this.setState({
89
+ componentStack: errorInfo.componentStack ?? '',
90
+ });
91
+ }
92
+
93
+ render() {
94
+ if (this.state.hasError) {
95
+ const msg = this.state.error?.message ?? 'Unknown error';
96
+ const category = categorizeError(msg);
97
+
98
+ return (
99
+ <div data-capsule-error="true">
100
+ {msg}
101
+ {/* Hidden diagnostic data for the seal loop to parse */}
102
+ <div
103
+ data-capsule-category={category.type}
104
+ data-capsule-hint={category.hint}
105
+ data-capsule-stack={this.state.componentStack}
106
+ style={{ display: 'none' }}
107
+ />
108
+ </div>
109
+ );
110
+ }
111
+ return this.props.children;
112
+ }
113
+ }
114
+
115
+ /** Categorize a render error to help the seal loop and viewer diagnose issues. */
116
+ function categorizeError(message: string): {
117
+ type: string;
118
+ hint: string;
119
+ } {
120
+ if (
121
+ message.includes('Cannot read properties of undefined') ||
122
+ message.includes('Cannot read properties of null') ||
123
+ message.includes('undefined is not an object')
124
+ ) {
125
+ return {
126
+ type: 'missing-property',
127
+ hint: 'A variable is undefined — likely a missing context provider, config, or data dependency.',
128
+ };
129
+ }
130
+ if (message.includes('useContext') || message.includes('provider')) {
131
+ return {
132
+ type: 'missing-provider',
133
+ hint: 'A React context provider is missing from the component tree. Add it to manifest.providers.',
134
+ };
135
+ }
136
+ if (message.includes('is not a function') || message.includes('is not a constructor')) {
137
+ return {
138
+ type: 'stubbed-call',
139
+ hint: 'A stubbed dependency was called as a function. Add a hookStub with proper return value.',
140
+ };
141
+ }
142
+ if (
143
+ message.includes('GraphQL') ||
144
+ message.includes('client was not initialized') ||
145
+ message.includes('ApolloError') ||
146
+ message.includes('gql')
147
+ ) {
148
+ return {
149
+ type: 'network-error',
150
+ hint: 'A GraphQL client is not initialized. Ensure @apollo/client or similar is stubbed.',
151
+ };
152
+ }
153
+ if (
154
+ message.includes('fetch') ||
155
+ message.includes('NetworkError') ||
156
+ message.includes('Failed to fetch')
157
+ ) {
158
+ return {
159
+ type: 'network-error',
160
+ hint: 'A network request failed. Add networkMocks to the manifest.',
161
+ };
162
+ }
163
+ return {
164
+ type: 'unknown',
165
+ hint: 'Unrecognized error — may need manual manifest entries or globals.',
166
+ };
167
+ }
168
+
169
+ /** Set a nested value on an object using dot-notation path */
170
+ export function setNestedValue(
171
+ obj: Record<string, unknown>,
172
+ keyPath: string,
173
+ value: unknown,
174
+ ): void {
175
+ const keys = keyPath.split('.');
176
+ let current: Record<string, unknown> = obj;
177
+ for (let i = 0; i < keys.length - 1; i++) {
178
+ const key = keys[i];
179
+ if (!key) continue;
180
+ if (typeof current[key] !== 'object' || current[key] === null) {
181
+ current[key] = {};
182
+ }
183
+ current = current[key] as Record<string, unknown>;
184
+ }
185
+ const lastKey = keys[keys.length - 1];
186
+ if (lastKey) {
187
+ current[lastKey] = value;
188
+ }
189
+ }
@@ -0,0 +1,178 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test';
2
+ import { applyBrowserStubs } from './browser-stubs.js';
3
+
4
+ describe('applyBrowserStubs', () => {
5
+ const originalKeys = new Set(Object.keys(globalThis));
6
+
7
+ afterEach(() => {
8
+ // Clean up stubs we added (best-effort — some may not be deletable)
9
+ const g = globalThis as Record<string, unknown>;
10
+ for (const key of Object.keys(g)) {
11
+ if (!originalKeys.has(key)) {
12
+ try {
13
+ delete g[key];
14
+ } catch {
15
+ // some properties aren't configurable
16
+ }
17
+ }
18
+ }
19
+ });
20
+
21
+ it('creates window if missing', () => {
22
+ const g = globalThis as Record<string, unknown>;
23
+ const original = g.window;
24
+ delete g.window;
25
+ applyBrowserStubs();
26
+ expect(g.window).toBeDefined();
27
+ g.window = original;
28
+ });
29
+
30
+ it('creates localStorage with full Storage API', () => {
31
+ const g = globalThis as Record<string, unknown>;
32
+ const original = g.localStorage;
33
+ delete g.localStorage;
34
+ applyBrowserStubs();
35
+
36
+ const storage = g.localStorage as Storage;
37
+ expect(storage.getItem('missing')).toBeNull();
38
+
39
+ storage.setItem('key', 'value');
40
+ expect(storage.getItem('key')).toBe('value');
41
+ expect(storage.length).toBe(1);
42
+
43
+ storage.removeItem('key');
44
+ expect(storage.getItem('key')).toBeNull();
45
+ expect(storage.length).toBe(0);
46
+
47
+ storage.setItem('a', '1');
48
+ storage.setItem('b', '2');
49
+ storage.clear();
50
+ expect(storage.length).toBe(0);
51
+
52
+ g.localStorage = original;
53
+ });
54
+
55
+ it('creates matchMedia returning false matches', () => {
56
+ const g = globalThis as Record<string, unknown>;
57
+ const original = g.matchMedia;
58
+ delete g.matchMedia;
59
+ applyBrowserStubs();
60
+
61
+ const mql = (g.matchMedia as (q: string) => MediaQueryList)(
62
+ '(prefers-color-scheme: dark)',
63
+ );
64
+ expect(mql.matches).toBe(false);
65
+ expect(mql.media).toBe('(prefers-color-scheme: dark)');
66
+ // Should not throw
67
+ mql.addEventListener('change', () => {});
68
+ mql.removeEventListener('change', () => {});
69
+
70
+ g.matchMedia = original;
71
+ });
72
+
73
+ it('creates IntersectionObserver that does not throw', () => {
74
+ const g = globalThis as Record<string, unknown>;
75
+ const original = g.IntersectionObserver;
76
+ delete g.IntersectionObserver;
77
+ applyBrowserStubs();
78
+
79
+ const IO = g.IntersectionObserver as new (cb: () => void) => IntersectionObserver;
80
+ const observer = new IO(() => {});
81
+
82
+ // All methods should be callable without errors
83
+ observer.observe(null as unknown as Element);
84
+ observer.unobserve(null as unknown as Element);
85
+ observer.disconnect();
86
+ expect(observer.takeRecords()).toEqual([]);
87
+
88
+ g.IntersectionObserver = original;
89
+ });
90
+
91
+ it('creates ResizeObserver that does not throw', () => {
92
+ const g = globalThis as Record<string, unknown>;
93
+ const original = g.ResizeObserver;
94
+ delete g.ResizeObserver;
95
+ applyBrowserStubs();
96
+
97
+ const RO = g.ResizeObserver as new (cb: () => void) => ResizeObserver;
98
+ const observer = new RO(() => {});
99
+
100
+ observer.observe(null as unknown as Element);
101
+ observer.unobserve(null as unknown as Element);
102
+ observer.disconnect();
103
+
104
+ g.ResizeObserver = original;
105
+ });
106
+
107
+ it('creates history with pushState/replaceState', () => {
108
+ const g = globalThis as Record<string, unknown>;
109
+ const original = g.history;
110
+ delete g.history;
111
+ applyBrowserStubs();
112
+
113
+ const h = g.history as History;
114
+ // Should not throw
115
+ h.pushState({}, '', '/test');
116
+ h.replaceState({}, '', '/test');
117
+ h.back();
118
+ h.forward();
119
+ expect(h.length).toBe(1);
120
+
121
+ g.history = original;
122
+ });
123
+
124
+ it('creates location with localhost defaults', () => {
125
+ const g = globalThis as Record<string, unknown>;
126
+ const original = g.location;
127
+ delete g.location;
128
+ applyBrowserStubs();
129
+
130
+ const loc = g.location as Location;
131
+ expect(loc.hostname).toBe('localhost');
132
+ expect(loc.pathname).toBe('/');
133
+ expect(loc.protocol).toBe('http:');
134
+
135
+ g.location = original;
136
+ });
137
+
138
+ it('creates requestAnimationFrame that calls back', async () => {
139
+ const g = globalThis as Record<string, unknown>;
140
+ const original = g.requestAnimationFrame;
141
+ delete g.requestAnimationFrame;
142
+ applyBrowserStubs();
143
+
144
+ const raf = g.requestAnimationFrame as (cb: FrameRequestCallback) => number;
145
+ const called = await new Promise<boolean>((resolve) => {
146
+ raf(() => resolve(true));
147
+ setTimeout(() => resolve(false), 100);
148
+ });
149
+ expect(called).toBe(true);
150
+
151
+ g.requestAnimationFrame = original;
152
+ });
153
+
154
+ it('does not overwrite existing globals', () => {
155
+ const g = globalThis as Record<string, unknown>;
156
+ const sentinel = { custom: true };
157
+ g.scrollTo = sentinel as unknown as typeof scrollTo;
158
+
159
+ applyBrowserStubs();
160
+
161
+ // Should not have replaced our custom value
162
+ expect(g.scrollTo).toBe(sentinel);
163
+ });
164
+
165
+ it('document stub provides querySelector returning null', () => {
166
+ const g = globalThis as Record<string, unknown>;
167
+ const original = g.document;
168
+ delete g.document;
169
+ applyBrowserStubs();
170
+
171
+ const doc = g.document as Document;
172
+ expect(doc.querySelector('.anything')).toBeNull();
173
+ expect(doc.querySelectorAll('.anything')).toHaveLength(0);
174
+ expect(doc.getElementById('anything')).toBeNull();
175
+
176
+ g.document = original;
177
+ });
178
+ });