From 2602c1ad12efa390abb73f540a8e6480fe1e0f63 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Fri, 2 Jan 2026 00:28:43 +0100 Subject: [PATCH 1/4] chore(structure): Benchmarking script To get a baseline for current ts-morph implementation --- packages/structure/benchmark.mts | 85 ++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 packages/structure/benchmark.mts diff --git a/packages/structure/benchmark.mts b/packages/structure/benchmark.mts new file mode 100644 index 0000000000..0874ed8117 --- /dev/null +++ b/packages/structure/benchmark.mts @@ -0,0 +1,85 @@ +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +import * as RWProjectJS from './src/model/RWProject.js' +import type { RWProject as RWProjectJST } from './src/model/RWProject.js' + +// @ts-expect-error tsx makes me do this +const RWProject: typeof RWProjectJST = RWProjectJS.default.RWProject + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const fixturePath = path.resolve(__dirname, '../../__fixtures__/test-project') + +const ITERATIONS = 10 + +async function benchmark() { + console.log('CedarJS Structure Performance Benchmark') + console.log('=======================================\n') + console.log(`Implementation: ts-morph`) + console.log(`Target Project: ${fixturePath}`) + console.log(`Iterations: ${ITERATIONS}\n`) + + const stats = { + initTime: 0, + coldDiagnosticsTime: 0, + warmDiagnosticsTime: 0, + heapUsed: 0, + diagnosticCount: 0, + } + + for (let i = 0; i < ITERATIONS; i++) { + // 1. Project Initialization (Shallow) + const t0 = performance.now() + const project = new RWProject({ projectRoot: fixturePath }) + const t1 = performance.now() + stats.initTime += t1 - t0 + + // 2. Full Project Build & Diagnostics (Cold) + // This triggers the heavy lifting of ts-morph parsing all files + const t2 = performance.now() + const diagnosticsCold = await project.collectDiagnostics() + const t3 = performance.now() + stats.coldDiagnosticsTime += t3 - t2 + stats.diagnosticCount = diagnosticsCold.length // Should be same across iterations + + // 3. Cached Diagnostics (Warm) + const t4 = performance.now() + await project.collectDiagnostics() + const t5 = performance.now() + stats.warmDiagnosticsTime += t5 - t4 + + // 4. Memory Usage + // Note: This measures memory at the end of each iteration. + // It might increase due to ts-morph's internal state. + stats.heapUsed += process.memoryUsage().heapUsed / 1024 / 1024 + } + + const averages: Record = { + 'Avg Init (ms)': (stats.initTime / ITERATIONS).toFixed(2), + 'Avg Cold Diagnostics (ms)': ( + stats.coldDiagnosticsTime / ITERATIONS + ).toFixed(2), + 'Avg Warm Diagnostics (ms)': ( + stats.warmDiagnosticsTime / ITERATIONS + ).toFixed(2), + 'Avg Heap Used (MB)': (stats.heapUsed / ITERATIONS).toFixed(2), + 'Diagnostic Count': stats.diagnosticCount.toString(), + } + + // Print Results + console.table(averages) + + console.log('\nBreakdown:') + console.log('- Avg Init: Time to create the RWProject instance.') + console.log( + '- Avg Cold: Time to build the entire graph and run all diagnostics (first run per iteration).', + ) + console.log('- Avg Warm: Time to run diagnostics when using internal cache.') + console.log('- Avg Heap: Total heap memory used after building the project.') +} + +benchmark().catch((err) => { + console.error('Benchmark failed:', err) + process.exit(1) +}) From 8db6d1ea4c44623887526e912709e0d815758688 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Fri, 2 Jan 2026 00:54:49 +0100 Subject: [PATCH 2/4] update next_steps --- packages/structure/NEXT_STEPS.md | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/structure/NEXT_STEPS.md b/packages/structure/NEXT_STEPS.md index 0119e31a0b..a6469f8c41 100644 --- a/packages/structure/NEXT_STEPS.md +++ b/packages/structure/NEXT_STEPS.md @@ -47,3 +47,48 @@ Why this is better than inheritance: This approach is essentially how most modern compilers and high-performance tools (like ESLint or rust-analyzer) are built today. It trades "magical getters" for a transparent, predictable data flow. + +✦ To take full advantage of a move to a high-performance parser like oxc, you should eventually update the interface, though you could keep the current one +as a temporary compatibility layer. + +Here is the breakdown of why keeping the current interface would actually limit the benefits of the rewrite: + +1. The "Compatibility Layer" Tax + The current interface is built on ts-morph, which provides a very high-level, human-friendly API (e.g., sourceFile.getVariableDeclaration('QUERY')). + +- The Problem: oxc (and most high-performance parsers) returns a raw, flat AST. +- The Consequence: If you keep the current interface, you have to write a massive "compatibility layer" that mimics ts-morph's API using the raw AST. This + layer would likely be written in JavaScript, adding back some of the overhead you were trying to remove. + +2. From "Pull" to "Push" (Performance) + +- Current (Pull): The inheritance/lazy-getter model is a "Pull" system. You ask for project.cells, and it triggers a chain of getters that eventually call + ts-morph. +- Optimal (Push): High-performance tools work better as "Push" systems. You run a fast Rust-based scanner over the whole project in one pass, which + "pushes" a plain data object representing the whole structure. +- If you keep the current interface, you are forcing a high-speed stream of data into a slow, stateful object container. + +3. Serializability and Concurrency + One of the biggest hidden benefits of oxc is that because it’s so fast and the data it produces is simple, you can easily move the work to Worker Threads. + +- The Limitation: You cannot send class instances (like RWProject) across worker boundaries easily. +- The Win: If you update the interface to use Plain Old JavaScript Objects (POJOs), the Structure package could parse 50 files in parallel on 50 threads + and return a single JSON-like object. Other packages would then "query" this data rather than interacting with live class instances. + +4. What a "Modern" Interface would look like + Instead of other packages doing this: + +1 const project = getProject(path) +2 const routes = project.router.routes // Deep getter chain + +They would do this: +1 // The structure package returns a frozen snapshot of the project +2 const projectData = await scanProject(path) +3 const routes = findRoutes(projectData) // Fast lookup in a data structure + +Recommendation +If you want to move to oxc, I would recommend a Two-Phase Approach: + +1. Phase 1 (Internal): Rewrite the internals to use oxc but keep the RWProject classes as "wrappers" so you don't break the CLI or Telemetry immediately. +2. Phase 2 (Public API): Introduce a new functional API (scanProject) and slowly migrate the CLI to use it. Once the CLI is migrated, you can delete the + inheritance-based classes entirely. From b476564f3761e49db83c75c9d1e80f08e8e1b83c Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Fri, 2 Jan 2026 16:33:00 +0100 Subject: [PATCH 3/4] Update benchmark script --- packages/structure/NEXT_STEPS.md | 3 + packages/structure/benchmark.mts | 192 +++++++++++++++++++++++++------ 2 files changed, 161 insertions(+), 34 deletions(-) diff --git a/packages/structure/NEXT_STEPS.md b/packages/structure/NEXT_STEPS.md index a6469f8c41..af76dfae6a 100644 --- a/packages/structure/NEXT_STEPS.md +++ b/packages/structure/NEXT_STEPS.md @@ -1,3 +1,6 @@ +> If you look at the Structure package code, is there anything you would recommend doing to improve it? It could be anything. Something to make it more +> performant, something to make it easier to work with, something to make it more modern. What's the one thing you'd recommend that'd have to biggest impact? + ✦ If I had to recommend one change with the biggest impact on the Structure package, it would be replacing `ts-morph` with a high-performance AST parser like [`oxc-parser`](https://github.com/oxc-project/oxc) or [`swc`](https://swc.rs/). diff --git a/packages/structure/benchmark.mts b/packages/structure/benchmark.mts index 0874ed8117..76b52e066d 100644 --- a/packages/structure/benchmark.mts +++ b/packages/structure/benchmark.mts @@ -11,72 +11,196 @@ const RWProject: typeof RWProjectJST = RWProjectJS.default.RWProject const __dirname = path.dirname(fileURLToPath(import.meta.url)) const fixturePath = path.resolve(__dirname, '../../__fixtures__/test-project') +const WARMUP_ITERATIONS = 2 const ITERATIONS = 10 +// Enable manual GC if running with --expose-gc +const gc = global.gc + +async function forceGC() { + if (gc) { + // Run GC multiple times to ensure everything is cleaned up + gc() + gc() + // Small delay to let GC settle + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +function getMemoryMB() { + return process.memoryUsage().heapUsed / 1024 / 1024 +} + +function calculateStats(values: number[]) { + const sorted = [...values].sort((a, b) => a - b) + const mean = values.reduce((a, b) => a + b, 0) / values.length + const variance = + values.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / + values.length + const stdDev = Math.sqrt(variance) + + return { + mean, + median: sorted[Math.floor(sorted.length / 2)], + min: sorted[0], + max: sorted[sorted.length - 1], + stdDev, + p95: sorted[Math.floor(sorted.length * 0.95)], + } +} + async function benchmark() { console.log('CedarJS Structure Performance Benchmark') console.log('=======================================\n') console.log(`Implementation: ts-morph`) console.log(`Target Project: ${fixturePath}`) - console.log(`Iterations: ${ITERATIONS}\n`) - - const stats = { - initTime: 0, - coldDiagnosticsTime: 0, - warmDiagnosticsTime: 0, - heapUsed: 0, - diagnosticCount: 0, + console.log(`Warmup Iterations: ${WARMUP_ITERATIONS}`) + console.log(`Measurement Iterations: ${ITERATIONS}`) + console.log( + `GC Control: ${gc ? 'enabled' : 'disabled (run with --expose-gc for accurate memory stats)'}`, + ) + console.log('') + + // Track individual measurements + const initTimes: number[] = [] + const coldDiagnosticsTimes: number[] = [] + const warmDiagnosticsTimes: number[] = [] + const memoryDeltas: number[] = [] + let diagnosticCount = 0 + + // Warmup phase (don't measure these) + console.log('Warming up...') + for (let i = 0; i < WARMUP_ITERATIONS; i++) { + const project = new RWProject({ projectRoot: fixturePath }) + await project.collectDiagnostics() + await project.collectDiagnostics() // Warm cache } + forceGC() + console.log('Warmup complete.\n') + + // Measurement phase + console.log('Running measurements...') + + // Establish a stable baseline + await forceGC() + const baselineMemory = getMemoryMB() for (let i = 0; i < ITERATIONS; i++) { + // Stabilize memory before measurement + await forceGC() + const memBefore = getMemoryMB() + // 1. Project Initialization (Shallow) const t0 = performance.now() const project = new RWProject({ projectRoot: fixturePath }) const t1 = performance.now() - stats.initTime += t1 - t0 + initTimes.push(t1 - t0) // 2. Full Project Build & Diagnostics (Cold) - // This triggers the heavy lifting of ts-morph parsing all files const t2 = performance.now() const diagnosticsCold = await project.collectDiagnostics() const t3 = performance.now() - stats.coldDiagnosticsTime += t3 - t2 - stats.diagnosticCount = diagnosticsCold.length // Should be same across iterations + coldDiagnosticsTimes.push(t3 - t2) + diagnosticCount = diagnosticsCold.length // 3. Cached Diagnostics (Warm) const t4 = performance.now() await project.collectDiagnostics() const t5 = performance.now() - stats.warmDiagnosticsTime += t5 - t4 + warmDiagnosticsTimes.push(t5 - t4) - // 4. Memory Usage - // Note: This measures memory at the end of each iteration. - // It might increase due to ts-morph's internal state. - stats.heapUsed += process.memoryUsage().heapUsed / 1024 / 1024 - } + // 4. Memory Usage - measure peak before GC + const memPeak = getMemoryMB() + const memDelta = memPeak - memBefore + + // Only record positive deltas (actual allocations) + // Negative values indicate GC interference + if (memDelta > 0) { + memoryDeltas.push(memDelta) + } - const averages: Record = { - 'Avg Init (ms)': (stats.initTime / ITERATIONS).toFixed(2), - 'Avg Cold Diagnostics (ms)': ( - stats.coldDiagnosticsTime / ITERATIONS - ).toFixed(2), - 'Avg Warm Diagnostics (ms)': ( - stats.warmDiagnosticsTime / ITERATIONS - ).toFixed(2), - 'Avg Heap Used (MB)': (stats.heapUsed / ITERATIONS).toFixed(2), - 'Diagnostic Count': stats.diagnosticCount.toString(), + process.stdout.write('.') } + console.log(' done!\n') + + // Calculate statistics + const initStats = calculateStats(initTimes) + const coldStats = calculateStats(coldDiagnosticsTimes) + const warmStats = calculateStats(warmDiagnosticsTimes) + const memStats = calculateStats(memoryDeltas) // Print Results - console.table(averages) + console.log('=== Initialization ===') + console.table({ + Mean: `${initStats.mean.toFixed(2)} ms`, + Median: `${initStats.median.toFixed(2)} ms`, + Min: `${initStats.min.toFixed(2)} ms`, + Max: `${initStats.max.toFixed(2)} ms`, + 'Std Dev': `${initStats.stdDev.toFixed(2)} ms`, + P95: `${initStats.p95.toFixed(2)} ms`, + }) + + console.log('\n=== Cold Diagnostics (First Run) ===') + console.table({ + Mean: `${coldStats.mean.toFixed(2)} ms`, + Median: `${coldStats.median.toFixed(2)} ms`, + Min: `${coldStats.min.toFixed(2)} ms`, + Max: `${coldStats.max.toFixed(2)} ms`, + 'Std Dev': `${coldStats.stdDev.toFixed(2)} ms`, + P95: `${coldStats.p95.toFixed(2)} ms`, + }) + + console.log('\n=== Warm Diagnostics (Cached) ===') + console.table({ + Mean: `${warmStats.mean.toFixed(2)} ms`, + Median: `${warmStats.median.toFixed(2)} ms`, + Min: `${warmStats.min.toFixed(2)} ms`, + Max: `${warmStats.max.toFixed(2)} ms`, + 'Std Dev': `${warmStats.stdDev.toFixed(2)} ms`, + P95: `${warmStats.p95.toFixed(2)} ms`, + }) + + console.log('\n=== Memory Usage (Peak per Iteration) ===') + if (memoryDeltas.length > 0) { + console.table({ + Mean: `${memStats.mean.toFixed(2)} MB`, + Median: `${memStats.median.toFixed(2)} MB`, + Min: `${memStats.min.toFixed(2)} MB`, + Max: `${memStats.max.toFixed(2)} MB`, + 'Std Dev': `${memStats.stdDev.toFixed(2)} MB`, + P95: `${memStats.p95.toFixed(2)} MB`, + Samples: memoryDeltas.length.toString(), + }) + } else { + console.log('No valid memory measurements (run with --expose-gc)') + } + + console.log('\n=== Summary ===') + console.log(`Total Diagnostics Found: ${diagnosticCount}`) + console.log( + `Total Time per Cold Run: ${(initStats.mean + coldStats.mean).toFixed(2)} ms`, + ) + console.log( + `Speedup (Cold → Warm): ${(coldStats.mean / warmStats.mean).toFixed(1)}x`, + ) - console.log('\nBreakdown:') - console.log('- Avg Init: Time to create the RWProject instance.') + console.log('\n=== Notes ===') + console.log('- Init: Time to create RWProject instance (lightweight)') console.log( - '- Avg Cold: Time to build the entire graph and run all diagnostics (first run per iteration).', + '- Cold: Full AST parsing + diagnostics (most relevant for comparison)', ) - console.log('- Avg Warm: Time to run diagnostics when using internal cache.') - console.log('- Avg Heap: Total heap memory used after building the project.') + console.log('- Warm: Re-running diagnostics with ts-morph internal caching') + console.log( + '- Memory Peak: Heap allocated per iteration (negative deltas filtered out)', + ) + console.log( + '\n⚠️ For accurate memory stats, run with: node --expose-gc benchmark.mts', + ) + if (!gc) { + console.log( + ' Memory measurements are unreliable without GC control enabled.', + ) + } } benchmark().catch((err) => { From dd474cb2ea67984a8f2de572f567dceac20dd201 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Sat, 3 Jan 2026 13:39:38 +0100 Subject: [PATCH 4/4] structure test project --- .../api/db/schema.prisma | 0 .../api/prisma.config.cjs | 1 + .../api/src/services/posts/posts.ts | 2 + .../structure-test-project/package.json | 4 + .../structure-test-project/redwood.toml | 7 + .../structure-test-project/web/src/Routes.tsx | 19 + .../components/UnusualCell/UnusualCell.tsx | 16 + .../web/src/layouts/MainLayout/MainLayout.tsx | 1 + .../web/src/pages/HomePage/HomePage.tsx | 1 + .../web/src/pages/NestedPage/NestedPage.tsx | 1 + .../src/pages/NotFoundPage/NotFoundPage.tsx | 1 + .../__snapshots__/diagnostics.test.ts.snap | 461 ++++++++++ .../__snapshots__/snapshot.test.ts.snap | 854 ++++++++++++++++++ .../src/__tests__/parity/api_contract.test.ts | 64 ++ .../src/__tests__/parity/diagnostics.test.ts | 59 ++ .../src/__tests__/parity/edge_cases.test.ts | 50 + .../src/__tests__/parity/extractors.test.ts | 69 ++ .../src/__tests__/parity/integration.test.ts | 45 + .../src/__tests__/parity/snapshot.test.ts | 71 ++ 19 files changed, 1726 insertions(+) create mode 100644 packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/api/db/schema.prisma create mode 100644 packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/api/prisma.config.cjs create mode 100644 packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/api/src/services/posts/posts.ts create mode 100644 packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/package.json create mode 100644 packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/redwood.toml create mode 100644 packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/Routes.tsx create mode 100644 packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/components/UnusualCell/UnusualCell.tsx create mode 100644 packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/layouts/MainLayout/MainLayout.tsx create mode 100644 packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/pages/HomePage/HomePage.tsx create mode 100644 packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/pages/NestedPage/NestedPage.tsx create mode 100644 packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/pages/NotFoundPage/NotFoundPage.tsx create mode 100644 packages/structure/src/__tests__/parity/__snapshots__/diagnostics.test.ts.snap create mode 100644 packages/structure/src/__tests__/parity/__snapshots__/snapshot.test.ts.snap create mode 100644 packages/structure/src/__tests__/parity/api_contract.test.ts create mode 100644 packages/structure/src/__tests__/parity/diagnostics.test.ts create mode 100644 packages/structure/src/__tests__/parity/edge_cases.test.ts create mode 100644 packages/structure/src/__tests__/parity/extractors.test.ts create mode 100644 packages/structure/src/__tests__/parity/integration.test.ts create mode 100644 packages/structure/src/__tests__/parity/snapshot.test.ts diff --git a/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/api/db/schema.prisma b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/api/db/schema.prisma new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/api/prisma.config.cjs b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/api/prisma.config.cjs new file mode 100644 index 0000000000..25003102a3 --- /dev/null +++ b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/api/prisma.config.cjs @@ -0,0 +1 @@ +module.exports = { schemaPath: 'db/schema.prisma' } diff --git a/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/api/src/services/posts/posts.ts b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/api/src/services/posts/posts.ts new file mode 100644 index 0000000000..78271de86f --- /dev/null +++ b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/api/src/services/posts/posts.ts @@ -0,0 +1,2 @@ +export const posts = () => [] +export const post = ({ id }: { id: number }) => ({ id, title: 'Post ' + id }) diff --git a/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/package.json b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/package.json new file mode 100644 index 0000000000..26edf72110 --- /dev/null +++ b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/package.json @@ -0,0 +1,4 @@ +{ + "name": "structure-test-project", + "private": true +} diff --git a/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/redwood.toml b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/redwood.toml new file mode 100644 index 0000000000..7234869d4f --- /dev/null +++ b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/redwood.toml @@ -0,0 +1,7 @@ +[web] + port = 8910 + apiUrl = "/.redwood/functions" +[api] + port = 8911 +[browser] + open = true diff --git a/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/Routes.tsx b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/Routes.tsx new file mode 100644 index 0000000000..6d943a5527 --- /dev/null +++ b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/Routes.tsx @@ -0,0 +1,19 @@ +import { Router, Route, Set } from '@cedarjs/router' + +import MainLayout from 'src/layouts/MainLayout' + +const Routes = () => { + return ( + + + + + + + + + + ) +} + +export default Routes diff --git a/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/components/UnusualCell/UnusualCell.tsx b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/components/UnusualCell/UnusualCell.tsx new file mode 100644 index 0000000000..30e75f742f --- /dev/null +++ b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/components/UnusualCell/UnusualCell.tsx @@ -0,0 +1,16 @@ +export const QUERY = gql` + query UnusualCell($id: Int!) { + post(id: $id) { + id + title + } + } +` + +export const Loading = () =>
Loading...
+ +// Unusual Success export +const MySuccess = ({ post }) =>
{post.title}
+export { MySuccess as Success } + +export const Failure = ({ error }) =>
{error.message}
diff --git a/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/layouts/MainLayout/MainLayout.tsx b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/layouts/MainLayout/MainLayout.tsx new file mode 100644 index 0000000000..704120713a --- /dev/null +++ b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/layouts/MainLayout/MainLayout.tsx @@ -0,0 +1 @@ +export default ({ children }) =>
{children}
diff --git a/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/pages/HomePage/HomePage.tsx b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/pages/HomePage/HomePage.tsx new file mode 100644 index 0000000000..e356b3f129 --- /dev/null +++ b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/pages/HomePage/HomePage.tsx @@ -0,0 +1 @@ +export default () =>

