Skip to content

Commit 02f8617

Browse files
authored
test(scripts): include type symbols in api snapshot test (#7985)
1 parent 96baad9 commit 02f8617

4 files changed

Lines changed: 1415 additions & 487 deletions

File tree

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ test-schema: bundles
5656
test-integration: bundles
5757
rm -rf ./clients/client-sso/node_modules/\@smithy # todo(yarn) incompatible redundant nesting.
5858
node ./scripts/validation/no-generic-byte-arrays.js
59+
node ./scripts/validation/api-snapshot-validation.js
60+
git diff --exit-code scripts/validation/api.json
5961
node ./scripts/compilation/Inliner.spec.js
6062
yarn g:vitest run -c vitest.config.integ.mts
6163
make test-protocols

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@
139139
},
140140
"husky": {
141141
"hooks": {
142-
"pre-commit": "lint-staged && yarn lint:versions && yarn lint:dependencies && yarn lint:api",
142+
"pre-commit": "lint-staged && yarn lint:versions && yarn lint:dependencies",
143143
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
144144
}
145145
},

scripts/validation/api-snapshot-validation.js

Lines changed: 128 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
#!/usr/bin/env node
22

33
/**
4-
This script uses the JSON file api-snapshot/api.json to validate that previously present symbols
4+
This script uses the JSON file scripts/validation/api.json to validate that previously present symbols
55
are still exported by the packages within the assessed group.
66
77
Data may only be deleted from api.json in an intentional backwards-incompatible change.
88
*/
99

1010
const fs = require("node:fs");
1111
const path = require("node:path");
12+
const ts = require("typescript");
1213

1314
const root = path.join(__dirname, "..", "..");
1415
const dataPath = path.join(root, "scripts", "validation", "api.json");
@@ -23,50 +24,150 @@ const packageDirs = [
2324
];
2425
const errors = [];
2526

