Skip to content

Commit ab2ea15

Browse files
cojiclaude
andcommitted
test: add tests for folder route pattern (route.tsx) migration
- isColocatedFile: route entries inside + folders are not colocated - import-rewriter: +types/route specifier rewriting, /index.ts stripping - normalizers: route segment collapsed to parent in snapshot comparison - CLI: folder route migration, +types/route rewriting, snapshot equivalence Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 12f4ab3 commit ab2ea15

File tree

4 files changed

+227
-3
lines changed

4 files changed

+227
-3
lines changed

test/migrate-cli.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,93 @@ describe('migrate CLI', () => {
279279
)
280280
})
281281

282+
it('migrates folder route pattern (route.tsx) correctly', () => {
283+
const fixture = createRoutesFixture({
284+
'app/routes/demo+/_layout/route.tsx':
285+
'import { Outlet } from "react-router"\nexport default function DemoLayout() { return <Outlet /> }\n',
286+
'app/routes/demo+/_index/route.tsx':
287+
'export default function DemoIndex() { return null }\n',
288+
'app/routes/demo+/about/route.tsx':
289+
"import type { Route } from './+types/route'\nexport default function About() { return null }\n",
290+
'app/routes/demo+/conform.nested-array/route.tsx':
291+
"import { schema } from './schema'\nexport default function ConformNestedArray() { return null }\n",
292+
'app/routes/demo+/conform.nested-array/schema.ts':
293+
'export const schema = {}\n',
294+
'app/routes/demo+/conform.nested-array/components/field.tsx':
295+
'export function Field() { return null }\n',
296+
})
297+
298+
const sourceAbsolute = fixture.sourceDir
299+
const sourceArg = fixture.toCwdRelativePath(sourceAbsolute)
300+
301+
const targetAbsolute = fixture.resolve('app', 'new-routes')
302+
const targetArg = fixture.toCwdRelativePath(targetAbsolute)
303+
304+
migrate(sourceArg, targetArg, {
305+
force: true,
306+
})
307+
308+
const files = fixture.listRelativeFiles(targetAbsolute)
309+
310+
// route.tsx files should become flat files or index.tsx, not +prefixed folders
311+
expect(files).toContain('demo/_layout.tsx')
312+
expect(files).toContain('demo/_index.tsx')
313+
expect(files).toContain('demo/about.tsx')
314+
expect(files).toContain('demo/conform.nested-array.tsx')
315+
316+
// Colocated files should get + prefix
317+
expect(files).toContain('demo/+conform.nested-array/schema.ts')
318+
expect(files).toContain('demo/+conform.nested-array/components/field.tsx')
319+
320+
// No route.tsx files should remain at the top level
321+
expect(files).not.toContain('demo/+_layout/route.tsx')
322+
expect(files).not.toContain('demo/+_index/route.tsx')
323+
expect(files).not.toContain('demo/+about/route.tsx')
324+
})
325+
326+
it('rewrites +types/route imports when migrating folder routes', () => {
327+
const fixture = createRoutesFixture({
328+
'app/routes/demo+/about/route.tsx':
329+
"import type { Route } from './+types/route'\nexport default function About({ loaderData }: Route.ComponentProps) { return null }\n",
330+
})
331+
332+
const sourceAbsolute = fixture.sourceDir
333+
const sourceArg = fixture.toCwdRelativePath(sourceAbsolute)
334+
const targetAbsolute = fixture.resolve('app', 'new-routes')
335+
const targetArg = fixture.toCwdRelativePath(targetAbsolute)
336+
337+
migrate(sourceArg, targetArg, { force: true })
338+
339+
const aboutPath = path.join(targetAbsolute, 'demo', 'about.tsx')
340+
const contents = fs.readFileSync(aboutPath, 'utf8')
341+
expect(contents).toContain("from './+types/about'")
342+
expect(contents).not.toContain("from './+types/route'")
343+
})
344+
345+
it('treats folder route (route.tsx) as equivalent to flat file in snapshots', () => {
346+
const before = normalizeSnapshot(
347+
`<Routes>
348+
<Route file="root.tsx">
349+
<Route file="routes/demo+/about/route.tsx" />
350+
<Route file="routes/demo+/_index/route.tsx" index />
351+
</Route>
352+
</Routes>
353+
`,
354+
)
355+
356+
const after = normalizeSnapshot(
357+
`<Routes>
358+
<Route file="root.tsx">
359+
<Route file="routes/demo/about.tsx" />
360+
<Route file="routes/demo/_index.tsx" index />
361+
</Route>
362+
</Routes>
363+
`,
364+
)
365+
366+
expect(before).toBe(after)
367+
})
368+
282369
it('treats dot notation index files as index routes', () => {
283370
const fixture = createRoutesFixture({
284371
'app/routes/settings+/profile.two-factor.tsx':

test/migration-convert-to-route.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,21 @@ describe('normalizeSnapshotRouteFilePath', () => {
7575
).toBe('routes/settings/profile.index.tsx')
7676
})
7777

78-
it('merges pathless underscore segments', () => {
78+
it('collapses folder route files (route.tsx) to their parent route', () => {
79+
expect(normalizeSnapshotRouteFilePath('routes/demo/about/route.tsx')).toBe(
80+
'routes/demo/about.tsx',
81+
)
82+
expect(normalizeSnapshotRouteFilePath('routes/demo/_index/route.tsx')).toBe(
83+
'routes/demo/_index.tsx',
84+
)
7985
expect(
80-
normalizeSnapshotRouteFilePath('routes/root_/__public.tsx'),
81-
).toBe('routes/root_.public.tsx')
86+
normalizeSnapshotRouteFilePath('routes/_public/($lang)._index/route.tsx'),
87+
).toBe('routes/_public/($lang)._index.tsx')
88+
})
89+
90+
it('merges pathless underscore segments', () => {
91+
expect(normalizeSnapshotRouteFilePath('routes/root_/__public.tsx')).toBe(
92+
'routes/root_.public.tsx',
93+
)
8294
})
8395
})

test/migration-fs-helpers.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,24 @@ describe('isColocatedFile', () => {
1919
expect(isColocatedFile('marketing/pricing.tsx')).toBe(false)
2020
expect(isColocatedFile('(_auth)/login.tsx')).toBe(false)
2121
})
22+
23+
it('returns false for folder route entries (route.tsx) inside + folders', () => {
24+
// remix-flat-routes folder route pattern: demo+/about/route.tsx
25+
// The `about/` directory is a route folder, not a colocated directory
26+
expect(isColocatedFile('demo+/about/route.tsx')).toBe(false)
27+
expect(isColocatedFile('demo+/_index/route.tsx')).toBe(false)
28+
expect(isColocatedFile('demo+/_layout/route.tsx')).toBe(false)
29+
expect(isColocatedFile('demo+/conform.nested-array/route.tsx')).toBe(false)
30+
expect(isColocatedFile('_public+/($lang)._index/route.tsx')).toBe(false)
31+
expect(isColocatedFile('app+/reports+/$id+/detail/route.tsx')).toBe(false)
32+
})
33+
34+
it('returns true for non-route files inside folder route directories', () => {
35+
// Files that are not route entries should still be colocated
36+
expect(isColocatedFile('demo+/about/components/header.tsx')).toBe(true)
37+
expect(isColocatedFile('demo+/conform.nested-array/schema.ts')).toBe(true)
38+
expect(isColocatedFile('demo+/conform.nested-array/faker.server.ts')).toBe(
39+
true,
40+
)
41+
})
2242
})