Home

diff --git a/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/pages/NestedPage/NestedPage.tsx b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/pages/NestedPage/NestedPage.tsx new file mode 100644 index 0000000000..a726c6ddca --- /dev/null +++ b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/pages/NestedPage/NestedPage.tsx @@ -0,0 +1 @@ +export default () =>

Nested

diff --git a/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/pages/NotFoundPage/NotFoundPage.tsx b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/pages/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 0000000000..3d6f782a8e --- /dev/null +++ b/packages/structure/src/__tests__/parity/__fixtures__/structure-test-project/web/src/pages/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1 @@ +export default () =>

Not Found

diff --git a/packages/structure/src/__tests__/parity/__snapshots__/diagnostics.test.ts.snap b/packages/structure/src/__tests__/parity/__snapshots__/diagnostics.test.ts.snap new file mode 100644 index 0000000000..02ee364009 --- /dev/null +++ b/packages/structure/src/__tests__/parity/__snapshots__/diagnostics.test.ts.snap @@ -0,0 +1,461 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Diagnostic Parity > captures correct diagnostics for example-todo-main-with-errors 1`] = ` +[ + { + "end": { + "character": 62, + "line": 2, + }, + "message": "Service Not Implemented", + "severity": 1, + "start": { + "character": 4, + "line": 2, + }, + "uri": "file:///api/src/graphql/todosMutations.sdl.js", + }, + { + "end": { + "character": 68, + "line": 2, + }, + "message": "Service Not Implemented", + "severity": 1, + "start": { + "character": 4, + "line": 2, + }, + "uri": "file:///api/src/graphql/todosWithAuthInvalidRolesErrors.sdl.js", + }, + { + "end": { + "character": 65, + "line": 2, + }, + "message": "Service Not Implemented", + "severity": 1, + "start": { + "character": 4, + "line": 2, + }, + "uri": "file:///api/src/graphql/todosWithAuthMissingRoleError.sdl.js", + }, + { + "end": { + "character": 60, + "line": 2, + }, + "message": "Service Not Implemented", + "severity": 1, + "start": { + "character": 4, + "line": 2, + }, + "uri": "file:///api/src/graphql/todosWithAuthRoles.sdl.js", + }, + { + "end": { + "character": 75, + "line": 3, + }, + "message": "Service Not Implemented", + "severity": 1, + "start": { + "character": 4, + "line": 3, + }, + "uri": "file:///api/src/graphql/todosWithAuthRoles.sdl.js", + }, + { + "end": { + "character": 108, + "line": 2, + }, + "message": "Service Not Implemented", + "severity": 1, + "start": { + "character": 4, + "line": 2, + }, + "uri": "file:///api/src/graphql/todosWithBuiltInDirectives.sdl.js", + }, + { + "end": { + "character": 0, + "line": 0, + }, + "message": "Syntax Error: Expected Name, found String "ADMIN". + +GraphQL request:3:57 +2 | type Query { +3 | todosWithMissingRolesAttribute: [Todo] @requireAuth("ADMIN") + | ^ +4 | }", + "severity": undefined, + "start": { + "character": 0, + "line": 0, + }, + "uri": "file:///api/src/graphql/todosWithMissingAuthRolesAttributeError.sdl.js", + }, + { + "end": { + "character": 0, + "line": 0, + }, + "message": "Syntax Error: Expected Name, found Int "42". + +GraphQL request:3:64 +2 | type Query { +3 | todosWithMissingRolesAttributeNumeric: [Todo] @requireAuth(42) + | ^ +4 | }", + "severity": undefined, + "start": { + "character": 0, + "line": 0, + }, + "uri": "file:///api/src/graphql/todosWithMissingAuthRolesAttributeNumericError.sdl.js", + }, + { + "end": { + "character": 56, + "line": 2, + }, + "message": "Service Not Implemented", + "severity": 1, + "start": { + "character": 4, + "line": 2, + }, + "uri": "file:///api/src/graphql/todosWithNumericRoleAuthError.sdl.js", + }, + { + "end": { + "character": 1, + "line": 12, + }, + "message": "We recommend that you name your query operation", + "severity": 2, + "start": { + "character": 24, + "line": 4, + }, + "uri": "file:///web/src/components/TodoListCell/TodoListCell.js", + }, + { + "end": { + "character": 21, + "line": 14, + }, + "message": "Duplicate Path", + "severity": 1, + "start": { + "character": 18, + "line": 14, + }, + "uri": "file:///web/src/Routes.js", + }, + { + "end": { + "character": 21, + "line": 15, + }, + "message": "Duplicate Path", + "severity": 1, + "start": { + "character": 18, + "line": 15, + }, + "uri": "file:///web/src/Routes.js", + }, + { + "end": { + "character": 32, + "line": 16, + }, + "message": "Error: Route path contains duplicate parameter: "/{foo}/{foo}"", + "severity": 1, + "start": { + "character": 18, + "line": 16, + }, + "uri": "file:///web/src/Routes.js", + }, + { + "end": { + "character": 51, + "line": 16, + }, + "message": "Page component not found", + "severity": 1, + "start": { + "character": 39, + "line": 16, + }, + "uri": "file:///web/src/Routes.js", + }, + { + "end": { + "character": 12, + "line": 13, + }, + "message": "You must specify a 'notfound' page", + "severity": 1, + "start": { + "character": 4, + "line": 13, + }, + "uri": "file:///web/src/Routes.js", + }, +] +`; + +exports[`Diagnostic Parity > captures correct diagnostics for local:structure-test-project 1`] = ` +[ + { + "end": { + "character": 0, + "line": 0, + }, + "message": "Every Cell MUST export a Success variable (React Component)", + "severity": 1, + "start": { + "character": 0, + "line": 0, + }, + "uri": "file:///web/src/components/UnusualCell/UnusualCell.tsx", + }, +] +`; + +exports[`Diagnostic Parity > captures correct diagnostics for test-project 1`] = ` +[ + { + "end": { + "character": 36, + "line": 185, + }, + "message": "env value NODE_ENV is not available. add it to your .env file", + "severity": 2, + "start": { + "character": 16, + "line": 185, + }, + "uri": "file:///api/src/functions/auth.ts", + }, +] +`; + +exports[`Diagnostic Parity > captures the exact same diagnostics for a project with errors 1`] = ` +[ + { + "end": { + "character": 21, + "line": 14, + }, + "message": "Duplicate Path", + "severity": 1, + "start": { + "character": 18, + "line": 14, + }, + "uri": "file:///web/src/Routes.js", + }, + { + "end": { + "character": 21, + "line": 15, + }, + "message": "Duplicate Path", + "severity": 1, + "start": { + "character": 18, + "line": 15, + }, + "uri": "file:///web/src/Routes.js", + }, + { + "end": { + "character": 32, + "line": 16, + }, + "message": "Error: Route path contains duplicate parameter: "/{foo}/{foo}"", + "severity": 1, + "start": { + "character": 18, + "line": 16, + }, + "uri": "file:///web/src/Routes.js", + }, + { + "end": { + "character": 51, + "line": 16, + }, + "message": "Page component not found", + "severity": 1, + "start": { + "character": 39, + "line": 16, + }, + "uri": "file:///web/src/Routes.js", + }, + { + "end": { + "character": 62, + "line": 2, + }, + "message": "Service Not Implemented", + "severity": 1, + "start": { + "character": 4, + "line": 2, + }, + "uri": "file:///api/src/graphql/todosMutations.sdl.js", + }, + { + "end": { + "character": 68, + "line": 2, + }, + "message": "Service Not Implemented", + "severity": 1, + "start": { + "character": 4, + "line": 2, + }, + "uri": "file:///api/src/graphql/todosWithAuthInvalidRolesErrors.sdl.js", + }, + { + "end": { + "character": 65, + "line": 2, + }, + "message": "Service Not Implemented", + "severity": 1, + "start": { + "character": 4, + "line": 2, + }, + "uri": "file:///api/src/graphql/todosWithAuthMissingRoleError.sdl.js", + }, + { + "end": { + "character": 60, + "line": 2, + }, + "message": "Service Not Implemented", + "severity": 1, + "start": { + "character": 4, + "line": 2, + }, + "uri": "file:///api/src/graphql/todosWithAuthRoles.sdl.js", + }, + { + "end": { + "character": 75, + "line": 3, + }, + "message": "Service Not Implemented", + "severity": 1, + "start": { + "character": 4, + "line": 3, + }, + "uri": "file:///api/src/graphql/todosWithAuthRoles.sdl.js", + }, + { + "end": { + "character": 108, + "line": 2, + }, + "message": "Service Not Implemented", + "severity": 1, + "start": { + "character": 4, + "line": 2, + }, + "uri": "file:///api/src/graphql/todosWithBuiltInDirectives.sdl.js", + }, + { + "end": { + "character": 56, + "line": 2, + }, + "message": "Service Not Implemented", + "severity": 1, + "start": { + "character": 4, + "line": 2, + }, + "uri": "file:///api/src/graphql/todosWithNumericRoleAuthError.sdl.js", + }, + { + "end": { + "character": 0, + "line": 0, + }, + "message": "Syntax Error: Expected Name, found Int "42". + +GraphQL request:3:64 +2 | type Query { +3 | todosWithMissingRolesAttributeNumeric: [Todo] @requireAuth(42) + | ^ +4 | }", + "severity": undefined, + "start": { + "character": 0, + "line": 0, + }, + "uri": "file:///api/src/graphql/todosWithMissingAuthRolesAttributeNumericError.sdl.js", + }, + { + "end": { + "character": 0, + "line": 0, + }, + "message": "Syntax Error: Expected Name, found String "ADMIN". + +GraphQL request:3:57 +2 | type Query { +3 | todosWithMissingRolesAttribute: [Todo] @requireAuth("ADMIN") + | ^ +4 | }", + "severity": undefined, + "start": { + "character": 0, + "line": 0, + }, + "uri": "file:///api/src/graphql/todosWithMissingAuthRolesAttributeError.sdl.js", + }, + { + "end": { + "character": 1, + "line": 12, + }, + "message": "We recommend that you name your query operation", + "severity": 2, + "start": { + "character": 24, + "line": 4, + }, + "uri": "file:///web/src/components/TodoListCell/TodoListCell.js", + }, + { + "end": { + "character": 12, + "line": 13, + }, + "message": "You must specify a 'notfound' page", + "severity": 1, + "start": { + "character": 4, + "line": 13, + }, + "uri": "file:///web/src/Routes.js", + }, +] +`; diff --git a/packages/structure/src/__tests__/parity/__snapshots__/snapshot.test.ts.snap b/packages/structure/src/__tests__/parity/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..c7354e1c3e --- /dev/null +++ b/packages/structure/src/__tests__/parity/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,854 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Project Serialization Parity > serializes example-todo-main correctly 1`] = ` +{ + "cells": [ + { + "name": "TodoListCell", + "queryOperationName": "TodoListCell_GetTodos", + }, + { + "name": "NumTodosTwoCell", + "queryOperationName": "NumTodosCell_GetCount", + }, + { + "name": "NumTodosCell", + "queryOperationName": "NumTodosCell_GetCount", + }, + ], + "layouts": [ + { + "name": "SetLayout", + }, + ], + "pages": [ + { + "constName": "BarPage", + "path": "/web/src/pages/BarPage/BarPage.tsx", + }, + { + "constName": "FatalErrorPage", + "path": "/web/src/pages/FatalErrorPage/FatalErrorPage.js", + }, + { + "constName": "FooPage", + "path": "/web/src/pages/FooPage/FooPage.tsx", + }, + { + "constName": "HomePage", + "path": "/web/src/pages/HomePage/HomePage.tsx", + }, + { + "constName": "NotFoundPage", + "path": "/web/src/pages/NotFoundPage/NotFoundPage.js", + }, + { + "constName": "PrivatePage", + "path": "/web/src/pages/PrivatePage/PrivatePage.tsx", + }, + { + "constName": "TypeScriptPage", + "path": "/web/src/pages/TypeScriptPage/TypeScriptPage.tsx", + }, + { + "constName": "adminEditUserPage", + "path": "/web/src/pages/admin/EditUserPage/EditUserPage.jsx", + }, + ], + "router": { + "routes": [ + { + "isNotFound": false, + "isPrivate": false, + "name": "home", + "pageIdentifier": "HomePage", + "path": "/", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "typescriptPage", + "pageIdentifier": "TypeScriptPage", + "path": "/typescript", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "someOtherPage", + "pageIdentifier": "EditUserPage", + "path": "/somewhereElse", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "fooPage", + "pageIdentifier": "FooPage", + "path": "/foo", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "barPage", + "pageIdentifier": "BarPage", + "path": "/bar", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": true, + "name": "privatePage", + "pageIdentifier": "PrivatePage", + "path": "/private-page", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": true, + "name": "privatePageAdmin", + "pageIdentifier": "PrivatePage", + "path": "/private-page-admin", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": true, + "name": "privatePageAdminSuper", + "pageIdentifier": "PrivatePage", + "path": "/private-page-admin-super", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": true, + "isPrivate": false, + "name": undefined, + "pageIdentifier": "NotFoundPage", + "path": undefined, + "prerender": false, + "redirect": undefined, + }, + ], + }, + "services": [ + { + "functions": [ + { + "name": "todos", + "parameters": [], + }, + { + "name": "createTodo", + "parameters": [ + "body", + ], + }, + { + "name": "numTodos", + "parameters": [], + }, + { + "name": "updateTodoStatus", + "parameters": [ + "id", + "status", + ], + }, + { + "name": "renameTodo", + "parameters": [ + "id", + "body", + ], + }, + ], + "name": "todos", + }, + ], +} +`; + +exports[`Project Serialization Parity > serializes local:structure-test-project correctly 1`] = ` +{ + "cells": [ + { + "name": "UnusualCell", + "queryOperationName": "UnusualCell", + }, + ], + "layouts": [ + { + "name": "MainLayout", + }, + ], + "pages": [ + { + "constName": "HomePage", + "path": "/web/src/pages/HomePage/HomePage.tsx", + }, + { + "constName": "NestedPage", + "path": "/web/src/pages/NestedPage/NestedPage.tsx", + }, + { + "constName": "NotFoundPage", + "path": "/web/src/pages/NotFoundPage/NotFoundPage.tsx", + }, + { + "constName": "TemplatePage", + "path": "/web/src/pages/TemplatePage/TemplatePage.tsx", + }, + ], + "router": { + "routes": [ + { + "isNotFound": false, + "isPrivate": false, + "name": "home", + "pageIdentifier": "HomePage", + "path": "/", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "nested", + "pageIdentifier": "NestedPage", + "path": "/nested", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "template", + "pageIdentifier": "TemplatePage", + "path": "/template-path-\${id}", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": true, + "isPrivate": false, + "name": undefined, + "pageIdentifier": "NotFoundPage", + "path": undefined, + "prerender": false, + "redirect": undefined, + }, + ], + }, + "services": [ + { + "functions": [ + { + "name": "posts", + "parameters": [], + }, + { + "name": "post", + "parameters": [ + "id", + ], + }, + ], + "name": "posts", + }, + ], +} +`; + +exports[`Project Serialization Parity > serializes test-project correctly 1`] = ` +{ + "cells": [ + { + "name": "WaterfallBlogPostCell", + "queryOperationName": "FindWaterfallBlogPostQuery", + }, + { + "name": "PostsCell", + "queryOperationName": "FindPosts", + }, + { + "name": "PostCell", + "queryOperationName": "FindPostById", + }, + { + "name": "EditPostCell", + "queryOperationName": "EditPostById", + }, + { + "name": "EditContactCell", + "queryOperationName": "EditContactById", + }, + { + "name": "ContactsCell", + "queryOperationName": "FindContacts", + }, + { + "name": "ContactCell", + "queryOperationName": "FindContactById", + }, + { + "name": "BlogPostsCell", + "queryOperationName": "BlogPostsQuery", + }, + { + "name": "BlogPostCell", + "queryOperationName": "FindBlogPostQuery", + }, + { + "name": "AuthorCell", + "queryOperationName": "FindAuthorQuery", + }, + ], + "layouts": [ + { + "name": "ScaffoldLayout", + }, + { + "name": "BlogLayout", + }, + ], + "pages": [ + { + "constName": "AboutPage", + "path": "/web/src/pages/AboutPage/AboutPage.tsx", + }, + { + "constName": "BlogPostPage", + "path": "/web/src/pages/BlogPostPage/BlogPostPage.tsx", + }, + { + "constName": "ContactUsPage", + "path": "/web/src/pages/ContactUsPage/ContactUsPage.tsx", + }, + { + "constName": "DoublePage", + "path": "/web/src/pages/DoublePage/DoublePage.tsx", + }, + { + "constName": "FatalErrorPage", + "path": "/web/src/pages/FatalErrorPage/FatalErrorPage.tsx", + }, + { + "constName": "ForgotPasswordPage", + "path": "/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx", + }, + { + "constName": "HomePage", + "path": "/web/src/pages/HomePage/HomePage.tsx", + }, + { + "constName": "LoginPage", + "path": "/web/src/pages/LoginPage/LoginPage.tsx", + }, + { + "constName": "NotFoundPage", + "path": "/web/src/pages/NotFoundPage/NotFoundPage.tsx", + }, + { + "constName": "ProfilePage", + "path": "/web/src/pages/ProfilePage/ProfilePage.tsx", + }, + { + "constName": "ResetPasswordPage", + "path": "/web/src/pages/ResetPasswordPage/ResetPasswordPage.tsx", + }, + { + "constName": "SignupPage", + "path": "/web/src/pages/SignupPage/SignupPage.tsx", + }, + { + "constName": "WaterfallPage", + "path": "/web/src/pages/WaterfallPage/WaterfallPage.tsx", + }, + { + "constName": "ContactContactPage", + "path": "/web/src/pages/Contact/ContactPage/ContactPage.tsx", + }, + { + "constName": "ContactContactsPage", + "path": "/web/src/pages/Contact/ContactsPage/ContactsPage.tsx", + }, + { + "constName": "ContactEditContactPage", + "path": "/web/src/pages/Contact/EditContactPage/EditContactPage.tsx", + }, + { + "constName": "ContactNewContactPage", + "path": "/web/src/pages/Contact/NewContactPage/NewContactPage.tsx", + }, + { + "constName": "PostEditPostPage", + "path": "/web/src/pages/Post/EditPostPage/EditPostPage.tsx", + }, + { + "constName": "PostNewPostPage", + "path": "/web/src/pages/Post/NewPostPage/NewPostPage.tsx", + }, + { + "constName": "PostPostPage", + "path": "/web/src/pages/Post/PostPage/PostPage.tsx", + }, + { + "constName": "PostPostsPage", + "path": "/web/src/pages/Post/PostsPage/PostsPage.tsx", + }, + ], + "router": { + "routes": [ + { + "isNotFound": false, + "isPrivate": false, + "name": "double", + "pageIdentifier": "DoublePage", + "path": "/double", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "login", + "pageIdentifier": "LoginPage", + "path": "/login", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "signup", + "pageIdentifier": "SignupPage", + "path": "/signup", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "forgotPassword", + "pageIdentifier": "ForgotPasswordPage", + "path": "/forgot-password", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "resetPassword", + "pageIdentifier": "ResetPasswordPage", + "path": "/reset-password", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "newContact", + "pageIdentifier": "ContactNewContactPage", + "path": "/contacts/new", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "editContact", + "pageIdentifier": "ContactEditContactPage", + "path": "/contacts/{id:Int}/edit", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "contact", + "pageIdentifier": "ContactContactPage", + "path": "/contacts/{id:Int}", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "contacts", + "pageIdentifier": "ContactContactsPage", + "path": "/contacts", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "newPost", + "pageIdentifier": "PostNewPostPage", + "path": "/posts/new", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "editPost", + "pageIdentifier": "PostEditPostPage", + "path": "/posts/{id:Int}/edit", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "post", + "pageIdentifier": "PostPostPage", + "path": "/posts/{id:Int}", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "posts", + "pageIdentifier": "PostPostsPage", + "path": "/posts", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "waterfall", + "pageIdentifier": "WaterfallPage", + "path": "/waterfall/{id:Int}", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": true, + "name": "profile", + "pageIdentifier": "ProfilePage", + "path": "/profile", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "blogPost", + "pageIdentifier": "BlogPostPage", + "path": "/blog-post/{id:Int}", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "contactUs", + "pageIdentifier": "ContactUsPage", + "path": "/contact", + "prerender": false, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "about", + "pageIdentifier": "AboutPage", + "path": "/about", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "home", + "pageIdentifier": "HomePage", + "path": "/", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": true, + "isPrivate": false, + "name": undefined, + "pageIdentifier": "NotFoundPage", + "path": undefined, + "prerender": true, + "redirect": undefined, + }, + ], + }, + "services": [ + { + "functions": [ + { + "name": "user", + "parameters": [ + "id", + ], + }, + ], + "name": "users", + }, + { + "functions": [ + { + "name": "posts", + "parameters": [], + }, + { + "name": "post", + "parameters": [ + "id", + ], + }, + { + "name": "createPost", + "parameters": [ + "input", + ], + }, + { + "name": "updatePost", + "parameters": [ + "id", + "input", + ], + }, + { + "name": "deletePost", + "parameters": [ + "id", + ], + }, + ], + "name": "posts", + }, + { + "functions": [ + { + "name": "contacts", + "parameters": [], + }, + { + "name": "contact", + "parameters": [ + "id", + ], + }, + { + "name": "createContact", + "parameters": [ + "input", + ], + }, + { + "name": "updateContact", + "parameters": [ + "id", + "input", + ], + }, + { + "name": "deleteContact", + "parameters": [ + "id", + ], + }, + ], + "name": "contacts", + }, + ], +} +`; + +exports[`Project Serialization Parity > serializes the entire project structure correctly 1`] = ` +{ + "cells": [ + { + "name": "TodoListCell", + "queryOperationName": "TodoListCell_GetTodos", + }, + { + "name": "NumTodosTwoCell", + "queryOperationName": "NumTodosCell_GetCount", + }, + { + "name": "NumTodosCell", + "queryOperationName": "NumTodosCell_GetCount", + }, + ], + "layouts": [ + { + "name": "SetLayout", + }, + ], + "pages": [ + { + "constName": "BarPage", + "path": "/web/src/pages/BarPage/BarPage.tsx", + }, + { + "constName": "FatalErrorPage", + "path": "/web/src/pages/FatalErrorPage/FatalErrorPage.js", + }, + { + "constName": "FooPage", + "path": "/web/src/pages/FooPage/FooPage.tsx", + }, + { + "constName": "HomePage", + "path": "/web/src/pages/HomePage/HomePage.tsx", + }, + { + "constName": "NotFoundPage", + "path": "/web/src/pages/NotFoundPage/NotFoundPage.js", + }, + { + "constName": "PrivatePage", + "path": "/web/src/pages/PrivatePage/PrivatePage.tsx", + }, + { + "constName": "TypeScriptPage", + "path": "/web/src/pages/TypeScriptPage/TypeScriptPage.tsx", + }, + { + "constName": "adminEditUserPage", + "path": "/web/src/pages/admin/EditUserPage/EditUserPage.jsx", + }, + ], + "router": { + "routes": [ + { + "isNotFound": false, + "isPrivate": false, + "name": "home", + "pageIdentifier": "HomePage", + "path": "/", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "typescriptPage", + "pageIdentifier": "TypeScriptPage", + "path": "/typescript", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "someOtherPage", + "pageIdentifier": "EditUserPage", + "path": "/somewhereElse", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "fooPage", + "pageIdentifier": "FooPage", + "path": "/foo", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": false, + "name": "barPage", + "pageIdentifier": "BarPage", + "path": "/bar", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": true, + "name": "privatePage", + "pageIdentifier": "PrivatePage", + "path": "/private-page", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": true, + "name": "privatePageAdmin", + "pageIdentifier": "PrivatePage", + "path": "/private-page-admin", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": false, + "isPrivate": true, + "name": "privatePageAdminSuper", + "pageIdentifier": "PrivatePage", + "path": "/private-page-admin-super", + "prerender": true, + "redirect": undefined, + }, + { + "isNotFound": true, + "isPrivate": false, + "name": undefined, + "pageIdentifier": "NotFoundPage", + "path": undefined, + "prerender": false, + "redirect": undefined, + }, + ], + }, + "services": [ + { + "functions": [ + { + "name": "todos", + "parameters": [], + }, + { + "name": "createTodo", + "parameters": [ + "body", + ], + }, + { + "name": "numTodos", + "parameters": [], + }, + { + "name": "updateTodoStatus", + "parameters": [ + "id", + "status", + ], + }, + { + "name": "renameTodo", + "parameters": [ + "id", + "body", + ], + }, + ], + "name": "todos", + }, + ], +} +`; diff --git a/packages/structure/src/__tests__/parity/api_contract.test.ts b/packages/structure/src/__tests__/parity/api_contract.test.ts new file mode 100644 index 0000000000..54ec745ec0 --- /dev/null +++ b/packages/structure/src/__tests__/parity/api_contract.test.ts @@ -0,0 +1,64 @@ +import { resolve } from 'node:path' + +import { describe, it, expect } from 'vitest' + +import { getProject } from '../../index' + +describe('API Contract (Public API stability)', () => { + const projectRoot = resolve( + __dirname, + '../../../../../__fixtures__/example-todo-main', + ) + const project = getProject(projectRoot) + + it('RWProject has expected top-level accessors', () => { + expect(project).toHaveProperty('pages') + expect(project).toHaveProperty('router') + expect(project).toHaveProperty('services') + expect(project).toHaveProperty('cells') + expect(project).toHaveProperty('layouts') + expect(Array.isArray(project.pages)).toBe(true) + }) + + it('RWRouter and RWRoute have expected properties', () => { + const router = project.router + expect(router).toHaveProperty('routes') + expect(Array.isArray(router.routes)).toBe(true) + + const route = router.routes[0] + expect(route).toHaveProperty('name') + expect(route).toHaveProperty('path') + expect(route).toHaveProperty('page') + expect(route).toHaveProperty('isPrivate') + expect(route).toHaveProperty('isNotFound') + expect(route).toHaveProperty('page_identifier_str') + // Important for Internal/Vite + expect(typeof route.isPrivate).toBe('boolean') + }) + + it('RWPage has expected properties', () => { + const page = project.pages[0] + expect(page).toHaveProperty('constName') + expect(page).toHaveProperty('path') // This is the file path + expect(typeof page.constName).toBe('string') + expect(typeof page.path).toBe('string') + }) + + it('RWCell has expected properties', () => { + const cell = project.cells[0] + expect(cell).toHaveProperty('queryOperationName') + expect(cell).toHaveProperty('isCell') + expect(typeof cell.isCell).toBe('boolean') + }) + + it('RWService has expected properties', () => { + const service = project.services[0] + expect(service).toHaveProperty('name') + expect(service).toHaveProperty('funcs') + expect(Array.isArray(service.funcs)).toBe(true) + + const func = service.funcs[0] + expect(func).toHaveProperty('name') + expect(func).toHaveProperty('parameterNames') + }) +}) diff --git a/packages/structure/src/__tests__/parity/diagnostics.test.ts b/packages/structure/src/__tests__/parity/diagnostics.test.ts new file mode 100644 index 0000000000..eeb47b7ddd --- /dev/null +++ b/packages/structure/src/__tests__/parity/diagnostics.test.ts @@ -0,0 +1,59 @@ +import { resolve } from 'node:path' + +import { describe, it, expect } from 'vitest' + +import { getProject } from '../../index' + +describe('Diagnostic Parity', () => { + const fixtures = [ + 'example-todo-main-with-errors', + 'test-project', + 'local:structure-test-project', + ] + + fixtures.forEach((fixtureName) => { + it(`captures correct diagnostics for ${fixtureName}`, async () => { + let projectRoot: string + if (fixtureName.startsWith('local:')) { + projectRoot = resolve( + __dirname, + '__fixtures__', + fixtureName.replace('local:', ''), + ) + } else { + projectRoot = resolve( + __dirname, + '../../../../../__fixtures__', + fixtureName, + ) + } + + const project = getProject(projectRoot) + const diagnostics = await project.collectDiagnostics() + + const cleanDiagnostics = diagnostics + .map((d) => ({ + message: d.diagnostic.message, + severity: d.diagnostic.severity, + start: { + line: d.diagnostic.range.start.line, + character: d.diagnostic.range.start.character, + }, + end: { + line: d.diagnostic.range.end.line, + character: d.diagnostic.range.end.character, + }, + uri: d.uri.replace(projectRoot, ''), + })) + .sort((a, b) => { + const uriComp = a.uri.localeCompare(b.uri) + if (uriComp !== 0) { + return uriComp + } + return a.message.localeCompare(b.message) + }) + + expect(cleanDiagnostics).toMatchSnapshot() + }) + }) +}) diff --git a/packages/structure/src/__tests__/parity/edge_cases.test.ts b/packages/structure/src/__tests__/parity/edge_cases.test.ts new file mode 100644 index 0000000000..98aad6fbe5 --- /dev/null +++ b/packages/structure/src/__tests__/parity/edge_cases.test.ts @@ -0,0 +1,50 @@ +import { resolve } from 'node:path' + +import { describe, it, expect } from 'vitest' + +import { getProject } from '../../index' + +describe('Error Handling and Edge Cases', () => { + const projectRootWithErrors = resolve( + __dirname, + '../../../../../__fixtures__/example-todo-main-with-errors', + ) + const projectWithErrors = getProject(projectRootWithErrors) + + it('handles malformed route syntax gracefully', async () => { + const routes = projectWithErrors.router.routes + const diagnostics = await projectWithErrors.router.collectDiagnostics() + + // Should still be able to parse other routes + expect(routes.length).toBeGreaterThan(0) + + // Should capture the syntax error in diagnostics + expect( + diagnostics.some((d) => + d.diagnostic.message.includes("specify a 'notfound' page"), + ), + ).toBe(true) + }) + + it('identifies missing mandatory exports in Cells via exportedSymbols', async () => { + const cell = projectWithErrors.cells.find( + (c) => c.basenameNoExt === 'TodoListCell', + ) + expect(cell).toBeDefined() + + // @ts-expect-error accessing internal exportedSymbols for verification + const symbols = cell.exportedSymbols + + expect(symbols.has('QUERY')).toBe(true) + expect(symbols.has('Success')).toBe(true) + expect(symbols.has('Failure')).toBe(false) // This is missing in the fixture + }) + + it('gracefully handles missing files', () => { + const project = getProject('/non/existent/path') + // Should not throw on init + expect(project.projectRoot).toBe('/non/existent/path') + // Should return empty arrays for children + expect(project.pages).toEqual([]) + }) +}) diff --git a/packages/structure/src/__tests__/parity/extractors.test.ts b/packages/structure/src/__tests__/parity/extractors.test.ts new file mode 100644 index 0000000000..f426ef3a85 --- /dev/null +++ b/packages/structure/src/__tests__/parity/extractors.test.ts @@ -0,0 +1,69 @@ +import { resolve } from 'node:path' + +import { describe, it, expect } from 'vitest' + +import { getProject } from '../../index' + +describe('Atomic Logic Parity', () => { + const fixtures = [ + 'example-todo-main', + 'test-project', + 'local:structure-test-project', + ] + + fixtures.forEach((fixtureName) => { + describe(`Fixture: ${fixtureName}`, () => { + let projectRoot: string + if (fixtureName.startsWith('local:')) { + projectRoot = resolve( + __dirname, + '__fixtures__', + fixtureName.replace('local:', ''), + ) + } else { + projectRoot = resolve( + __dirname, + '../../../../../__fixtures__', + fixtureName, + ) + } + + const project = getProject(projectRoot) + + it('correctly identifies a Cell vs a Component', () => { + const cells = project.cells + if (cells.length > 0) { + expect(cells[0].isCell).toBe(true) + } + + const component = project.components.find( + (c) => !c.basenameNoExt.endsWith('Cell'), + ) + if (component) { + // @ts-expect-error accessing internals for verification + expect(component.isCell).toBeUndefined() + } + }) + + it('extracts GraphQL operation names from Cells', () => { + for (const cell of project.cells) { + expect(cell.queryOperationName).toBeDefined() + } + }) + + it('finds all exported functions in services', () => { + for (const service of project.services) { + const funcNames = service.funcs.map((f) => f.name) + expect(funcNames.length).toBeGreaterThan(0) + } + }) + + it('correctly detects route attributes', () => { + for (const route of project.router.routes) { + expect(typeof route.isPrivate).toBe('boolean') + expect(typeof route.isNotFound).toBe('boolean') + } + }) + }) + }) +}) diff --git a/packages/structure/src/__tests__/parity/integration.test.ts b/packages/structure/src/__tests__/parity/integration.test.ts new file mode 100644 index 0000000000..9e48645cc3 --- /dev/null +++ b/packages/structure/src/__tests__/parity/integration.test.ts @@ -0,0 +1,45 @@ +import { resolve } from 'node:path' + +import { describe, it, expect, beforeAll, afterAll } from 'vitest' + +import { getProjectRoutes } from '../../../../internal/src/routes' + +describe('Internal Package Integration', () => { + const projectRoot = resolve( + __dirname, + '../../../../../__fixtures__/test-project', + ) + let originalCwd: string + + beforeAll(() => { + originalCwd = process.cwd() + process.chdir(projectRoot) + }) + + afterAll(() => { + process.chdir(originalCwd) + }) + + it('getProjectRoutes (from @cedarjs/internal) returns correctly mapped routes', () => { + const routes = getProjectRoutes() + expect(routes.length).toBeGreaterThan(15) + + const homeRoute = routes.find((r) => r.name === 'home') + expect(homeRoute).toBeDefined() + expect(homeRoute?.pathDefinition).toBe('/') + expect(homeRoute?.filePath).toContain('HomePage') + expect(homeRoute?.isPrivate).toBe(false) + + const privateRoute = routes.find((r) => r.isPrivate === true) + expect(privateRoute).toBeDefined() + expect(privateRoute?.unauthenticated).toBeDefined() + expect(privateRoute?.name).toEqual('profile') + + const paramRoute = routes.find((r) => r.name === 'editContact') + expect(paramRoute).toBeDefined() + expect(paramRoute?.pathDefinition).toBe('/posts/{id:Int}/edit') + expect(paramRoute?.filePath).toContain('EditPostPage') + expect(paramRoute?.isPrivate).toBe(false) + expect(paramRoute?.hasParams).toBe(true) + }) +}) diff --git a/packages/structure/src/__tests__/parity/snapshot.test.ts b/packages/structure/src/__tests__/parity/snapshot.test.ts new file mode 100644 index 0000000000..7bc45caef8 --- /dev/null +++ b/packages/structure/src/__tests__/parity/snapshot.test.ts @@ -0,0 +1,71 @@ +import { resolve } from 'node:path' + +import { describe, it, expect } from 'vitest' + +import { getProject } from '../../index' + +describe('Project Serialization Parity', () => { + const fixtures = [ + 'example-todo-main', + 'test-project', + 'local:structure-test-project', + ] + + fixtures.forEach((fixtureName) => { + it(`serializes ${fixtureName} correctly`, async () => { + let projectRoot: string + if (fixtureName.startsWith('local:')) { + projectRoot = resolve( + __dirname, + '__fixtures__', + fixtureName.replace('local:', ''), + ) + } else { + projectRoot = resolve( + __dirname, + '../../../../../__fixtures__', + fixtureName, + ) + } + + const project = getProject(projectRoot) + + // Helper to strip absolute paths from snapshots to make them portable + const cleanPath = (p: string | undefined) => p?.replace(projectRoot, '') + + const snapshot = { + pages: project.pages.map((p) => ({ + constName: p.constName, + path: cleanPath(p.path), + })), + router: { + routes: project.router.routes.map((r) => ({ + name: r.name, + path: r.path, + pageIdentifier: r.page_identifier_str, + isPrivate: r.isPrivate, + isNotFound: r.isNotFound, + prerender: r.prerender, + redirect: r.redirect, + })), + }, + services: project.services.map((s) => ({ + name: s.name, + functions: s.funcs.map((f) => ({ + name: f.name, + parameters: f.parameterNames, + })), + })), + cells: project.cells.map((c) => ({ + name: c.basenameNoExt, + queryOperationName: c.queryOperationName, + })), + layouts: project.layouts.map((l) => ({ + name: l.basenameNoExt, + })), + } + + expect(snapshot).toMatchSnapshot() + }) + }) +})