27+
// Collect all .d.ts entry points upfront.
28+
const dtsEntries = []; // { dtsPath, name, version, cjsPath }
29+
2630
for (const packageRoot of packageDirs) {
2731
const pkgJsonPath = path.join(packageRoot, "package.json");
2832
const cjsPath = path.join(packageRoot, "dist-cjs", "index.js");
2933

3034
if (fs.existsSync(pkgJsonPath) && fs.existsSync(cjsPath)) {
3135
const packageJson = require(pkgJsonPath);
3236
const { name, version } = packageJson;
33-
const module = require(cjsPath);
3437

35-
for (const key of Object.keys(module)) {
36-
if (module[key] === undefined) {
37-
console.warn(`symbol ${key} in ${name}@${version} has a value of undefined.`);
38+
const dtsPath = path.join(packageRoot, packageJson.types || "dist-types/index.d.ts");
39+
if (fs.existsSync(dtsPath)) {
40+
dtsEntries.push({ dtsPath, name, version, cjsPath });
41+
}
42+
43+
if (packageJson.exports) {
44+
for (const [exportPath, exportConfig] of Object.entries(packageJson.exports)) {
45+
if (exportPath === "." || exportPath === "./package.json") continue;
46+
const subCjsPath = path.join(packageRoot, exportConfig.require || exportConfig.node);
47+
const subDtsPath = path.join(packageRoot, exportConfig.types || "");
48+
if (fs.existsSync(subCjsPath) && fs.existsSync(subDtsPath)) {
49+
const subName = `${name}/${exportPath.replace("./", "")}`;
50+
dtsEntries.push({ dtsPath: subDtsPath, name: subName, version, cjsPath: subCjsPath });
51+
}
3852
}
3953
}
54+
}
55+
}
4056

41-
if (!api[name]) {
42-
api[name] = {};
43-
for (const key of Object.keys(module)) {
44-
api[name][key] = [typeof module[key], `<=${version}`].join(", since ");
57+
// Create a single TypeScript program with all entry points.
58+
const allDtsPaths = dtsEntries.map((e) => e.dtsPath);
59+
const program = ts.createProgram(allDtsPaths, {
60+
moduleResolution: ts.ModuleResolutionKind.NodeJs,
61+
baseUrl: root,
62+
});
63+
const checker = program.getTypeChecker();
64+
65+
/**
66+
* Extract type-only exports from a .d.ts entry point using the shared program.
67+
*/
68+
function getTypeExports(dtsPath) {
69+
const typeExports = new Map();
70+
const sourceFile = program.getSourceFile(dtsPath);
71+
if (!sourceFile) return typeExports;
72+
73+
const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
74+
if (!moduleSymbol) return typeExports;
75+
76+
const exports = checker.getExportsOfModule(moduleSymbol);
77+
for (const sym of exports) {
78+
let resolved = sym;
79+
if (resolved.flags & ts.SymbolFlags.Alias) {
80+
resolved = checker.getAliasedSymbol(resolved);
81+
}
82+
const flags = resolved.flags;
83+
const isTypeOnly =
84+
!!(flags & ts.SymbolFlags.Interface) ||
85+
!!(flags & ts.SymbolFlags.TypeAlias) ||
86+
(!!(flags & ts.SymbolFlags.Enum) && !(flags & ts.SymbolFlags.RegularEnum));
87+
if (isTypeOnly) {
88+
let kind = "type";
89+
if (flags & ts.SymbolFlags.Interface) {
90+
kind = "type(interface)";
91+
} else if (flags & ts.SymbolFlags.TypeAlias) {
92+
const type = checker.getDeclaredTypeOfSymbol(resolved);
93+
if (type.isUnion()) kind = "type(union)";
94+
else if (type.isIntersection()) kind = "type(intersection)";
95+
else if (type.flags & ts.TypeFlags.String) kind = "type(string)";
96+
else if (type.flags & ts.TypeFlags.Number) kind = "type(number)";
97+
else if (type.flags & ts.TypeFlags.Boolean) kind = "type(boolean)";
98+
else if (type.flags & ts.TypeFlags.Object) kind = "type(object)";
99+
else kind = "type(alias)";
100+
} else {
101+
kind = "type(enum)";
45102
}
46-
} else {
47-
for (const symbol of [...new Set([...Object.keys(api[name]), ...Object.keys(module)])]) {
48-
if (symbol in module && !(symbol in api[name])) {
49-
errors.push(`You must commit changes in api.json.`);
50-
api[name][symbol] = [typeof module[symbol], version].join(", since ");
51-
}
52-
if (!(symbol in module) && symbol in api[name]) {
53-
errors.push(`Symbol [${symbol}] is missing from ${name}, (${api[name][symbol]}).`);
54-
}
55-
if (symbol in module && symbol in api[name]) {
56-
if (api[name][symbol].split(", ")[0] !== typeof module[symbol]) {
57-
errors.push(
58-
`Symbol [${symbol}] has a different type than expected in ${name}, actual=${typeof module[
59-
symbol
60-
]} expected=${api[name][symbol]}.`
61-
);
62-
}
103+
typeExports.set(sym.getName(), kind);
104+
}
105+
}
106+
return typeExports;
107+
}
108+
109+
// Process each entry.
110+
for (const { dtsPath, name, version, cjsPath } of dtsEntries) {
111+
const module = require(cjsPath);
112+
const typeExports = getTypeExports(dtsPath);
113+
checkModule(name, version, module, typeExports);
114+
}
115+
116+
function checkModule(name, version, module, typeExports) {
117+
for (const key of Object.keys(module)) {
118+
if (module[key] === undefined) {
119+
console.warn(`symbol ${key} in ${name}@${version} has a value of undefined.`);
120+
}
121+
}
122+
123+
// Merge runtime keys and type-only keys into a combined snapshot.
124+
const allSymbols = new Set([...Object.keys(module), ...typeExports.keys()]);
125+
126+
if (!api[name]) {
127+
api[name] = {};
128+
for (const key of allSymbols) {
129+
api[name][key] = key in module ? typeof module[key] : typeExports.get(key);
130+
}
131+
} else {
132+
for (const symbol of [...new Set([...Object.keys(api[name]), ...allSymbols])]) {
133+
const inRuntime = symbol in module;
134+
const inTypes = typeExports.has(symbol);
135+
const inSnapshot = symbol in api[name];
136+
const inCurrent = inRuntime || inTypes;
137+
138+
if (inCurrent && !inSnapshot) {
139+
const kind = inRuntime ? typeof module[symbol] : typeExports.get(symbol);
140+
errors.push(`You must commit changes in api.json adding ${symbol} (${kind}) to ${name}.`);
141+
api[name][symbol] = kind;
142+
}
143+
if (!inCurrent && inSnapshot) {
144+
errors.push(`Symbol [${symbol}] is missing from ${name}, (${api[name][symbol]}).`);
145+
}
146+
if (inCurrent && inSnapshot) {
147+
const expectedKind = api[name][symbol];
148+
const actualKind = inRuntime ? typeof module[symbol] : typeExports.get(symbol);
149+
if (expectedKind !== actualKind) {
150+
errors.push(
151+
`Symbol [${symbol}] has a different type than expected in ${name}, actual=${actualKind} expected=${expectedKind}.`
152+
);
63153
}
64154
}
65155
}
66156
}
67157
}
68158

69-
fs.writeFileSync(dataPath, JSON.stringify(api, null, 2) + "\n");
159+
const sorted = Object.fromEntries(
160+
Object.entries(api)
161+
.sort(([a], [b]) => a.localeCompare(b))
162+
.map(([k, v]) => [
163+
k,
164+
typeof v === "object" && v !== null
165+
? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b)))
166+
: v,
167+
])
168+
);
169+
170+
fs.writeFileSync(dataPath, JSON.stringify(sorted, null, 2) + "\n");
70171

71172
if (errors.length) {
72173
throw new Error(errors.join("\n"));

0 commit comments

Comments
 (0)