@octanejs/mcp-server 0.2.3 → 0.2.5
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/README.md +53 -12
- package/package.json +3 -2
- package/skills/bridge-react-package.md +35 -17
- package/skills/build-octane-software.md +69 -0
- package/skills/migrate-react-component.md +24 -6
- package/skills/react-divergences.md +66 -10
- package/skills/setup-ssr.md +44 -17
- package/src/bridge.js +208 -7
- package/src/bridge.test.js +116 -0
- package/src/index.js +181 -21
- package/src/index.test.js +122 -0
package/src/bridge.js
CHANGED
|
@@ -8,29 +8,89 @@ export const KNOWN_BINDINGS = {
|
|
|
8
8
|
zustand: '@octanejs/zustand',
|
|
9
9
|
jotai: '@octanejs/jotai',
|
|
10
10
|
'@apollo/client': '@octanejs/apollo-client',
|
|
11
|
+
'@tanstack/ai-react': '@octanejs/tanstack-ai',
|
|
12
|
+
'@tanstack/react-devtools': '@octanejs/tanstack-devtools',
|
|
13
|
+
'@tanstack/react-form': '@octanejs/tanstack-form',
|
|
11
14
|
'@tanstack/react-query': '@octanejs/tanstack-query',
|
|
12
15
|
'@tanstack/react-router': '@octanejs/tanstack-router',
|
|
16
|
+
'@tanstack/react-store': '@octanejs/tanstack-store',
|
|
13
17
|
'@tanstack/react-table': '@octanejs/tanstack-table',
|
|
14
18
|
'@tanstack/react-virtual': '@octanejs/tanstack-virtual',
|
|
15
19
|
'framer-motion': '@octanejs/motion',
|
|
16
20
|
motion: '@octanejs/motion',
|
|
17
21
|
'@stylexjs/stylex': '@octanejs/stylex',
|
|
22
|
+
'styled-components': '@octanejs/styled-components',
|
|
18
23
|
'react-router': '@octanejs/remix-router',
|
|
19
24
|
'react-router-dom': '@octanejs/remix-router',
|
|
20
25
|
'@lexical/react': '@octanejs/lexical',
|
|
26
|
+
'@tiptap/react': '@octanejs/tiptap',
|
|
21
27
|
'lucide-react': '@octanejs/lucide',
|
|
22
28
|
'@floating-ui/react': '@octanejs/floating-ui',
|
|
29
|
+
'react-aria': '@octanejs/aria',
|
|
30
|
+
'react-aria-components': '@octanejs/aria',
|
|
31
|
+
'react-stately': '@octanejs/aria',
|
|
23
32
|
'radix-ui': '@octanejs/radix',
|
|
24
33
|
'react-hook-form': '@octanejs/hook-form',
|
|
25
34
|
'@base-ui-components/react': '@octanejs/base-ui',
|
|
26
35
|
'@dnd-kit/react': '@octanejs/dnd-kit',
|
|
27
36
|
sonner: '@octanejs/sonner',
|
|
28
37
|
recharts: '@octanejs/recharts',
|
|
38
|
+
'@react-three/fiber': '@octanejs/three',
|
|
39
|
+
'@visx/visx': '@octanejs/visx',
|
|
40
|
+
'@visx/a11y': '@octanejs/visx',
|
|
41
|
+
'@visx/a11y/react': '@octanejs/visx',
|
|
42
|
+
'@visx/a11y/server': '@octanejs/visx',
|
|
43
|
+
'@visx/annotation': '@octanejs/visx',
|
|
44
|
+
'@visx/axis': '@octanejs/visx',
|
|
45
|
+
'@visx/axis/react': '@octanejs/visx',
|
|
46
|
+
'@visx/bounds': '@octanejs/visx',
|
|
47
|
+
'@visx/brush': '@octanejs/visx',
|
|
48
|
+
'@visx/chart': '@octanejs/visx',
|
|
49
|
+
'@visx/chord': '@octanejs/visx',
|
|
50
|
+
'@visx/clip-path': '@octanejs/visx',
|
|
51
|
+
'@visx/curve': '@octanejs/visx',
|
|
52
|
+
'@visx/delaunay': '@octanejs/visx',
|
|
53
|
+
'@visx/drag': '@octanejs/visx',
|
|
54
|
+
'@visx/event': '@octanejs/visx',
|
|
55
|
+
'@visx/geo': '@octanejs/visx',
|
|
56
|
+
'@visx/glyph': '@octanejs/visx',
|
|
57
|
+
'@visx/gradient': '@octanejs/visx',
|
|
58
|
+
'@visx/grid': '@octanejs/visx',
|
|
59
|
+
'@visx/group': '@octanejs/visx',
|
|
60
|
+
'@visx/heatmap': '@octanejs/visx',
|
|
61
|
+
'@visx/hierarchy': '@octanejs/visx',
|
|
62
|
+
'@visx/kernel': '@octanejs/visx',
|
|
63
|
+
'@visx/legend': '@octanejs/visx',
|
|
64
|
+
'@visx/marker': '@octanejs/visx',
|
|
65
|
+
'@visx/mock-data': '@octanejs/visx',
|
|
66
|
+
'@visx/network': '@octanejs/visx',
|
|
67
|
+
'@visx/pattern': '@octanejs/visx',
|
|
68
|
+
'@visx/point': '@octanejs/visx',
|
|
69
|
+
'@visx/react-spring': '@octanejs/visx',
|
|
70
|
+
'@visx/responsive': '@octanejs/visx',
|
|
71
|
+
'@visx/sankey': '@octanejs/visx',
|
|
72
|
+
'@visx/scale': '@octanejs/visx',
|
|
73
|
+
'@visx/scale/react': '@octanejs/visx',
|
|
74
|
+
'@visx/shape': '@octanejs/visx',
|
|
75
|
+
'@visx/shape/react': '@octanejs/visx',
|
|
76
|
+
'@visx/stats': '@octanejs/visx',
|
|
77
|
+
'@visx/text': '@octanejs/visx',
|
|
78
|
+
'@visx/theme': '@octanejs/visx',
|
|
79
|
+
'@visx/theme/react': '@octanejs/visx',
|
|
80
|
+
'@visx/threshold': '@octanejs/visx',
|
|
81
|
+
'@visx/tooltip': '@octanejs/visx',
|
|
82
|
+
'@visx/tooltip/floating': '@octanejs/visx',
|
|
83
|
+
'@visx/voronoi': '@octanejs/visx',
|
|
84
|
+
'@visx/voronoi/react': '@octanejs/visx',
|
|
85
|
+
'@visx/wordcloud': '@octanejs/visx',
|
|
86
|
+
'@visx/xychart': '@octanejs/visx',
|
|
87
|
+
'@visx/zoom': '@octanejs/visx',
|
|
29
88
|
'react-redux': '@octanejs/redux',
|
|
30
89
|
'@reduxjs/toolkit': '@octanejs/redux-toolkit',
|
|
31
90
|
'@testing-library/react': '@octanejs/testing-library',
|
|
32
91
|
'react-i18next': '@octanejs/i18next',
|
|
33
92
|
'@mdx-js/react': '@octanejs/mdx',
|
|
93
|
+
'dexie-react-hooks': '@octanejs/dexie',
|
|
34
94
|
};
|
|
35
95
|
|
|
36
96
|
// Workspace directory names for the maintained bindings. Keep this derived
|
|
@@ -57,6 +117,7 @@ export const KNOWN_VANILLA_CORES = {
|
|
|
57
117
|
valtio: 'valtio/vanilla',
|
|
58
118
|
jotai: 'jotai/vanilla',
|
|
59
119
|
'@lexical/react': 'lexical',
|
|
120
|
+
'@tiptap/react': '@tiptap/core',
|
|
60
121
|
};
|
|
61
122
|
|
|
62
123
|
export const REACT_API_MAP = {
|
|
@@ -117,8 +178,14 @@ export const REACT_API_MAP = {
|
|
|
117
178
|
status: 'rewrite',
|
|
118
179
|
note: 'No forwardRef. Rewrite to React 19 refs-as-props: accept ref as a normal prop.',
|
|
119
180
|
},
|
|
120
|
-
useDebugValue: {
|
|
121
|
-
|
|
181
|
+
useDebugValue: {
|
|
182
|
+
status: 'same',
|
|
183
|
+
note: 'Supported as an accepted no-op (devtools-only label; there is no DevTools integration).',
|
|
184
|
+
},
|
|
185
|
+
lazy: {
|
|
186
|
+
status: 'same',
|
|
187
|
+
note: "Supported. Accepts React's { default } module shape and additionally a bare component from the loader; wrapping Suspense or ViewTransition in lazy() is valid (nested lazy wrappers are not).",
|
|
188
|
+
},
|
|
122
189
|
Component: {
|
|
123
190
|
status: 'unsupported',
|
|
124
191
|
note: 'No class components. Rewrite as a function component.',
|
|
@@ -138,17 +205,21 @@ export const REACT_API_MAP = {
|
|
|
138
205
|
status: 'rewrite',
|
|
139
206
|
note: 'Use renderToString() from octane/server (sync) or prerender() from octane/static (async, awaits Suspense); both return { html, css }.',
|
|
140
207
|
},
|
|
208
|
+
renderToStaticMarkup: {
|
|
209
|
+
status: 'rewrite',
|
|
210
|
+
note: 'Use renderToStaticMarkup() from octane/server (clean, non-hydratable HTML; returns { html, css }).',
|
|
211
|
+
},
|
|
141
212
|
renderToPipeableStream: {
|
|
142
|
-
status: '
|
|
143
|
-
note: '
|
|
213
|
+
status: 'rewrite',
|
|
214
|
+
note: 'Supported natively: import renderToPipeableStream from octane/server (Octane argument convention: component, props?, options?; returns { pipe, abort } with onShellReady/onShellError/onAllReady StreamOptions).',
|
|
144
215
|
},
|
|
145
216
|
renderToReadableStream: {
|
|
146
|
-
status: '
|
|
147
|
-
note: '
|
|
217
|
+
status: 'rewrite',
|
|
218
|
+
note: 'Supported natively: import renderToReadableStream from octane/server (Octane argument convention; resolves with a ReadableStream once the shell is ready, same StreamOptions).',
|
|
148
219
|
},
|
|
149
220
|
onChange: {
|
|
150
221
|
status: 'rewrite',
|
|
151
|
-
note: '
|
|
222
|
+
note: 'This is a standard text host using React-style per-edit onChange. Use onInput (or onInputCapture) for per-edit intent. Preserve component callbacks, select/checkable change handlers, and deliberate native text commits; mark the last case with suppressNativeChangeWarning.',
|
|
152
223
|
},
|
|
153
224
|
defaultProps: {
|
|
154
225
|
status: 'rewrite',
|
|
@@ -169,6 +240,111 @@ const SCANNABLE = /\.(js|mjs|cjs|jsx|ts|tsx|mts|cts)$/;
|
|
|
169
240
|
const SKIP_DIRS = new Set(['node_modules', '.git', '__tests__', '__mocks__', 'test', 'tests']);
|
|
170
241
|
const MAX_FILES = 400;
|
|
171
242
|
|
|
243
|
+
const NON_TEXT_INPUT_TYPES = new Set([
|
|
244
|
+
'button',
|
|
245
|
+
'checkbox',
|
|
246
|
+
'color',
|
|
247
|
+
'date',
|
|
248
|
+
'datetime-local',
|
|
249
|
+
'file',
|
|
250
|
+
'hidden',
|
|
251
|
+
'image',
|
|
252
|
+
'month',
|
|
253
|
+
'radio',
|
|
254
|
+
'range',
|
|
255
|
+
'reset',
|
|
256
|
+
'submit',
|
|
257
|
+
'time',
|
|
258
|
+
'week',
|
|
259
|
+
]);
|
|
260
|
+
|
|
261
|
+
function booleanAttributeState(attributes, name, exactTrue = false) {
|
|
262
|
+
const match = attributes.match(
|
|
263
|
+
new RegExp(
|
|
264
|
+
`\\b${name}(?=\\s|=|\\/|$)(?:\\s*=\\s*(?:"([^"]*)"|'([^']*)'|\\{\\s*([^}]*)\\s*\\}))?`,
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
if (!match) return 'false';
|
|
268
|
+
if (!match[0].includes('=')) return 'true';
|
|
269
|
+
const quoted = match[1] ?? match[2];
|
|
270
|
+
if (exactTrue && quoted !== undefined) return 'false';
|
|
271
|
+
if (quoted !== undefined) return quoted.length > 0 ? 'true' : 'false';
|
|
272
|
+
const expression = match[3]?.trim();
|
|
273
|
+
if (exactTrue) {
|
|
274
|
+
if (expression === 'true') return 'true';
|
|
275
|
+
if (
|
|
276
|
+
/^(?:false|null|undefined|void\s+0|[+-]?(?:\d+(?:\.\d+)?|\.\d+)|NaN|'[^']*'|"[^"]*")$/.test(
|
|
277
|
+
expression ?? '',
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
return 'false';
|
|
281
|
+
return 'dynamic';
|
|
282
|
+
}
|
|
283
|
+
if (/^(?:false|null|undefined|void\s+0|0|-0|NaN|''|"")$/.test(expression ?? '')) {
|
|
284
|
+
return 'false';
|
|
285
|
+
}
|
|
286
|
+
if (/^(?:true|[1-9]\d*(?:\.\d+)?|'[^']+'|"[^"]+")$/.test(expression ?? '')) {
|
|
287
|
+
return 'true';
|
|
288
|
+
}
|
|
289
|
+
return 'dynamic';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function hasPotentialEventAssignment(attributes, name) {
|
|
293
|
+
const match = attributes.match(new RegExp(`\\b${name}\\s*=\\s*([^\\s>]+)`));
|
|
294
|
+
if (!match) return false;
|
|
295
|
+
const value = match[1];
|
|
296
|
+
if (value.startsWith('"') || value.startsWith("'")) return false;
|
|
297
|
+
if (
|
|
298
|
+
/^\{\s*(?:false|true|null|undefined|void\s+0|0|-0|NaN|''|"")\s*\}/.test(
|
|
299
|
+
attributes.slice(match.index + match[0].indexOf(value)),
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
return false;
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function countReactStyleTextChanges(source) {
|
|
307
|
+
let count = 0;
|
|
308
|
+
const hosts = source.matchAll(/<(input|textarea)\b((?:[^>]|=>)*)>/g);
|
|
309
|
+
for (const match of hosts) {
|
|
310
|
+
const host = match[1].toLowerCase();
|
|
311
|
+
const attrs = match[2];
|
|
312
|
+
const changeHandlers = ['onChange', 'onChangeCapture'].filter((name) =>
|
|
313
|
+
hasPotentialEventAssignment(attrs, name),
|
|
314
|
+
);
|
|
315
|
+
if (
|
|
316
|
+
changeHandlers.length === 0 ||
|
|
317
|
+
hasPotentialEventAssignment(attrs, 'onInput') ||
|
|
318
|
+
hasPotentialEventAssignment(attrs, 'onInputCapture')
|
|
319
|
+
)
|
|
320
|
+
continue;
|
|
321
|
+
// A spread can replace the handler, type, editability, or suppression.
|
|
322
|
+
// Leave that site for the compiler/runtime's final-props diagnostic rather
|
|
323
|
+
// than suggesting a source rewrite from an incomplete static scan.
|
|
324
|
+
if (/\{\s*\.\.\./.test(attrs)) continue;
|
|
325
|
+
const suppression = booleanAttributeState(attrs, 'suppressNativeChangeWarning', true);
|
|
326
|
+
const readOnly = booleanAttributeState(attrs, 'readOnly');
|
|
327
|
+
const disabled = booleanAttributeState(attrs, 'disabled');
|
|
328
|
+
if ([suppression, readOnly, disabled].includes('true')) continue;
|
|
329
|
+
if ([suppression, readOnly, disabled].includes('dynamic')) continue;
|
|
330
|
+
if (host === 'input') {
|
|
331
|
+
const literalType = attrs.match(
|
|
332
|
+
/\btype\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*["']([^"']*)["']\s*\})/i,
|
|
333
|
+
);
|
|
334
|
+
if (!literalType && /\btype\s*=/.test(attrs)) continue;
|
|
335
|
+
const type = (
|
|
336
|
+
literalType?.[1] ??
|
|
337
|
+
literalType?.[2] ??
|
|
338
|
+
literalType?.[3] ??
|
|
339
|
+
'text'
|
|
340
|
+
).toLowerCase();
|
|
341
|
+
if (NON_TEXT_INPUT_TYPES.has(type)) continue;
|
|
342
|
+
}
|
|
343
|
+
count += changeHandlers.length;
|
|
344
|
+
}
|
|
345
|
+
return count;
|
|
346
|
+
}
|
|
347
|
+
|
|
172
348
|
export async function collectSourceFiles(root, out = [], depth = 0) {
|
|
173
349
|
if (depth > 6 || out.length >= MAX_FILES) return out;
|
|
174
350
|
let entries;
|
|
@@ -193,9 +369,12 @@ export async function collectSourceFiles(root, out = [], depth = 0) {
|
|
|
193
369
|
export function scanSource(source) {
|
|
194
370
|
const apis = new Map();
|
|
195
371
|
for (const name of Object.keys(REACT_API_MAP)) {
|
|
372
|
+
if (name === 'onChange') continue;
|
|
196
373
|
const matches = source.match(new RegExp(`\\b${name}\\b`, 'g'));
|
|
197
374
|
if (matches) apis.set(name, matches.length);
|
|
198
375
|
}
|
|
376
|
+
const textChanges = countReactStyleTextChanges(source);
|
|
377
|
+
if (textChanges > 0) apis.set('onChange', textChanges);
|
|
199
378
|
const imports = new Set();
|
|
200
379
|
for (const spec of IMPORT_SOURCES) {
|
|
201
380
|
if (
|
|
@@ -306,6 +485,28 @@ export async function bridgeReport({ packageName, path, projectRoot }) {
|
|
|
306
485
|
return report;
|
|
307
486
|
}
|
|
308
487
|
|
|
488
|
+
// Filesystem-free variant of bridgeReport for hosted/remote use: the caller
|
|
489
|
+
// pastes source text instead of pointing at an installed package, so there is
|
|
490
|
+
// no node_modules resolution, no version, and no file counting. Everything
|
|
491
|
+
// else (API rows, verdict, plan) matches bridgeReport.
|
|
492
|
+
export function bridgeReportFromSource(source, { packageName } = {}) {
|
|
493
|
+
const report = {
|
|
494
|
+
target: packageName ?? 'pasted-source',
|
|
495
|
+
existingBinding: packageName ? (KNOWN_BINDINGS[packageName] ?? null) : null,
|
|
496
|
+
};
|
|
497
|
+
if (packageName) {
|
|
498
|
+
report.vanillaCore = detectVanillaCore(packageName, null);
|
|
499
|
+
}
|
|
500
|
+
const scan = scanSource(source);
|
|
501
|
+
const rows = apiRows(scan.apis);
|
|
502
|
+
report.reactImports = [...scan.imports];
|
|
503
|
+
report.classComponents = scan.classComponent;
|
|
504
|
+
report.apis = rows;
|
|
505
|
+
report.verdict = verdictFor(rows, scan.classComponent);
|
|
506
|
+
report.plan = planFor(report);
|
|
507
|
+
return report;
|
|
508
|
+
}
|
|
509
|
+
|
|
309
510
|
function planFor(report) {
|
|
310
511
|
const steps = [];
|
|
311
512
|
if (report.existingBinding) {
|
package/src/bridge.test.js
CHANGED
|
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import {
|
|
7
7
|
bridgeReport,
|
|
8
|
+
bridgeReportFromSource,
|
|
8
9
|
detectVanillaCore,
|
|
9
10
|
scanSource,
|
|
10
11
|
KNOWN_BINDINGS,
|
|
@@ -36,6 +37,36 @@ describe('scanSource', () => {
|
|
|
36
37
|
expect(scanSource('class Boundary extends React.Component {}').classComponent).toBe(true);
|
|
37
38
|
expect(scanSource('class Memoish extends PureComponent {}').classComponent).toBe(true);
|
|
38
39
|
});
|
|
40
|
+
|
|
41
|
+
it('targets only React-style text-host onChange wiring', () => {
|
|
42
|
+
const source = `
|
|
43
|
+
function Demo(props) {
|
|
44
|
+
return <>
|
|
45
|
+
<input onChange={(event) => props.edit(event.currentTarget.value)} />
|
|
46
|
+
<textarea onChangeCapture={props.capture} />
|
|
47
|
+
<input type="checkbox" onChange={props.toggle} />
|
|
48
|
+
<input type={props.type} onChange={props.dynamic} />
|
|
49
|
+
<input {...props.field} onChange={props.dynamic} />
|
|
50
|
+
<input onInput={props.input} onChange={props.commit} />
|
|
51
|
+
<input onInput={null} onChange={props.edit} />
|
|
52
|
+
<input onChange={null} />
|
|
53
|
+
<input onChange={props.commit} suppressNativeChangeWarning />
|
|
54
|
+
<input onChange={props.commit} suppressNativeChangeWarning={true} />
|
|
55
|
+
<input onChange={props.edit} suppressNativeChangeWarning={false} />
|
|
56
|
+
<input onChange={props.edit} suppressNativeChangeWarning="false" />
|
|
57
|
+
<input onChange={props.edit} suppressNativeChangeWarning="true" />
|
|
58
|
+
<textarea onChange={props.commit} readOnly />
|
|
59
|
+
<textarea onChange={props.commit} disabled="disabled" />
|
|
60
|
+
<select onChange={props.select}><option>one</option></select>
|
|
61
|
+
<Input onChange={props.value} />
|
|
62
|
+
<Field onChange={props.value} />
|
|
63
|
+
</>;
|
|
64
|
+
}
|
|
65
|
+
`;
|
|
66
|
+
expect(scanSource(source).apis.get('onChange')).toBe(6);
|
|
67
|
+
const report = bridgeReportFromSource(source);
|
|
68
|
+
expect(report.plan.join('\n')).toContain('standard text host');
|
|
69
|
+
});
|
|
39
70
|
});
|
|
40
71
|
|
|
41
72
|
describe('detectVanillaCore', () => {
|
|
@@ -104,6 +135,34 @@ describe('bridgeReport', () => {
|
|
|
104
135
|
expect(report.plan.join('\n')).toContain('forwardRef');
|
|
105
136
|
});
|
|
106
137
|
|
|
138
|
+
it('lazy plus Suspense stays bridgeable', async () => {
|
|
139
|
+
const root = await mkdtemp(join(tmpdir(), 'octane-bridge-'));
|
|
140
|
+
await writeFakePackage(root, 'lazy-lib', {
|
|
141
|
+
'index.js': `
|
|
142
|
+
import { lazy, Suspense } from 'react';
|
|
143
|
+
export const Panel = lazy(() => import('./panel.js'));
|
|
144
|
+
export { Suspense };
|
|
145
|
+
`,
|
|
146
|
+
});
|
|
147
|
+
const report = await bridgeReport({ packageName: 'lazy-lib', projectRoot: root });
|
|
148
|
+
expect(report.apis.find((row) => row.name === 'lazy').status).toBe('same');
|
|
149
|
+
expect(report.verdict).toBe('bridgeable');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('routes streaming SSR entry points to octane/server as a rewrite', async () => {
|
|
153
|
+
const root = await mkdtemp(join(tmpdir(), 'octane-bridge-'));
|
|
154
|
+
await writeFakePackage(root, 'streamer', {
|
|
155
|
+
'index.js': `
|
|
156
|
+
import { renderToPipeableStream } from 'react-dom/server';
|
|
157
|
+
export const ssr = (el) => renderToPipeableStream(el);
|
|
158
|
+
`,
|
|
159
|
+
});
|
|
160
|
+
const report = await bridgeReport({ packageName: 'streamer', projectRoot: root });
|
|
161
|
+
expect(report.apis.find((row) => row.name === 'renderToPipeableStream').status).toBe('rewrite');
|
|
162
|
+
expect(report.verdict).toBe('bridgeable-with-rewrites');
|
|
163
|
+
expect(report.plan.join('\n')).toContain('octane/server');
|
|
164
|
+
});
|
|
165
|
+
|
|
107
166
|
it('reports class components as needs-rework', async () => {
|
|
108
167
|
const root = await mkdtemp(join(tmpdir(), 'octane-bridge-'));
|
|
109
168
|
await writeFakePackage(root, 'classy', {
|
|
@@ -144,7 +203,64 @@ describe('bridgeReport', () => {
|
|
|
144
203
|
});
|
|
145
204
|
});
|
|
146
205
|
|
|
206
|
+
describe('bridgeReportFromSource', () => {
|
|
207
|
+
it('produces the same verdict and plan as bridgeReport without touching the filesystem', () => {
|
|
208
|
+
const report = bridgeReportFromSource(`
|
|
209
|
+
import { forwardRef, useState } from 'react';
|
|
210
|
+
export const Thing = forwardRef((props, ref) => {
|
|
211
|
+
const [n] = useState(0);
|
|
212
|
+
return n;
|
|
213
|
+
});
|
|
214
|
+
`);
|
|
215
|
+
expect(report.target).toBe('pasted-source');
|
|
216
|
+
expect(report.reactImports).toContain('react');
|
|
217
|
+
expect(report.verdict).toBe('bridgeable-with-rewrites');
|
|
218
|
+
expect(report.apis.find((row) => row.name === 'forwardRef').status).toBe('rewrite');
|
|
219
|
+
expect(report.plan.join('\n')).toContain('forwardRef');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('reports class components as needs-rework', () => {
|
|
223
|
+
const report = bridgeReportFromSource(`
|
|
224
|
+
import React from 'react';
|
|
225
|
+
export class Panel extends React.Component { render() { return null; } }
|
|
226
|
+
`);
|
|
227
|
+
expect(report.classComponents).toBe(true);
|
|
228
|
+
expect(report.verdict).toBe('needs-rework');
|
|
229
|
+
expect(report.plan.join('\n')).toContain('function component');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('surfaces the official binding and vanilla core when a package name is given', () => {
|
|
233
|
+
const report = bridgeReportFromSource(`export {};`, {
|
|
234
|
+
packageName: '@tanstack/react-query',
|
|
235
|
+
});
|
|
236
|
+
expect(report.target).toBe('@tanstack/react-query');
|
|
237
|
+
expect(report.existingBinding).toBe('@octanejs/tanstack-query');
|
|
238
|
+
expect(report.vanillaCore).toBe('@tanstack/query-core');
|
|
239
|
+
expect(report.plan[0]).toContain('@octanejs/tanstack-query');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('same-name hook usage stays bridgeable', () => {
|
|
243
|
+
const report = bridgeReportFromSource(`
|
|
244
|
+
import { useSyncExternalStore } from 'react';
|
|
245
|
+
export function useStore(api, selector) {
|
|
246
|
+
return useSyncExternalStore(api.subscribe, () => selector(api.getState()));
|
|
247
|
+
}
|
|
248
|
+
`);
|
|
249
|
+
expect(report.verdict).toBe('bridgeable');
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
147
253
|
describe('KNOWN_BINDINGS', () => {
|
|
254
|
+
it('maps every public Visx entry point to the aggregate Octane port', async () => {
|
|
255
|
+
const packagesRoot = fileURLToPath(new URL('../..', import.meta.url));
|
|
256
|
+
const manifest = JSON.parse(await readFile(join(packagesRoot, 'visx', 'package.json'), 'utf8'));
|
|
257
|
+
const upstreamPackages = Object.keys(manifest.exports).map((entry) =>
|
|
258
|
+
entry === '.' ? '@visx/visx' : `@visx/${entry.slice(2)}`,
|
|
259
|
+
);
|
|
260
|
+
expect(upstreamPackages).toHaveLength(49);
|
|
261
|
+
expect(upstreamPackages.every((name) => KNOWN_BINDINGS[name] === '@octanejs/visx')).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
|
|
148
264
|
it('covers every published @octanejs binding (derived from workspace manifests)', async () => {
|
|
149
265
|
// The expected set is DERIVED from packages/*/package.json rather than
|
|
150
266
|
// hand-maintained, so publishing a new binding without registering it in
|