verify-optional-dependency-imports.spec.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. /**
  2. * Tests for the optional-dependency load gate: which import and re-export forms
  3. * survive emit, and therefore load a package the installed tree may not carry.
  4. *
  5. * The expectations here match what `tsc` emits with `verbatimModuleSyntax` off:
  6. * `import type`, `import {}`, an inline `type` specifier, and a named binding
  7. * that resolves to a type all disappear; a bare import, a value binding, and a
  8. * star re-export remain.
  9. */
  10. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  11. import { tmpdir } from 'node:os'
  12. import { dirname, join } from 'node:path'
  13. import { afterAll, describe, expect, it } from 'vitest'
  14. import { TypeScriptProject } from './ts-project.ts'
  15. import { collectOptionalImportViolations } from './verify-optional-dependency-imports.ts'
  16. const FIXTURE: Record<string, string> = {
  17. 'tsconfig.host.json': JSON.stringify({
  18. compilerOptions: {
  19. target: 'es2022',
  20. module: 'esnext',
  21. moduleResolution: 'bundler',
  22. noEmit: true,
  23. skipLibCheck: true,
  24. types: [],
  25. paths: {
  26. '@f/opt': ['./packages/f/opt/src/index.ts'],
  27. '@f/hard': ['./packages/f/hard/src/index.ts'],
  28. },
  29. },
  30. include: ['packages/**/*.ts'],
  31. }),
  32. 'packages/f/opt/package.json': JSON.stringify({ name: '@f/opt', version: '0.0.1' }),
  33. 'packages/f/opt/src/index.ts': [
  34. 'export interface Shape { a: number }',
  35. 'export const runtimeValue = 1',
  36. '',
  37. ].join('\n'),
  38. 'packages/f/hard/package.json': JSON.stringify({ name: '@f/hard', version: '0.0.1' }),
  39. 'packages/f/hard/src/index.ts': 'export const hardValue = 2\n',
  40. // The consumer allows @f/opt to be absent and requires @f/hard.
  41. 'packages/f/consumer/package.json': JSON.stringify({
  42. name: '@f/consumer',
  43. version: '0.0.1',
  44. dependencies: { '@f/hard': '*' },
  45. peerDependencies: { '@f/opt': '*' },
  46. peerDependenciesMeta: { '@f/opt': { optional: true } },
  47. }),
  48. // Elided by the compiler, so each of these is allowed.
  49. 'packages/f/consumer/src/allowed-type-only.ts': [
  50. "import type {} from '@f/opt'",
  51. 'export const a = 1',
  52. '',
  53. ].join('\n'),
  54. 'packages/f/consumer/src/allowed-empty.ts': [
  55. "import {} from '@f/opt'",
  56. 'export const b = 1',
  57. '',
  58. ].join('\n'),
  59. 'packages/f/consumer/src/allowed-inline-type.ts': [
  60. "import { type Shape } from '@f/opt'",
  61. 'export const c: Shape = { a: 1 }',
  62. '',
  63. ].join('\n'),
  64. 'packages/f/consumer/src/allowed-type-binding.ts': [
  65. "import { Shape } from '@f/opt'",
  66. 'export const d: Shape = { a: 1 }',
  67. '',
  68. ].join('\n'),
  69. 'packages/f/consumer/src/allowed-type-reexport.ts': [
  70. "export type { Shape } from '@f/opt'",
  71. '',
  72. ].join('\n'),
  73. // A hard dependency may be loaded at module scope; only optional ones may not.
  74. 'packages/f/consumer/src/allowed-hard-dependency.ts': [
  75. "import { hardValue } from '@f/hard'",
  76. 'export const e = hardValue',
  77. '',
  78. ].join('\n'),
  79. // Kept by the compiler, so each of these loads a package that may be absent.
  80. 'packages/f/consumer/src/rejected-bare.ts': [
  81. "import '@f/opt'",
  82. 'export const f = 1',
  83. '',
  84. ].join('\n'),
  85. 'packages/f/consumer/src/rejected-value.ts': [
  86. "import { runtimeValue } from '@f/opt'",
  87. 'export const g = runtimeValue',
  88. '',
  89. ].join('\n'),
  90. 'packages/f/consumer/src/rejected-star-reexport.ts': [
  91. "export * from '@f/opt'",
  92. '',
  93. ].join('\n'),
  94. }
  95. const root = mkdtempSync(join(tmpdir(), 'optional-imports-'))
  96. for (const [rel, content] of Object.entries(FIXTURE)) {
  97. mkdirSync(dirname(join(root, rel)), { recursive: true })
  98. writeFileSync(join(root, rel), content)
  99. }
  100. const violations = collectOptionalImportViolations(new TypeScriptProject(root))
  101. afterAll(() => {
  102. rmSync(root, { recursive: true, force: true })
  103. })
  104. describe('optional dependency loads', () => {
  105. it('reports every form the compiler keeps, and nothing else', () => {
  106. expect(violations.map(violation => violation.split(' loads ')[0])).toEqual([
  107. 'packages/f/consumer/src/rejected-bare.ts:1',
  108. 'packages/f/consumer/src/rejected-star-reexport.ts:1',
  109. 'packages/f/consumer/src/rejected-value.ts:1',
  110. ])
  111. })
  112. it('names the package, the declaration that made it optional, and the way out', () => {
  113. expect(violations[0]).toBe(
  114. 'packages/f/consumer/src/rejected-bare.ts:1 loads @f/opt at module scope,'
  115. + ' declared optional in peerDependenciesMeta; import it as a type,'
  116. + ' or restructure so module scope does not need it',
  117. )
  118. })
  119. })