tsconfig-extends-aliases.test.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. /**
  2. * `compilerOptions.paths` behind an `extends` chain (#1534).
  3. *
  4. * Nx-style TypeScript monorepos keep every alias in `tsconfig.base.json` and
  5. * let the root `tsconfig.json` inherit it with a bare `"extends"`. The v1
  6. * loader read only the root file's own `compilerOptions`, so those repos got
  7. * `null` back — every cross-package import fell through to name-based
  8. * matching, silently, with no unresolved-import warning.
  9. *
  10. * What is locked in here:
  11. * - `paths` is picked up through one and through several `extends` hops
  12. * - `extends` targets resolve as relative paths (with or without `.json`),
  13. * and as `node_modules` package specifiers
  14. * - inherited `paths` resolve against the config that DECLARED them (a base
  15. * config one directory down must not have its targets read as root-relative)
  16. * - an explicit `baseUrl` still wins, and is itself relative to the file that
  17. * declared it
  18. * - the nearest config wins: a child's own `paths` replaces (not merges with)
  19. * the parent's, which is what `tsc` does
  20. * - a cyclic `extends` terminates instead of blowing the stack
  21. */
  22. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  23. import * as fs from 'fs';
  24. import * as path from 'path';
  25. import * as os from 'os';
  26. import { loadProjectAliases, applyAliases } from '../src/resolution/path-aliases';
  27. function write(file: string, content: unknown): void {
  28. fs.mkdirSync(path.dirname(file), { recursive: true });
  29. fs.writeFileSync(file, typeof content === 'string' ? content : JSON.stringify(content, null, 2));
  30. }
  31. describe('tsconfig `extends` chains (#1534)', () => {
  32. let root: string;
  33. beforeEach(() => {
  34. root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-tsextends-'));
  35. });
  36. afterEach(() => {
  37. fs.rmSync(root, { recursive: true, force: true });
  38. });
  39. it('picks up paths from an extended tsconfig.base.json (Nx layout)', () => {
  40. write(path.join(root, 'tsconfig.base.json'), {
  41. compilerOptions: {
  42. baseUrl: '.',
  43. paths: { '@scope/lib-name': ['libs/lib-name/src/index.ts'], '@scope/*': ['libs/*/src/index.ts'] },
  44. },
  45. });
  46. write(path.join(root, 'tsconfig.json'), { extends: './tsconfig.base.json', compilerOptions: {} });
  47. const aliases = loadProjectAliases(root);
  48. expect(aliases).not.toBeNull();
  49. expect(applyAliases('@scope/lib-name', aliases!, root)).toEqual(['libs/lib-name/src/index.ts']);
  50. expect(applyAliases('@scope/other', aliases!, root)).toEqual(['libs/other/src/index.ts']);
  51. });
  52. it('follows a multi-hop chain and accepts an extensionless relative target', () => {
  53. write(path.join(root, 'tsconfig.root.json'), {
  54. compilerOptions: { baseUrl: '.', paths: { '@app/*': ['packages/*/src'] } },
  55. });
  56. write(path.join(root, 'tsconfig.mid.json'), { extends: './tsconfig.root' });
  57. write(path.join(root, 'tsconfig.json'), { extends: './tsconfig.mid.json' });
  58. const aliases = loadProjectAliases(root);
  59. expect(applyAliases('@app/ui', aliases!, root)).toEqual(['packages/ui/src']);
  60. });
  61. it('resolves an `extends` package specifier through node_modules', () => {
  62. write(path.join(root, 'node_modules/@acme/tsconfig/tsconfig.json'), {
  63. // Anchored at the package's own directory: node_modules/@acme/tsconfig
  64. compilerOptions: { paths: { '@acme/*': ['../../../src/*'] } },
  65. });
  66. write(path.join(root, 'tsconfig.json'), { extends: '@acme/tsconfig' });
  67. const aliases = loadProjectAliases(root);
  68. expect(applyAliases('@acme/thing', aliases!, root)).toEqual(['src/thing']);
  69. });
  70. it('resolves inherited paths against the config that declared them, not the root', () => {
  71. // No baseUrl anywhere: tsc anchors `paths` at the declaring config's own
  72. // directory. Reading `src/*` as root-relative would silently point every
  73. // alias at the wrong tree.
  74. write(path.join(root, 'config/tsconfig.base.json'), {
  75. compilerOptions: { paths: { '~/*': ['src/*'] } },
  76. });
  77. write(path.join(root, 'tsconfig.json'), { extends: './config/tsconfig.base.json' });
  78. const aliases = loadProjectAliases(root);
  79. expect(applyAliases('~/foo', aliases!, root)).toEqual(['config/src/foo']);
  80. });
  81. it('honours an inherited baseUrl relative to the file that declared it', () => {
  82. write(path.join(root, 'config/tsconfig.base.json'), {
  83. compilerOptions: { baseUrl: '..', paths: { '~/*': ['src/*'] } },
  84. });
  85. write(path.join(root, 'tsconfig.json'), { extends: './config/tsconfig.base.json' });
  86. const aliases = loadProjectAliases(root);
  87. expect(applyAliases('~/foo', aliases!, root)).toEqual(['src/foo']);
  88. });
  89. it('lets the nearest config override inherited paths and baseUrl', () => {
  90. write(path.join(root, 'tsconfig.base.json'), {
  91. compilerOptions: { baseUrl: 'base-dir', paths: { '@x/*': ['from-base/*'] } },
  92. });
  93. write(path.join(root, 'tsconfig.json'), {
  94. extends: './tsconfig.base.json',
  95. compilerOptions: { baseUrl: 'own-dir', paths: { '@x/*': ['from-child/*'] } },
  96. });
  97. const aliases = loadProjectAliases(root);
  98. expect(applyAliases('@x/y', aliases!, root)).toEqual(['own-dir/from-child/y']);
  99. });
  100. it('terminates on a cyclic extends chain and still uses what it reached', () => {
  101. // The paths live INSIDE the cycle, so this only passes if the chain is
  102. // actually walked — and only returns at all if the cycle is cut.
  103. write(path.join(root, 'tsconfig.json'), { extends: './a.json' });
  104. write(path.join(root, 'a.json'), {
  105. extends: './b.json',
  106. compilerOptions: { paths: { '@cycle/*': ['from-a/*'] } },
  107. });
  108. write(path.join(root, 'b.json'), { extends: './a.json' });
  109. const aliases = loadProjectAliases(root);
  110. expect(applyAliases('@cycle/x', aliases!, root)).toEqual(['from-a/x']);
  111. });
  112. it('falls back to tsconfig.base.json behind a solution-style root config', () => {
  113. // What `nrwl/nx` itself ships: the root tsconfig.json is a project-
  114. // references shell with no `extends` and no `paths`, so following the
  115. // chain from it reaches nothing. The aliases are all in the base.
  116. write(path.join(root, 'tsconfig.base.json'), {
  117. compilerOptions: { baseUrl: '.', paths: { '@scope/*': ['libs/*/src/index.ts'] } },
  118. });
  119. write(path.join(root, 'tsconfig.json'), {
  120. compileOnSave: false,
  121. files: [],
  122. include: [],
  123. references: [{ path: './libs/lib-name' }],
  124. });
  125. const aliases = loadProjectAliases(root);
  126. expect(aliases).not.toBeNull();
  127. expect(applyAliases('@scope/lib-name', aliases!, root)).toEqual(['libs/lib-name/src/index.ts']);
  128. });
  129. it('falls back to tsconfig.base.json when no root tsconfig.json exists', () => {
  130. // The classic Nx integrated layout: only per-project tsconfigs and a
  131. // base at the root. Nothing to follow an `extends` chain from.
  132. write(path.join(root, 'tsconfig.base.json'), {
  133. compilerOptions: { baseUrl: '.', paths: { '@scope/*': ['libs/*/src/index.ts'] } },
  134. });
  135. const aliases = loadProjectAliases(root);
  136. expect(aliases).not.toBeNull();
  137. expect(applyAliases('@scope/lib-name', aliases!, root)).toEqual(['libs/lib-name/src/index.ts']);
  138. });
  139. it('still prefers the root tsconfig.json when both files carry paths', () => {
  140. // Precedence guard for the fallback: base is consulted only when the
  141. // root config yields nothing.
  142. write(path.join(root, 'tsconfig.base.json'), {
  143. compilerOptions: { baseUrl: '.', paths: { '@x/*': ['from-base/*'] } },
  144. });
  145. write(path.join(root, 'tsconfig.json'), {
  146. compilerOptions: { baseUrl: '.', paths: { '@x/*': ['from-root/*'] } },
  147. });
  148. const aliases = loadProjectAliases(root);
  149. expect(applyAliases('@x/y', aliases!, root)).toEqual(['from-root/y']);
  150. });
  151. it('still returns null when nothing in the chain declares paths', () => {
  152. write(path.join(root, 'tsconfig.base.json'), { compilerOptions: { strict: true } });
  153. write(path.join(root, 'tsconfig.json'), { extends: './tsconfig.base.json' });
  154. expect(loadProjectAliases(root)).toBeNull();
  155. });
  156. });