web-product-bundle-isolation.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. /** Exercise real Vite build inputs, including files absent from ordinary chunk imports. */
  2. import { existsSync, mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
  3. import { createRequire } from 'node:module'
  4. import { tmpdir } from 'node:os'
  5. import { dirname, join } from 'node:path'
  6. import { pathToFileURL } from 'node:url'
  7. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  8. import { browserDependencyAnalysis, productWebBundleIsolation } from '../apps/web/product-isolation.ts'
  9. import { WebProductBundleIsolation } from './web-product-bundle-isolation.ts'
  10. import { BundleInputIsolation } from './bundle-input-isolation.ts'
  11. const filesystem = vi.hoisted(() => ({ missingRoot: false }))
  12. vi.mock('node:fs', async (importOriginal) => {
  13. const actual = await importOriginal<typeof import('node:fs')>()
  14. return {
  15. ...actual,
  16. existsSync: (path: Parameters<typeof actual.existsSync>[0]) => !filesystem.missingRoot && actual.existsSync(path),
  17. }
  18. })
  19. // Vite is owned by the Web app rather than the repository's scripting dependencies.
  20. const vite = createRequire(new URL('../apps/web/package.json', import.meta.url))('vite') as {
  21. build(config: Record<string, unknown>): Promise<unknown>
  22. }
  23. function fixture() {
  24. // Vite resolves inputs with native realpath, which expands Windows short names.
  25. const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-web-bundle-isolation-')))
  26. const web = join(root, 'apps/web')
  27. const links: string[] = []
  28. onTestFinished(() => {
  29. for (const path of links) unlinkSync(path)
  30. rmSync(root, { recursive: true, force: true })
  31. })
  32. const write = (path: string, content: string): void => {
  33. mkdirSync(dirname(join(root, path)), { recursive: true })
  34. writeFileSync(join(root, path), content)
  35. }
  36. write('package.json', '{"name":"isolation-fixture","type":"module"}')
  37. write('apps/web/package.json', '{"name":"@deepseek-ai/dsh-web-frontend","type":"module"}')
  38. write('apps/web/index.html', '<script type="module" src="/src/main.js"></script>')
  39. write('apps/web/src/main.js', 'globalThis.product = true')
  40. write('apps/web/src/preview.js', 'import "../../../packages/experimental/prototype/index.js"')
  41. write('packages/experimental/prototype/package.json', '{"name":"@deepseek-ai/dsh-experimental-prototype","type":"module"}')
  42. write('packages/experimental/prototype/index.js', 'globalThis.experimental = true')
  43. write('packages/experimental/prototype/style.css', '.experimental { color: red }')
  44. write('packages/experimental/prototype/tiny.svg', '<svg xmlns="http://www.w3.org/2000/svg"/>')
  45. const run = (extra: Record<string, unknown> = {}): Promise<unknown> => vite.build({
  46. configFile: false,
  47. root: web,
  48. logLevel: 'silent',
  49. plugins: productWebBundleIsolation(root, web),
  50. build: {
  51. write: false,
  52. minify: false,
  53. rollupOptions: {
  54. input: { index: join(web, 'index.html'), bootstrap: join(web, 'src/preview.js') },
  55. },
  56. },
  57. ...extra,
  58. })
  59. return { root, web, write, run, links }
  60. }
  61. describe('default Web bundle input isolation', () => {
  62. it('requires explicit analysis intent before allowing externalized inputs in a non-writing build', async () => {
  63. const test = fixture()
  64. test.write('apps/web/src/main.js', 'import "external-lib"; globalThis.product = true')
  65. const externalize = {
  66. name: 'fixture-external-analysis',
  67. resolveId(id: string) { return id === 'external-lib' ? { id, external: true } : null },
  68. }
  69. await expect(test.run({ plugins: [...productWebBundleIsolation(test.root, test.web), externalize] }))
  70. .rejects.toThrow(/external module external-lib has no bundled input proof/)
  71. await expect(test.run({ plugins: [
  72. ...productWebBundleIsolation(test.root, test.web), browserDependencyAnalysis(), externalize,
  73. ] })).resolves.toBeDefined()
  74. expect(existsSync(join(test.web, 'dist'))).toBe(false)
  75. })
  76. it('refuses output writing when dependency analysis bypasses the product check', async () => {
  77. const test = fixture()
  78. const output = join(test.root, 'analysis-output')
  79. await expect(test.run({
  80. plugins: [...productWebBundleIsolation(test.root, test.web), browserDependencyAnalysis()],
  81. build: { write: true, outDir: output },
  82. })).rejects.toThrow(/browser dependency analysis requires build.write: false/)
  83. expect(existsSync(output)).toBe(false)
  84. })
  85. it('allows experimental code in the separately emitted preview entry', async () => {
  86. await expect(fixture().run()).resolves.toMatchObject({
  87. output: expect.arrayContaining([
  88. expect.objectContaining({ type: 'asset', fileName: 'index.html' }),
  89. expect.objectContaining({ type: 'chunk', name: 'bootstrap' }),
  90. ]) as unknown,
  91. })
  92. })
  93. it.each([
  94. ['static import', 'import "../../../packages/experimental/prototype/index.js"'],
  95. ['dynamic import', 'void import("../../../packages/experimental/prototype/index.js")'],
  96. ['raw query import', 'import text from "../../../packages/experimental/prototype/index.js?raw"; console.log(text)'],
  97. ])('rejects a product %s of experimental input', async (_name, source) => {
  98. const test = fixture()
  99. test.write('apps/web/src/main.js', source)
  100. await expect(test.run()).rejects.toThrow(/Web product isolation: experimental input/)
  101. })
  102. it('checks inline HTML modules after Vite resolves and bundles them', async () => {
  103. const test = fixture()
  104. test.write('apps/web/index.html', '<script type="module">import "../../packages/experimental/prototype/index.js"</script>')
  105. await expect(test.run()).rejects.toThrow(/Web product isolation: experimental input/)
  106. })
  107. it('checks resolver aliases by the actual file owner', async () => {
  108. const test = fixture()
  109. test.write('apps/web/src/main.js', 'import "feature"')
  110. await expect(test.run({ resolve: { alias: { feature: join(test.root, 'packages/experimental/prototype/index.js') } } }))
  111. .rejects.toThrow(/Web product isolation: experimental input/)
  112. })
  113. it('checks npm alias package identity independently of its directory name', async () => {
  114. const test = fixture()
  115. test.write('apps/web/src/main.js', 'import "ordinary-name"')
  116. test.write('apps/web/node_modules/ordinary-name/package.json',
  117. '{"name":"@deepseek-ai/dsh-experimental-aliased","type":"module","main":"index.js"}')
  118. test.write('apps/web/node_modules/ordinary-name/index.js', 'globalThis.aliased = true')
  119. await expect(test.run()).rejects.toThrow(/belongs to experimental package/)
  120. })
  121. it('follows virtual module edges to their resolved inputs', async () => {
  122. const test = fixture()
  123. test.write('apps/web/src/main.js', 'import "virtual:feature"')
  124. await expect(test.run({ plugins: [...productWebBundleIsolation(test.root, test.web), {
  125. name: 'fixture-virtual-module',
  126. resolveId(id: string) { return id === 'virtual:feature' ? '\u0000virtual:feature' : null },
  127. load(id: string) {
  128. return id === '\u0000virtual:feature'
  129. ? `import ${JSON.stringify(join(test.root, 'packages/experimental/prototype/index.js'))}` : null
  130. },
  131. }] })).rejects.toThrow(/Web product isolation: experimental input/)
  132. })
  133. it('rejects experimental inputs merged into a product-reachable shared chunk', async () => {
  134. const test = fixture()
  135. test.write('apps/web/src/main.js', 'import "./shared.js"; globalThis.product = true')
  136. test.write('apps/web/src/shared.js', 'globalThis.shared = true')
  137. await expect(test.run({ build: {
  138. write: false,
  139. minify: false,
  140. rollupOptions: {
  141. input: { index: join(test.web, 'index.html'), bootstrap: join(test.web, 'src/preview.js') },
  142. output: { manualChunks: (id: string) => id.endsWith('/shared.js') || id.includes('/packages/experimental/')
  143. ? 'shared' : undefined },
  144. },
  145. } })).rejects.toThrow(/Web product isolation: experimental input/)
  146. })
  147. it('checks symlinked package ownership', async () => {
  148. const test = fixture()
  149. test.write('apps/web/src/main.js', 'import "ordinary-name/index.js"')
  150. const link = join(test.web, 'node_modules/ordinary-name')
  151. mkdirSync(dirname(link), { recursive: true })
  152. symlinkSync(join(test.root, 'packages/experimental/prototype'), link, process.platform === 'win32' ? 'junction' : 'dir')
  153. test.links.push(link)
  154. await expect(test.run({ resolve: { preserveSymlinks: true } }))
  155. .rejects.toThrow(/Web product isolation: experimental input/)
  156. })
  157. it.each([
  158. ['stylesheet import', '@import "../../../packages/experimental/prototype/style.css";'],
  159. ['inlined image', '.product { background: url("../../../packages/experimental/prototype/tiny.svg") }'],
  160. ['emitted image', '.product { background: url("../../../packages/experimental/prototype/tiny.svg?no-inline") }'],
  161. ])('checks the actual CSS transform inputs for %s', async (_name, css) => {
  162. const test = fixture()
  163. test.write('apps/web/src/main.js', 'import "./style.css"; globalThis.product = true')
  164. test.write('apps/web/src/style.css', css)
  165. await expect(test.run()).rejects.toThrow(/Web product isolation: experimental input/)
  166. })
  167. it('allows experimental CSS only used by preview', async () => {
  168. const test = fixture()
  169. test.write('apps/web/src/preview.js', 'import "./preview.css"; globalThis.preview = true')
  170. test.write('apps/web/src/preview.css', '@import "../../../packages/experimental/prototype/style.css";')
  171. await expect(test.run()).resolves.toBeDefined()
  172. })
  173. it.each([
  174. ['URL worker', 'new Worker(new URL("./worker.js", import.meta.url), { type: "module" })'],
  175. ['inline worker', 'import Worker from "./worker.js?worker&inline"; new Worker()'],
  176. ])('checks %s subbuild inputs when the product loads it', async (_name, source) => {
  177. const test = fixture()
  178. test.write('apps/web/src/main.js', source)
  179. test.write('apps/web/src/worker.js', 'import "../../../packages/experimental/prototype/index.js"')
  180. await expect(test.run()).rejects.toThrow(/Web product isolation: experimental input/)
  181. })
  182. it('allows an experimental worker loaded only by preview', async () => {
  183. const test = fixture()
  184. test.write('apps/web/src/preview.js', 'new Worker(new URL("./worker.js", import.meta.url), { type: "module" })')
  185. test.write('apps/web/src/worker.js', 'import "../../../packages/experimental/prototype/index.js"')
  186. await expect(test.run()).resolves.toBeDefined()
  187. })
  188. it('rejects an emitted entry without its original HTML input', () => {
  189. const test = fixture()
  190. const inputs = new WebProductBundleIsolation(test.root, test.web)
  191. expect(() => { inputs.verify({}, () => null) }).toThrow(/index.html is missing its original HTML input/)
  192. })
  193. it('rejects a worker asset whose subbuild was not observed', () => {
  194. const test = fixture()
  195. const inputs = new WebProductBundleIsolation(test.root, test.web)
  196. inputs.assetReference('worker.js', 'index.html', 'asset')
  197. expect(() => { inputs.verify({
  198. 'index.html': { type: 'asset', fileName: 'index.html', names: ['index.html'], originalFileNames: ['index.html'] },
  199. 'worker.js': { type: 'asset', fileName: 'worker.js', names: [], originalFileNames: [] },
  200. }, () => null) }).toThrow(/worker.js has no recorded original files/)
  201. })
  202. })
  203. describe('bundler input ownership', () => {
  204. it('refuses source-map paths whose filesystem root is unavailable', () => {
  205. const test = fixture()
  206. const ownership = new BundleInputIsolation(test.root, 'fixture')
  207. filesystem.missingRoot = true
  208. try {
  209. expect(() => { ownership.assertSourceMapInput(join(test.web, 'missing/source.js')) })
  210. .toThrow(/has no existing filesystem root/)
  211. } finally {
  212. filesystem.missingRoot = false
  213. }
  214. })
  215. it('retains ownership through queries, filesystem URLs, and virtual path wrappers', () => {
  216. const test = fixture()
  217. const ownership = new BundleInputIsolation(test.root, 'fixture')
  218. const file = join(test.root, 'packages/experimental/prototype/index.js')
  219. for (const id of [file, `${file}?raw`, `${pathToFileURL(file).href}?import`, `\u0000${file}?commonjs-proxy`,
  220. `virtual:${file}`, `/@fs/${file.replaceAll('\\', '/')}`, file.replaceAll('/', '\\')]) {
  221. expect(() => { ownership.assertInput(id) }, id).toThrow(/experimental input/)
  222. }
  223. })
  224. it('distinguishes missing actual inputs from omitted upstream source-map files', () => {
  225. const test = fixture()
  226. const ownership = new BundleInputIsolation(test.root, 'fixture')
  227. const file = join(test.web, 'node_modules/upstream/src/omitted.js')
  228. test.write('apps/web/node_modules/upstream/package.json', '{"name":"upstream"}')
  229. expect(() => { ownership.assertInput(file) }).toThrow(/is missing/)
  230. expect(() => { ownership.assertSourceMapInput(file) }).not.toThrow()
  231. test.write('apps/web/node_modules/upstream/package.json', '{"name":"@deepseek-ai/dsh-experimental-upstream"}')
  232. ownership.reset()
  233. expect(() => { ownership.assertSourceMapInput(file) }).toThrow(/belongs to experimental package/)
  234. })
  235. })