|
| 1 | +import { it, expect, describe } from "vitest"; |
| 2 | +import * as fs from "fs"; |
| 3 | +import { join as pathJoin } from "path"; |
| 4 | +import { |
| 5 | + getReactDsfrImportedModuleIds, |
| 6 | + resolveModuleIdToDsfrComponents |
| 7 | +} from "../../../../src/bin/only-include-used-components"; |
| 8 | + |
| 9 | +/** |
| 10 | + * `only-include-used-components` maps every imported react-dsfr module to the DSFR CSS |
| 11 | + * components it renders. A module that is in neither REACT_DSFR_MODULE_TO_DSFR_COMPONENTS |
| 12 | + * nor NON_COMPONENT_MODULE_IDS makes resolveModuleIdToDsfrComponents() return undefined, |
| 13 | + * which trips the include-everything fail-safe: the script warns and exits 0, so a |
| 14 | + * consumer importing a component added after the map was written silently stops getting |
| 15 | + * any CSS trimmed at all. Nothing else in the test suite notices. |
| 16 | + * |
| 17 | + * This test is the guard: adding a component to `src/` without adding it to one of the |
| 18 | + * two maps fails here, naming the module. |
| 19 | + * |
| 20 | + * On what counts as a public module: package.json has NO `exports` field (checked below), |
| 21 | + * so the published subpaths are not an explicit list to compare against. They are exactly |
| 22 | + * whatever `tsc -p src` emits: the publish job runs denoify's `enable_short_npm_import_path` |
| 23 | + * (.github/workflows/ci.yaml), which moves the content of the tsconfig `outDir` up one level |
| 24 | + * onto the package root, so `@codegouvfr/react-dsfr/<subpath>` resolves straight into it. |
| 25 | + * The enumeration below therefore replays that emission: the entries of `src/`, minus what |
| 26 | + * `src/tsconfig.json` excludes (read from the file, not hardcoded, so a future exclude stays |
| 27 | + * in sync) and minus the files tsc does not emit a module for. |
| 28 | + */ |
| 29 | +describe("REACT_DSFR_MODULE_TO_DSFR_COMPONENTS exhaustiveness", () => { |
| 30 | + const projectRootDirPath = process.cwd(); |
| 31 | + const srcDirPath = pathJoin(projectRootDirPath, "src"); |
| 32 | + |
| 33 | + /** |
| 34 | + * Top level names excluded from the `tsc -p src` emission, e.g. "bin". |
| 35 | + * Only the first path segment is kept, so a pattern is still understood if it is ever |
| 36 | + * widened to a glob ("./bin" and "./bin/**" both yield "bin"). |
| 37 | + */ |
| 38 | + const getSrcTsconfigExcludedNames = (): Set<string> => { |
| 39 | + const { exclude }: { exclude?: string[] } = JSON.parse( |
| 40 | + fs.readFileSync(pathJoin(srcDirPath, "tsconfig.json")).toString("utf8") |
| 41 | + ); |
| 42 | + |
| 43 | + return new Set((exclude ?? []).map(pattern => pattern.replace(/^\.\//, "").split("/")[0])); |
| 44 | + }; |
| 45 | + |
| 46 | + const isModuleSourceFileName = (fileName: string): boolean => |
| 47 | + /\.tsx?$/.test(fileName) && !fileName.endsWith(".d.ts"); |
| 48 | + |
| 49 | + const removeExtension = (fileName: string): string => fileName.replace(/\.tsx?$/, ""); |
| 50 | + |
| 51 | + const hasAnIndex = (dirPath: string): boolean => |
| 52 | + fs.readdirSync(dirPath).some(childName => /^index\.tsx?$/.test(childName)); |
| 53 | + |
| 54 | + /** |
| 55 | + * The `@codegouvfr/react-dsfr/<subpath>` a consumer can import, one per module the |
| 56 | + * script has to resolve. Not every importable path: `tools/powerhooks/useConst` is |
| 57 | + * importable too, but yields the same module id as `tools/cx`, so listing the |
| 58 | + * shallowest path per module is enough and keeps failure messages readable. |
| 59 | + * |
| 60 | + * A directory with an index is importable as is. One without (Chart, blocks, shared, |
| 61 | + * tools...) is only reachable deeper, so its children are listed instead, which is what |
| 62 | + * produces the two segment "blocks/PasswordInput". A child directory is importable in |
| 63 | + * turn only if it has an index of its own: `tools/StatefulObservable` has one, |
| 64 | + * `tools/powerhooks` does not. |
| 65 | + */ |
| 66 | + const getPublicSubpaths = (): string[] => { |
| 67 | + const excludedNames = getSrcTsconfigExcludedNames(); |
| 68 | + |
| 69 | + const subpaths: string[] = []; |
| 70 | + |
| 71 | + for (const dirent of fs.readdirSync(srcDirPath, { "withFileTypes": true })) { |
| 72 | + if (excludedNames.has(dirent.name)) { |
| 73 | + continue; |
| 74 | + } |
| 75 | + |
| 76 | + if (!dirent.isDirectory()) { |
| 77 | + if (isModuleSourceFileName(dirent.name)) { |
| 78 | + subpaths.push(removeExtension(dirent.name)); |
| 79 | + } |
| 80 | + continue; |
| 81 | + } |
| 82 | + |
| 83 | + const dirPath = pathJoin(srcDirPath, dirent.name); |
| 84 | + |
| 85 | + if (hasAnIndex(dirPath)) { |
| 86 | + subpaths.push(dirent.name); |
| 87 | + continue; |
| 88 | + } |
| 89 | + |
| 90 | + for (const childDirent of fs.readdirSync(dirPath, { "withFileTypes": true })) { |
| 91 | + // Files are kept whatever their extension: src/assets holds only .svg and |
| 92 | + // .css, and skipping those would drop the "assets" module id altogether, |
| 93 | + // silently leaving it unchecked. |
| 94 | + if (childDirent.isDirectory() && !hasAnIndex(pathJoin(dirPath, childDirent.name))) { |
| 95 | + continue; |
| 96 | + } |
| 97 | + |
| 98 | + subpaths.push(`${dirent.name}/${removeExtension(childDirent.name)}`); |
| 99 | + } |
| 100 | + } |
| 101 | + |
| 102 | + return subpaths; |
| 103 | + }; |
| 104 | + |
| 105 | + /** |
| 106 | + * Goes through the production extraction rather than reimplementing it, so the module |
| 107 | + * ids asserted here are the ones the script will actually resolve at run time. |
| 108 | + */ |
| 109 | + const getModuleIdBySubpath = (): Map<string, string[]> => |
| 110 | + new Map( |
| 111 | + getPublicSubpaths().map(subpath => [ |
| 112 | + subpath, |
| 113 | + getReactDsfrImportedModuleIds({ |
| 114 | + "rawFileContent": `import "@codegouvfr/react-dsfr/${subpath}";` |
| 115 | + }) |
| 116 | + ]) |
| 117 | + ); |
| 118 | + |
| 119 | + it("covers every module publicly exposed by src/", () => { |
| 120 | + // One list, one assertion: every problem found in this run is reported at once. |
| 121 | + // Asserting inside the loop, or once per category, would abort on the first |
| 122 | + // offender and hide the rest, turning a single fix-and-rerun cycle into several. |
| 123 | + const problems: string[] = []; |
| 124 | + |
| 125 | + for (const [subpath, moduleIds] of getModuleIdBySubpath()) { |
| 126 | + // A public subpath must yield exactly one module id, otherwise the extraction |
| 127 | + // dropped it and its coverage would go unchecked, vacuously passing. |
| 128 | + if (moduleIds.length !== 1) { |
| 129 | + problems.push( |
| 130 | + `src/${subpath}: extraction yielded ${moduleIds.length} module ids, expected 1` |
| 131 | + ); |
| 132 | + continue; |
| 133 | + } |
| 134 | + |
| 135 | + const [moduleId] = moduleIds; |
| 136 | + |
| 137 | + if (resolveModuleIdToDsfrComponents({ moduleId }) === undefined) { |
| 138 | + problems.push(`${moduleId} (from src/${subpath}): resolves to undefined`); |
| 139 | + } |
| 140 | + } |
| 141 | + |
| 142 | + expect( |
| 143 | + problems, |
| 144 | + [ |
| 145 | + `only-include-used-components would fall back to including every DSFR`, |
| 146 | + `component because of:`, |
| 147 | + ...problems.map(entry => ` - ${entry}`), |
| 148 | + ``, |
| 149 | + `Add each unresolved module to REACT_DSFR_MODULE_TO_DSFR_COMPONENTS (with the`, |
| 150 | + `DSFR components its markup renders, transitive dependencies included) or to`, |
| 151 | + `NON_COMPONENT_MODULE_IDS if it renders no DSFR markup, in`, |
| 152 | + `src/bin/only-include-used-components.ts.` |
| 153 | + ].join("\n") |
| 154 | + ).toStrictEqual([]); |
| 155 | + }); |
| 156 | + |
| 157 | + it("enumerates the public modules from the real src/ layout", () => { |
| 158 | + // Guards the enumeration itself: were getPublicSubpaths() to return nothing, or to |
| 159 | + // quietly stop covering a module, the assertion above would pass on what it no |
| 160 | + // longer looks at. Narrowing the enumeration has to fail here, not go unnoticed. |
| 161 | + const subpaths = getPublicSubpaths(); |
| 162 | + |
| 163 | + expect(subpaths.length).toBeGreaterThan(40); |
| 164 | + |
| 165 | + for (const expected of ["Header", "Highlight", "blocks/PasswordInput", "i18n"]) { |
| 166 | + expect(subpaths).toContain(expected); |
| 167 | + } |
| 168 | + |
| 169 | + // One representative per shape: a component directory with an index, a flat file, |
| 170 | + // index-less directories reached through a child, and a directory whose children |
| 171 | + // are not TypeScript at all (src/assets holds only .svg and .css). |
| 172 | + const moduleIds = new Set(Array.from(getModuleIdBySubpath().values()).flat()); |
| 173 | + |
| 174 | + for (const expected of ["Header", "Alert", "Chart", "tools", "shared", "assets"]) { |
| 175 | + expect(moduleIds).toContain(expected); |
| 176 | + } |
| 177 | + |
| 178 | + // `src/tsconfig.json` excludes `./bin`: the CLI is not a public import subpath. |
| 179 | + expect(subpaths.some(subpath => subpath.startsWith("bin"))).toBe(false); |
| 180 | + }); |
| 181 | + |
| 182 | + it("has no package.json exports field to compare against", () => { |
| 183 | + // The premise of the enumeration above. If an `exports` map is ever added, this |
| 184 | + // fails and the public subpaths must be read from it instead of from src/. |
| 185 | + const packageJsonParsed = JSON.parse( |
| 186 | + fs.readFileSync(pathJoin(projectRootDirPath, "package.json")).toString("utf8") |
| 187 | + ); |
| 188 | + |
| 189 | + expect(packageJsonParsed["exports"]).toBe(undefined); |
| 190 | + }); |
| 191 | +}); |
0 commit comments