Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
generated
28 changes: 28 additions & 0 deletions dev-packages/e2e-tests/test-applications/package-types/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Package Types Test

This test "app" checks that the types of all `@sentry/*` packages match their JS files, for Node's `require`, Node's
`import` and bundlers.

- `scripts/check-entries.mjs` checks that the app depends on every published package with types, and that every
exports entry without types is in `JS_ONLY_ENTRIES`. The other checks skip entries without types.
- `scripts/check-attw.mjs` checks every package with [attw](https://github.com/arethetypeswrong/arethetypeswrong.github.io).
It fails for a problem that is not in `KNOWN_PROBLEMS`, and for a known problem that attw does not report any more.
- `scripts/check-types.mjs` imports every exports entry that has types, with TypeScript 5.0, 5.9, 6.0 and 7.0, in
`nodenext`, `bundler` and `node10` mode, and with the `react-native` condition. A default import must be an error
where the runtime loads an ESM file without a default export, and must be `module.exports` where it loads a CJS file.
- `scripts/check-runtime.mjs` checks that a CJS file sets `__esModule` only if its entry has a default export, and
runs the fixtures in `src` (see https://github.com/getsentry/sentry-javascript/issues/3105).

The checks need the `overrides` and the `SENTRY_E2E_WORKSPACE_ROOT` variable that the E2E runner adds, so run them
with `pnpm run test:run package-types` in `dev-packages/e2e-tests`.

## How to add a new package

Add the package as a dependency. The checks find its exports entries in its `package.json`.

## How to add an entry

- Entry with types: nothing to do.
- Entry with a default export: add it to `DEFAULT_EXPORT_ENTRIES` in `scripts/entries.mjs`.
- Entry without types (a hook, a loader or a file that a build tool injects): add it to `JS_ONLY_ENTRIES` in
`scripts/entries.mjs`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"name": "package-types",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "node node_modules/typescript-7.0/bin/tsc -p tsconfig.runtime.json",
"test:build": "pnpm install && pnpm build",
"test:assert": "node scripts/check-entries.mjs && node scripts/check-attw.mjs && node scripts/check-types.mjs && node scripts/check-runtime.mjs"
},
"dependencies": {
"@sentry/angular": "file:../../packed/sentry-angular-packed.tgz",
"@sentry/astro": "file:../../packed/sentry-astro-packed.tgz",
"@sentry/aws-serverless": "file:../../packed/sentry-aws-serverless-packed.tgz",
"@sentry/browser": "file:../../packed/sentry-browser-packed.tgz",
"@sentry/browser-utils": "file:../../packed/sentry-browser-utils-packed.tgz",
"@sentry/bun": "file:../../packed/sentry-bun-packed.tgz",
"@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz",
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz",
"@sentry/core": "file:../../packed/sentry-core-packed.tgz",
"@sentry/deno": "file:../../packed/sentry-deno-packed.tgz",
"@sentry/effect": "file:../../packed/sentry-effect-packed.tgz",
"@sentry/elysia": "file:../../packed/sentry-elysia-packed.tgz",
"@sentry/ember": "file:../../packed/sentry-ember-packed.tgz",
"@sentry/esbuild-plugin": "file:../../packed/sentry-esbuild-plugin-packed.tgz",
"@sentry/feedback": "file:../../packed/sentry-feedback-packed.tgz",
"@sentry/gatsby": "file:../../packed/sentry-gatsby-packed.tgz",
"@sentry/google-cloud-serverless": "file:../../packed/sentry-google-cloud-serverless-packed.tgz",
"@sentry/hono": "file:../../packed/sentry-hono-packed.tgz",
"@sentry/nestjs": "file:../../packed/sentry-nestjs-packed.tgz",
"@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz",
"@sentry/nitro": "file:../../packed/sentry-nitro-packed.tgz",
"@sentry/node": "file:../../packed/sentry-node-packed.tgz",
"@sentry/node-native": "file:../../packed/sentry-node-native-packed.tgz",
"@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz",
"@sentry/opentelemetry": "file:../../packed/sentry-opentelemetry-packed.tgz",
"@sentry/profiling-node": "file:../../packed/sentry-profiling-node-packed.tgz",
"@sentry/react": "file:../../packed/sentry-react-packed.tgz",
"@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz",
"@sentry/remix": "file:../../packed/sentry-remix-packed.tgz",
"@sentry/replay": "file:../../packed/sentry-replay-packed.tgz",
"@sentry/replay-canvas": "file:../../packed/sentry-replay-canvas-packed.tgz",
"@sentry/rollup-plugin": "file:../../packed/sentry-rollup-plugin-packed.tgz",
"@sentry/server-runtime-injection": "file:../../packed/sentry-server-runtime-injection-packed.tgz",
"@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz",
"@sentry/solid": "file:../../packed/sentry-solid-packed.tgz",
"@sentry/solidstart": "file:../../packed/sentry-solidstart-packed.tgz",
"@sentry/svelte": "file:../../packed/sentry-svelte-packed.tgz",
"@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz",
"@sentry/tanstackstart-react": "file:../../packed/sentry-tanstackstart-react-packed.tgz",
"@sentry/vercel-edge": "file:../../packed/sentry-vercel-edge-packed.tgz",
"@sentry/vite-plugin": "file:../../packed/sentry-vite-plugin-packed.tgz",
"@sentry/vue": "file:../../packed/sentry-vue-packed.tgz",
"@sentry/wasm": "file:../../packed/sentry-wasm-packed.tgz",
"@sentry/webpack-plugin": "file:../../packed/sentry-webpack-plugin-packed.tgz"
},
"devDependencies": {
"@arethetypeswrong/core": "0.18.5",
"@types/node": "^20.19.0",
"typescript-5.0": "npm:typescript@5.0.4",
"typescript-5.9": "npm:typescript@5.9.3",
"typescript-6.0": "npm:typescript@6.0.3",
"typescript-7.0": "npm:typescript@7.0.2"
},
"volta": {
"extends": "../../package.json"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
allowBuilds:
'@sentry/node-cpu-profiler': false
'@sentry/node-native-stacktrace': false
minimumReleaseAge: 0
# The checks read only the files of the Sentry packages, so the framework peer dependencies are not needed.
autoInstallPeers: false
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Checks the types of every `@sentry/*` package with "Are the types wrong?" (attw), for Node's `require`, Node's
* `import` and bundlers. It fails for every problem that is not in `KNOWN_PROBLEMS`, and for every entry in
* `KNOWN_PROBLEMS` that attw does not report any more.
*/
import { checkPackage, Package } from '@arethetypeswrong/core';
import { problemAffectsEntrypointResolution } from '@arethetypeswrong/core/problems';
import fs from 'node:fs';
import path from 'node:path';
import { getPackageDir, getPackageJson, getPackageNames, getTypedEntries } from './entries.mjs';

const RESOLUTION_KINDS = ['node16-cjs', 'node16-esm', 'bundler'];

/** What attw reports for `require` of an entry that has no CJS build. */
const ESM_ONLY_PROBLEMS = new Set(['CJSResolvesToESM', 'NoResolution']);

/** Problems in the published packages that are not fixed yet. Keys are `<entry> <resolution kind> <problem kind>`. */
const KNOWN_PROBLEMS = {
'@sentry/angular node16-cjs InternalResolutionError':
'ng-packagr writes ESM declaration files with relative imports that have no file extension.',
'@sentry/angular node16-esm InternalResolutionError':
'ng-packagr writes ESM declaration files with relative imports that have no file extension.',
'@sentry/astro node16-cjs InternalResolutionError':
'The package has "type": "module", so its declaration files are ESM, and their relative imports have no file extension.',
'@sentry/astro node16-esm InternalResolutionError':
'The package has "type": "module", so its declaration files are ESM, and their relative imports have no file extension.',
'@sentry/astro/integration node16-cjs InternalResolutionError':
'The package has "type": "module", so its declaration files are ESM, and their relative imports have no file extension.',
'@sentry/astro/integration node16-esm InternalResolutionError':
'The package has "type": "module", so its declaration files are ESM, and their relative imports have no file extension.',
'@sentry/deno node16-cjs InternalResolutionError':
'The declaration files are in the ESM build folder, and their relative imports have no file extension.',
'@sentry/deno node16-esm InternalResolutionError':
'The declaration files are in the ESM build folder, and their relative imports have no file extension.',
'@sentry/ember node16-cjs UnexpectedModuleSyntax':
'dist/index.js has ESM syntax in a package without "type": "module". Only the Ember build loads it.',
'@sentry/ember node16-esm UnexpectedModuleSyntax':
'dist/index.js has ESM syntax in a package without "type": "module". Only the Ember build loads it.',
'@sentry/nextjs node16-esm NamedExports':
"Node's `import` loads the CJS build (`node` condition), and Node does not detect some of its named exports, for example `logger`.",
'@sentry/nuxt/module/plugins node16-esm UnexpectedModuleSyntax':
'The plugin files have ESM syntax in a package without "type": "module". Only the Nuxt build loads them.',
'@sentry/remix node16-esm NamedExports':
"Node's `import` loads the CJS build (`node` condition), and Node does not detect some of its named exports, for example `growthbookIntegration`.",
};

const unexpected = new Map();
const found = new Set();

for (const name of getPackageNames()) {
const entries = getTypedEntries(name);
const typedSubpaths = new Set(entries.map(entry => entry.subpath));
const esmOnlySubpaths = new Set(
entries.filter(entry => entry.formats.nodeRequire !== 'cjs').map(entry => entry.subpath),
);
const { exports, version } = getPackageJson(name);

const analysis = await checkPackage(new Package(readPackageFiles(name), name, version), {
// Entries without types are JS-only (hooks, loaders, injected files).
excludeEntrypoints: Object.keys(exports).filter(subpath => !typedSubpaths.has(subpath)),
});

if (!analysis.types) {
unexpected.set(name, 'The package has no types.');
continue;
}

for (const problem of analysis.problems) {
for (const subpath of Object.keys(analysis.entrypoints)) {
for (const resolutionKind of RESOLUTION_KINDS) {
if (!problemAffectsEntrypointResolution(problem, subpath, resolutionKind, analysis)) {
continue;
}
if (resolutionKind === 'node16-cjs' && esmOnlySubpaths.has(subpath) && ESM_ONLY_PROBLEMS.has(problem.kind)) {
continue;
}

const key = `${name}${subpath === '.' ? '' : subpath.slice(1)} ${resolutionKind} ${problem.kind}`;
found.add(key);
if (!KNOWN_PROBLEMS[key] && !unexpected.has(key)) {
unexpected.set(key, JSON.stringify(problem));
}
}
}
}
}

const fixed = Object.keys(KNOWN_PROBLEMS).filter(key => !found.has(key));

if (unexpected.size) {
console.error(`attw found ${unexpected.size} new problems:\n`);
for (const [key, details] of unexpected) {
console.error(`${key}\n ${details}`);
}
console.error('\nSee https://github.com/arethetypeswrong/arethetypeswrong.github.io/tree/main/docs/problems\n');
}
if (fixed.length) {
console.error(
`attw does not report these known problems any more, remove them from KNOWN_PROBLEMS:\n${fixed.join('\n')}`,
);
}
if (unexpected.size || fixed.length) {
process.exit(1);
}

console.log(`attw: ${getPackageNames().length} packages, no new problems.`);

/** Reads the files of an installed package in the format of `Package` (paths start with `/node_modules/<name>/`). */
function readPackageFiles(name) {
const packageDir = getPackageDir(name);
const files = {};
const walk = dir => {
for (const dirent of fs.readdirSync(dir, { withFileTypes: true })) {
const filePath = path.join(dir, dirent.name);
if (dirent.isDirectory()) {
if (dirent.name !== 'node_modules') {
walk(filePath);
}
} else {
const relativePath = path.relative(packageDir, filePath).split(path.sep).join('/');
files[`/node_modules/${name}/${relativePath}`] = new Uint8Array(fs.readFileSync(filePath));
}
}
};
walk(packageDir);
return files;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Checks that the other checks cover every package and every entry:
* - The app depends on every `@sentry/*` package of the repository that has types.
* - Every exports entry without types is in `JS_ONLY_ENTRIES`, because the other checks skip entries without types.
*/
import fs from 'node:fs';
import path from 'node:path';
import { getEntriesWithoutTypes, getPackageNames, JS_ONLY_ENTRIES, PACKAGES_WITHOUT_TYPES } from './entries.mjs';

// The E2E runner and CI set it to the repository root.
const workspaceRoot = process.env.SENTRY_E2E_WORKSPACE_ROOT;
if (!workspaceRoot) {
console.error('SENTRY_E2E_WORKSPACE_ROOT is not set. Run the app with `pnpm run test:run package-types`.');
process.exit(1);
}

const failed = [];
const dependencies = new Set(getPackageNames());

const packagesDir = path.join(workspaceRoot, 'packages');
for (const dirent of fs.readdirSync(packagesDir, { withFileTypes: true })) {
const packageJsonPath = path.join(packagesDir, dirent.name, 'package.json');
if (!dirent.isDirectory() || !fs.existsSync(packageJsonPath)) {
continue;
}
const { name } = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (name.startsWith('@sentry/') && !dependencies.has(name) && !PACKAGES_WITHOUT_TYPES.has(name)) {
failed.push(`${name} is not a dependency of this app. Add it to package.json.`);
}
}

const entriesWithoutTypes = new Set([...dependencies].flatMap(name => getEntriesWithoutTypes(name)));
for (const specifier of entriesWithoutTypes) {
if (!JS_ONLY_ENTRIES.has(specifier)) {
failed.push(
`${specifier} has no types. Add types, or add it to JS_ONLY_ENTRIES if it is a hook, loader or injected file.`,
);
}
}
for (const specifier of JS_ONLY_ENTRIES) {
if (!entriesWithoutTypes.has(specifier)) {
failed.push(`${specifier} is in JS_ONLY_ENTRIES, but it has types or does not exist. Remove it from the list.`);
}
}

if (failed.length) {
console.error(failed.join('\n'));
process.exit(1);
}

console.log(`Entries: ${dependencies.size} packages, ${JS_ONLY_ENTRIES.size} entries without types.`);
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Checks the JS files that Node loads:
* - A CJS file sets `__esModule` only if its entry has a default export. With `__esModule`, a default import
* compiled by TypeScript (`esModuleInterop`) or a bundler reads `exports.default`, which is `undefined` for an entry
* without a default export (https://github.com/getsentry/sentry-javascript/issues/3105).
* - The fixtures in `src`, compiled to `dist`, run without errors.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { APP_DIR, getPackageNames, getTypedEntries } from './entries.mjs';

const ES_MODULE_MARKER_REGEX =
/Object\.defineProperty\(\s*exports\s*,\s*['"]__esModule['"]|Object\.defineProperties\(\s*exports\s*,\s*\{\s*__esModule\s*:|exports\.__esModule\s*=/;

const require = createRequire(path.join(APP_DIR, 'package.json'));
const failed = [];

for (const entry of getPackageNames().flatMap(name => getTypedEntries(name))) {
if (entry.formats.nodeRequire !== 'cjs') {
continue;
}

const file = require.resolve(entry.specifier);
const hasMarker = ES_MODULE_MARKER_REGEX.test(fs.readFileSync(file, 'utf8'));
if (hasMarker !== entry.hasDefaultExport) {
failed.push(
`${entry.specifier}: ${path.relative(APP_DIR, file)} ${hasMarker ? 'sets' : 'does not set'} __esModule, but the entry ${entry.hasDefaultExport ? 'has' : 'has no'} default export.`,
);
}
}

for (const fixture of ['default-import.cjs', 'namespace-import.mjs']) {
try {
execFileSync(process.execPath, [path.join(APP_DIR, 'dist', fixture)], { stdio: 'inherit' });
} catch {
failed.push(`dist/${fixture} failed.`);
}
}

if (failed.length) {
console.error(failed.join('\n'));
process.exit(1);
}

console.log('Runtime: all checks passed.');
Loading
Loading