test/migration-import-rewriter.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,111 @@ export default function Admin() {
190190
expect(rewritten).toContain(`from '../utils/auth/index'`)
191191
})
192192

193+
it('rewrites +types/route specifier when route.tsx becomes a flat file', () => {
194+
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'rewrite-types-route-'))
195+
196+
const sourceDir = path.join(workspace, 'app', 'routes')
197+
const targetDir = path.join(workspace, 'app', 'new-routes')
198+
199+
// route.tsx → about.tsx: ./+types/route should become ./+types/about
200+
const sourceFile = path.join(sourceDir, 'demo+', 'about', 'route.tsx')
201+
const targetFile = path.join(targetDir, 'demo', 'about.tsx')
202+
203+
fs.mkdirSync(path.dirname(sourceFile), { recursive: true })
204+
fs.writeFileSync(
205+
sourceFile,
206+
`import type { Route } from './+types/route'
207+
208+
export function loader({ params }: Route.LoaderArgs) {
209+
return { title: 'About' }
210+
}
211+
212+
export default function About({ loaderData }: Route.ComponentProps) {
213+
return <h1>{loaderData.title}</h1>
214+
}
215+
`,
216+
)
217+
218+
const normalizedMapping = new Map<string, string>([
219+
[normalizeAbsolutePath(sourceFile), normalizeAbsolutePath(targetFile)],
220+
])
221+
222+
const specifierReplacements: SpecifierReplacement[] = []
223+
rewriteAndCopy(
224+
{ source: sourceFile, target: targetFile },
225+
normalizedMapping,
226+
specifierReplacements,
227+
)
228+
229+
const rewritten = fs.readFileSync(targetFile, 'utf8')
230+
expect(rewritten).toContain("from './+types/about'")
231+
expect(rewritten).not.toContain("from './+types/route'")
232+
})
233+
234+
it('strips /index.ts extension from import specifiers', () => {
235+
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'rewrite-strip-ts-ext-'))
236+
237+
const sourceDir = path.join(workspace, 'app', 'routes')
238+
const targetDir = path.join(workspace, 'app', 'new-routes')
239+
240+
const sourceFile = path.join(sourceDir, 'demo+', 'example', 'route.tsx')
241+
const targetFile = path.join(targetDir, 'demo', 'example.tsx')
242+
243+
const componentsDir = path.join(sourceDir, 'demo+', 'example', 'components')
244+
fs.mkdirSync(componentsDir, { recursive: true })
245+
fs.writeFileSync(
246+
path.join(componentsDir, 'index.ts'),
247+
'export { Header } from "./header"\n',
248+
)
249+
fs.writeFileSync(
250+
path.join(componentsDir, 'header.tsx'),
251+
'export function Header() { return null }\n',
252+
)
253+
254+
// Colocated components move to +components/
255+
const targetComponentsDir = path.join(
256+
targetDir,
257+
'demo',
258+
'+example',
259+
'components',
260+
)
261+
fs.mkdirSync(targetComponentsDir, { recursive: true })
262+
fs.writeFileSync(
263+
path.join(targetComponentsDir, 'index.ts'),
264+
'export { Header } from "./header"\n',
265+
)
266+
fs.writeFileSync(
267+
path.join(targetComponentsDir, 'header.tsx'),
268+
'export function Header() { return null }\n',
269+
)
270+
271+
fs.mkdirSync(path.dirname(sourceFile), { recursive: true })
272+
fs.writeFileSync(
273+
sourceFile,
274+
`import { Header } from './components/index.ts'
275+
276+
export default function Example() {
277+
return <Header />
278+
}
279+
`,
280+
)
281+
282+
const normalizedMapping = new Map<string, string>([
283+
[normalizeAbsolutePath(sourceFile), normalizeAbsolutePath(targetFile)],
284+
])
285+
286+
const specifierReplacements: SpecifierReplacement[] = []
287+
rewriteAndCopy(
288+
{ source: sourceFile, target: targetFile },
289+
normalizedMapping,
290+
specifierReplacements,
291+
)
292+
293+
const rewritten = fs.readFileSync(targetFile, 'utf8')
294+
// /index.ts should be stripped
295+
expect(rewritten).not.toContain('/index.ts')
296+
})
297+
193298
it('rewrites aliased imports for parent routes promoted to _layout', () => {
194299
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'rewrite-alias-'))
195300

0 commit comments

Comments
 (0)