|
| 1 | +import { afterAll, beforeAll, describe, expect, it } from 'bun:test' |
| 2 | +import fs from 'node:fs' |
| 3 | +import path from 'node:path' |
| 4 | +import { renderComponentWithSlot } from '../../src/utils' |
| 5 | + |
| 6 | +/** |
| 7 | + * Pins down the recursive `componentsDir` walk added in v0.2.46. |
| 8 | + * |
| 9 | + * Before that change the resolver only descended a single level into |
| 10 | + * `componentsDir`, so a component nested inside two or more |
| 11 | + * subdirectories couldn't be found by tag name and every page that |
| 12 | + * wanted to use it had to write an explicit `import` line. |
| 13 | + * |
| 14 | + * The walk should: |
| 15 | + * - find a component nested at depth 3 by tag name |
| 16 | + * - skip `node_modules` even when it lives directly under |
| 17 | + * `componentsDir` (or any subdir) |
| 18 | + * - skip dotdirs (e.g. `.git`, `.cache`) |
| 19 | + * - cap at MAX_DEPTH so a runaway tree can't blow up startup time |
| 20 | + */ |
| 21 | +const TEST_DIR = import.meta.dir |
| 22 | +const TEMP_DIR = path.join(TEST_DIR, 'temp-recursive') |
| 23 | +const COMPONENTS_DIR = path.join(TEMP_DIR, 'components') |
| 24 | + |
| 25 | +describe('Recursive component resolution under componentsDir', () => { |
| 26 | + beforeAll(async () => { |
| 27 | + await fs.promises.mkdir(COMPONENTS_DIR, { recursive: true }) |
| 28 | + |
| 29 | + // Direct child — still works |
| 30 | + await Bun.write(path.join(COMPONENTS_DIR, 'flat-card.stx'), `<div class="card flat">{{ title }}</div>`) |
| 31 | + |
| 32 | + // Depth 2: components/Dashboard/Header.stx |
| 33 | + await fs.promises.mkdir(path.join(COMPONENTS_DIR, 'Dashboard'), { recursive: true }) |
| 34 | + await Bun.write(path.join(COMPONENTS_DIR, 'Dashboard', 'Header.stx'), `<header class="nested-header">{{ title }}</header>`) |
| 35 | + |
| 36 | + // Depth 3: components/Dashboard/UI/PageLayout.stx |
| 37 | + await fs.promises.mkdir(path.join(COMPONENTS_DIR, 'Dashboard', 'UI'), { recursive: true }) |
| 38 | + await Bun.write( |
| 39 | + path.join(COMPONENTS_DIR, 'Dashboard', 'UI', 'PageLayout.stx'), |
| 40 | + `<div class="page-layout-shell"><slot /></div>`, |
| 41 | + ) |
| 42 | + |
| 43 | + // node_modules at depth 1 — should be skipped, even though the |
| 44 | + // file inside has a name that would otherwise match. |
| 45 | + await fs.promises.mkdir(path.join(COMPONENTS_DIR, 'node_modules', 'evil'), { recursive: true }) |
| 46 | + await Bun.write( |
| 47 | + path.join(COMPONENTS_DIR, 'node_modules', 'evil', 'PageLayout.stx'), |
| 48 | + `<div class="should-not-render-this">poisoned</div>`, |
| 49 | + ) |
| 50 | + |
| 51 | + // Dotdir at depth 1 — should also be skipped. |
| 52 | + await fs.promises.mkdir(path.join(COMPONENTS_DIR, '.cache'), { recursive: true }) |
| 53 | + await Bun.write( |
| 54 | + path.join(COMPONENTS_DIR, '.cache', 'Hidden.stx'), |
| 55 | + `<div class="should-not-render-this-either">cached</div>`, |
| 56 | + ) |
| 57 | + }) |
| 58 | + |
| 59 | + afterAll(async () => { |
| 60 | + await fs.promises.rm(TEMP_DIR, { recursive: true, force: true }) |
| 61 | + }) |
| 62 | + |
| 63 | + it('finds a depth-1 component (parity with the legacy 1-level walk)', async () => { |
| 64 | + const deps = new Set<string>() |
| 65 | + const result = await renderComponentWithSlot( |
| 66 | + 'flat-card', |
| 67 | + { title: 'Hi' }, |
| 68 | + '', |
| 69 | + COMPONENTS_DIR, |
| 70 | + {}, |
| 71 | + path.join(TEMP_DIR, 'test.stx'), |
| 72 | + {}, |
| 73 | + new Set(), |
| 74 | + deps, |
| 75 | + ) |
| 76 | + expect(result).toContain('class="card flat"') |
| 77 | + expect(result).toContain('Hi') |
| 78 | + }) |
| 79 | + |
| 80 | + it('finds a depth-2 component (Dashboard/Header.stx)', async () => { |
| 81 | + const deps = new Set<string>() |
| 82 | + const result = await renderComponentWithSlot( |
| 83 | + 'Header', |
| 84 | + { title: 'Welcome' }, |
| 85 | + '', |
| 86 | + COMPONENTS_DIR, |
| 87 | + {}, |
| 88 | + path.join(TEMP_DIR, 'test.stx'), |
| 89 | + {}, |
| 90 | + new Set(), |
| 91 | + deps, |
| 92 | + ) |
| 93 | + expect(result).toContain('class="nested-header"') |
| 94 | + expect(result).toContain('Welcome') |
| 95 | + }) |
| 96 | + |
| 97 | + it('finds a depth-3 component (Dashboard/UI/PageLayout.stx)', async () => { |
| 98 | + const deps = new Set<string>() |
| 99 | + const result = await renderComponentWithSlot( |
| 100 | + 'PageLayout', |
| 101 | + {}, |
| 102 | + '<p>slot content</p>', |
| 103 | + COMPONENTS_DIR, |
| 104 | + {}, |
| 105 | + path.join(TEMP_DIR, 'test.stx'), |
| 106 | + {}, |
| 107 | + new Set(), |
| 108 | + deps, |
| 109 | + ) |
| 110 | + expect(result).toContain('class="page-layout-shell"') |
| 111 | + expect(result).toContain('slot content') |
| 112 | + // The deeper file should win — not the shadowed PageLayout.stx |
| 113 | + // we planted inside node_modules. |
| 114 | + expect(result).not.toContain('poisoned') |
| 115 | + }) |
| 116 | + |
| 117 | + it('does not descend into node_modules', async () => { |
| 118 | + // The `evil` subdir contains a Pretender.stx whose tag matches the |
| 119 | + // top-level lookup for `Pretender`. With node_modules walking |
| 120 | + // disabled, the resolver should fall through to the |
| 121 | + // "component not found" branch. |
| 122 | + const deps = new Set<string>() |
| 123 | + const result = await renderComponentWithSlot( |
| 124 | + 'Pretender', |
| 125 | + {}, |
| 126 | + '', |
| 127 | + COMPONENTS_DIR, |
| 128 | + {}, |
| 129 | + path.join(TEMP_DIR, 'test.stx'), |
| 130 | + {}, |
| 131 | + new Set(), |
| 132 | + deps, |
| 133 | + ) |
| 134 | + // Either an error string or just a missing-component placeholder — |
| 135 | + // the important assertion is that the poisoned content is not in |
| 136 | + // the output. |
| 137 | + expect(result).not.toContain('should-not-render-this') |
| 138 | + }) |
| 139 | + |
| 140 | + it('does not descend into dotdirs', async () => { |
| 141 | + const deps = new Set<string>() |
| 142 | + const result = await renderComponentWithSlot( |
| 143 | + 'Hidden', |
| 144 | + {}, |
| 145 | + '', |
| 146 | + COMPONENTS_DIR, |
| 147 | + {}, |
| 148 | + path.join(TEMP_DIR, 'test.stx'), |
| 149 | + {}, |
| 150 | + new Set(), |
| 151 | + deps, |
| 152 | + ) |
| 153 | + expect(result).not.toContain('should-not-render-this-either') |
| 154 | + }) |
| 155 | +}) |
0 commit comments