browser-bundled-externals.spec.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
  2. import { createRequire } from 'node:module'
  3. import { tmpdir } from 'node:os'
  4. import { dirname, join, resolve } from 'node:path'
  5. import { afterEach, describe, expect, it, vi } from 'vitest'
  6. import { browserBundledExternals, browserPackageOfFile, browserSourceAliases } from './browser-bundled-externals.ts'
  7. const filesystem = vi.hoisted(() => ({ preservedAlias: undefined as string | undefined }))
  8. vi.mock('node:fs', async (importOriginal) => {
  9. const actual = await importOriginal<typeof import('node:fs')>()
  10. return {
  11. ...actual,
  12. realpathSync: Object.assign((...args: Parameters<typeof actual.realpathSync>) => {
  13. // Windows' JavaScript realpath can retain an 8.3 alias that Vite's native resolution expands.
  14. if (typeof args[0] === 'string' && args[0] === filesystem.preservedAlias) return args[0]
  15. return actual.realpathSync(...args)
  16. }, { native: actual.realpathSync.native }),
  17. }
  18. })
  19. const roots: string[] = []
  20. const dependencyLinks: string[] = []
  21. const repositoryRoot = resolve(import.meta.dirname, '..')
  22. afterEach(() => {
  23. filesystem.preservedAlias = undefined
  24. for (const link of dependencyLinks.splice(0)) unlinkSync(link)
  25. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  26. })
  27. function fixture(): string {
  28. const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-browser-notices-')))
  29. roots.push(root)
  30. write(root, 'package.json', '{"type":"module"}')
  31. write(root, 'tsconfig.base.json', JSON.stringify({
  32. compilerOptions: {
  33. module: 'esnext', target: 'es2022', jsx: 'react-jsx',
  34. paths: { '@fixture/static': ['./packages/client/static/src/index.ts'] },
  35. },
  36. }))
  37. write(root, 'tsconfig.base.client.json', '{"extends":"./tsconfig.base.json"}')
  38. return root
  39. }
  40. function write(root: string, path: string, text: string): void {
  41. const target = join(root, path)
  42. mkdirSync(dirname(target), { recursive: true })
  43. writeFileSync(target, text)
  44. }
  45. function library(root: string, name: string, source = 'export const value = 1'): void {
  46. write(root, `node_modules/${name}/package.json`, JSON.stringify({ name, type: 'module', exports: './index.js' }))
  47. write(root, `node_modules/${name}/index.js`, source)
  48. }
  49. function dynamicPlugin(root: string, source: string): void {
  50. write(root, 'packages/client/dynamic/package.json', JSON.stringify({
  51. name: '@fixture/dynamic', dsh: { client: { platform: 'web' } },
  52. }))
  53. write(root, 'packages/client/dynamic/tsdown.config.ts', [
  54. 'export default {',
  55. ' name: "@fixture/dynamic/client",',
  56. ' entry: { client: "src/client/index.ts" },',
  57. ' inputOptions: { resolve: { conditionNames: ["browser", "import", "default"] } },',
  58. '}',
  59. ].join('\n'))
  60. write(root, 'packages/client/dynamic/src/client/index.ts', source)
  61. }
  62. describe('browser dependency discovery', () => {
  63. it('records runtime imports through the client config without following upstream dependencies or erased types', async () => {
  64. const root = fixture()
  65. library(root, 'browser-lib', 'export { value } from "transitive-lib"')
  66. library(root, 'transitive-lib')
  67. dynamicPlugin(root, [
  68. 'import type { MissingType } from "type-only-lib"',
  69. 'import { value } from "browser-lib"',
  70. 'export const output: MissingType = value',
  71. ].join('\n'))
  72. expect(await browserBundledExternals(root)).toEqual(new Set(['browser-lib']))
  73. expect(existsSync(join(root, 'packages/client/dynamic/lib'))).toBe(false)
  74. })
  75. it('rejects an unresolved runtime library', async () => {
  76. const root = fixture()
  77. dynamicPlugin(root, 'export { value } from "missing-browser-lib"')
  78. await expect(browserBundledExternals(root)).rejects.toThrow('cannot resolve missing-browser-lib')
  79. })
  80. it('rejects a declared client without its browser config', async () => {
  81. const root = fixture()
  82. dynamicPlugin(root, 'export const value = 1')
  83. write(root, 'packages/client/dynamic/tsdown.config.ts', 'export default { name: "@fixture/dynamic", entry: "src/index.ts" }')
  84. await expect(browserBundledExternals(root)).rejects.toThrow('has no browser build config')
  85. })
  86. it.each([
  87. ['Rollup', false, false], ['Rollup', true, false], ['Rollup', true, true],
  88. ['Rolldown', false, false], ['Rolldown', true, false], ['Rolldown', true, true],
  89. ] as const)('follows shell aliases, CSS and lazy imports without writing output (%s, symlinked root: %s, retained alias: %s)', async (bundler, linked, preserveAlias) => {
  90. const root = fixture()
  91. library(root, 'shell-lib')
  92. library(root, 'lazy-lib')
  93. library(root, 'asset-lib')
  94. write(root, 'node_modules/asset-lib/package.json', JSON.stringify({
  95. name: 'asset-lib', exports: { './theme.css': './theme.css' },
  96. }))
  97. write(root, 'node_modules/asset-lib/theme.css', '.fixture { color: red }')
  98. write(root, 'packages/client/static/package.json', '{"name":"@fixture/static"}')
  99. write(root, 'packages/client/static/src/index.ts', [
  100. 'import { value } from "shell-lib"',
  101. 'import "asset-lib/theme.css"',
  102. 'export const output = value',
  103. 'export const lazy = () => import("lazy-lib")',
  104. ].join('\n'))
  105. const app = join(root, 'apps/web')
  106. write(root, 'apps/web/package.json', '{"name":"@fixture/web","type":"module","exports":{"./dist/*":"./dist/*"}}')
  107. const owner = bundler === 'Rollup' ? resolve(repositoryRoot, 'apps/web/package.json')
  108. : createRequire(resolve(repositoryRoot, 'package.json')).resolve('vitest/package.json')
  109. const viteDirectory = dirname(createRequire(owner).resolve('vite/package.json'))
  110. const viteLink = join(app, 'node_modules/vite')
  111. mkdirSync(dirname(viteLink), { recursive: true })
  112. symlinkSync(viteDirectory, viteLink, 'junction')
  113. dependencyLinks.push(viteLink)
  114. write(root, 'apps/web/index.html', '<script type="module" src="./main.ts"></script>')
  115. write(root, 'apps/web/main.ts', 'import { output, lazy } from "@fixture/static"; console.log(output); void lazy()')
  116. write(root, 'apps/web/vite.config.ts', `
  117. import { productWebBundleIsolation } from ${JSON.stringify(resolve(repositoryRoot, 'apps/web/product-isolation.ts').replaceAll('\\', '/'))}
  118. export default ({ mode }) => {
  119. if (mode !== 'production') throw new Error('notice discovery must use the production build mode')
  120. return {
  121. plugins: [...productWebBundleIsolation(${JSON.stringify(root)}, ${JSON.stringify(app)}), {
  122. name: 'fixture-bundler-engine',
  123. generateBundle() {
  124. const actual = typeof this.meta.rolldownVersion === 'string' ? 'Rolldown' : 'Rollup'
  125. if (actual !== ${JSON.stringify(bundler)}) throw new Error('unexpected bundler: ' + actual)
  126. },
  127. }],
  128. build: { rollupOptions: { input: { index: ${JSON.stringify(join(app, 'index.html'))}, preview: "missing-preview.ts" } } }
  129. }
  130. }
  131. `)
  132. write(root, 'apps/web/dist/sentinel.txt', 'untouched')
  133. const scanRoot = linked ? join(fixture(), 'linked') : root
  134. if (linked) symlinkSync(root, scanRoot, 'junction')
  135. if (preserveAlias) filesystem.preservedAlias = scanRoot
  136. try {
  137. expect(await browserBundledExternals(scanRoot)).toEqual(new Set(['shell-lib', 'lazy-lib', 'asset-lib']))
  138. } finally {
  139. filesystem.preservedAlias = undefined
  140. if (linked) unlinkSync(scanRoot)
  141. }
  142. expect(readFileSync(join(app, 'dist/sentinel.txt'), 'utf8')).toBe('untouched')
  143. expect(existsSync(join(app, 'dist/index.html'))).toBe(false)
  144. expect(existsSync(join(root, 'packages/client/static/lib'))).toBe(false)
  145. })
  146. it('normalizes installed module ids and excludes virtual/workspace modules', () => {
  147. expect(browserPackageOfFile('/repo/node_modules/.pnpm/pkg@1/node_modules/pkg/a.js')).toBe('pkg')
  148. expect(browserPackageOfFile('C:\\repo\\node_modules\\@scope\\pkg\\a.css?url')).toBe('@scope/pkg')
  149. expect(browserPackageOfFile('/repo/packages/client/a.ts')).toBeUndefined()
  150. expect(browserPackageOfFile('\0vite/modulepreload-polyfill')).toBeUndefined()
  151. })
  152. it('maps exact names and subpaths from the source facade', () => {
  153. const root = fixture()
  154. const aliases = browserSourceAliases(root)
  155. expect('@fixture/static'.replace(aliases[0]!.find, aliases[0]!.replacement))
  156. .toBe(join(root, 'packages/client/static/src/index.ts'))
  157. expect(aliases[0]!.find.test('@fixture/static-extra')).toBe(false)
  158. })
  159. })