Skip to content
Merged
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
50 changes: 50 additions & 0 deletions .github/workflows/javascript-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,53 @@
run: |
export NODE_OPTIONS="--max-old-space-size=4096"
yarn ci

# Builds the artifact that actually ships to npm (the `prepare` script: tsc -b +
# rollup + copy-css) and loads every subpath of the `exports` map from it.
#
# The `build` job above only runs `tsc -p tsconfig.json`, so nothing in CI has ever
# imported the published shape. That gap is how Cratis/Components#118 - the published
# ESM contains directory imports, so no node-environment spec can render Dialog or
# CommandDialog - reached a release unnoticed.
#
# REPORTING ONLY - this job is expected to be RED today; being red is the point,
# it reproduces #118. It must not block merges until the two underlying causes are
# fixed: PrimeReact 11 (#107) for the bare `primereact/*` directory imports, and the
# CSS side-effect imports that rollup externalises via /\.css$/ (independent of #107).
# Once both are resolved, drop `continue-on-error` to make this a real publish gate.
verify-published-exports:
runs-on: ubuntu-latest
continue-on-error: true

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup node
uses: actions/setup-node@v4
with:
node-version: 23.x
registry-url: "https://registry.npmjs.org"

- uses: actions/cache@v4
id: yarn-cache
with:
path: |
.yarn/cache
**/node_modules
**/.eslintcache
**/yarn.lock
key: ${{ runner.os }}-yarn-${{ hashFiles('**/package.json') }}

- name: Yarn install
run: yarn

- name: Build the published artifact
working-directory: Source
run: |
export NODE_OPTIONS="--max-old-space-size=4096"
yarn run prepare

- name: Verify published exports
working-directory: Source
run: node scripts/verify-exports.mjs
Comment on lines +72 to +106
200 changes: 200 additions & 0 deletions Source/scripts/verify-exports.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

/*
* Verifies that every subpath declared in the `exports` map of Source/package.json
* can actually be loaded by Node from the *built* artifact.
*
* Why this exists: `yarn build` only runs `tsc -p tsconfig.json`. The artifact that
* ships to npm is produced by `prepare` (`tsc -b && rollup -c && copy-css`), and
* nothing in CI has ever imported that output. Cratis/Components#118 - the published
* ESM contains directory imports, so no node-environment spec can render Dialog or
* CommandDialog - reached a release precisely because of that gap.
*
* How it resolves the built output: the package root is `Source/` (a Yarn workspace
* named `@cratis/components`, node-modules linker), so `node_modules/@cratis/components`
* is a symlink to `Source/`. Loading the *package specifier* `@cratis/components/<subpath>`
* therefore goes through the real `exports` map and lands in `Source/dist/esm/...`
* (or `dist/cjs/...` for `require`) - the exact resolution a consumer gets. The child
* prints `import.meta.resolve(...)` first, so a pass/fail is always attributable to a
* concrete file under `dist/`.
*
* Each subpath is checked in an isolated child `node` process so that one failure
* cannot poison the module registry of the next, and so the real Node error code
* (ERR_UNSUPPORTED_DIR_IMPORT, ERR_MODULE_NOT_FOUND, ERR_UNKNOWN_FILE_EXTENSION, ...)
* is reported per subpath rather than as a single aggregate.
*
* Usage: node scripts/verify-exports.mjs [--esm-only] [--require-only]
* Exits non-zero if any JavaScript subpath fails to load, or any asset subpath is missing.
*/

import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const packageJsonPath = path.join(packageDir, 'package.json');

const args = new Set(process.argv.slice(2));
const checkEsm = !args.has('--require-only');
const checkCjs = !args.has('--esm-only');

/** Non-JavaScript targets Node cannot `import` - CSS assets and the package manifest.
* These are legitimate bundler-only entries, so they are existence-checked instead. */
const ASSET_EXTENSIONS = ['.css', '.json'];

const isAsset = (target) => typeof target === 'string' && ASSET_EXTENSIONS.some((ext) => target.endsWith(ext));

/** Picks the file a given condition resolves to, mirroring Node's condition ordering. */
function targetFor(value, condition) {
if (typeof value === 'string') return value;
if (value && typeof value === 'object') {
if (typeof value[condition] === 'string') return value[condition];
if (typeof value.default === 'string') return value.default;
}
return undefined;
}

/** Runs one child process that resolves and then loads a specifier. */
function loadInChildProcess(specifier, mode) {
const script =
mode === 'import'
? `const r = import.meta.resolve(${JSON.stringify(specifier)});
process.stdout.write('RESOLVED ' + r + '\\n');
await import(${JSON.stringify(specifier)});
process.stdout.write('LOADED\\n');`
: `const r = require.resolve(${JSON.stringify(specifier)});
process.stdout.write('RESOLVED ' + r + '\\n');
require(${JSON.stringify(specifier)});
process.stdout.write('LOADED\\n');`;

const result = spawnSync(
process.execPath,
['--input-type', mode === 'import' ? 'module' : 'commonjs', '--eval', script],
{ cwd: packageDir, encoding: 'utf8', timeout: 120_000 }
);

const stdout = result.stdout ?? '';
const stderr = result.stderr ?? '';
const resolved = (stdout.match(/^RESOLVED (.+)$/m) ?? [])[1];

if (result.status === 0 && stdout.includes('LOADED')) {
return { ok: true, resolved };
}

// Prefer Node's own error code and its message line ("Error [ERR_X]: ...").
const code = (stderr.match(/\b(ERR_[A-Z0-9_]+)\b/) ?? [])[1];
const message = (stderr.match(/^\s*[A-Za-z]*Error(?: \[ERR_[A-Z0-9_]+\])?: .*$/m) ?? [])[0]?.trim();

// CommonJS parse failures name the offending file on the very first stderr line
// (e.g. `.../Filter/FilterPanel.css:9`) but omit it from the message - keep it.
const offending = (stderr.match(/^(\/.+?\.(?:c|m)?[jt]sx?|\/.+?\.css):\d+$/m) ?? [])[1];

const parts = [message ?? 'unknown failure'];
if (offending && !parts[0].includes(offending)) parts.push(`in ${offending}`);

return {
ok: false,
resolved,
code: code ?? `exit ${result.status ?? 'signal ' + result.signal}`,
detail: parts.join(' - ').slice(0, 300),
stderr,
};
}

const pkg = JSON.parse(await readFile(packageJsonPath, 'utf8'));
const exportsMap = pkg.exports ?? {};
const subpaths = Object.keys(exportsMap);

if (subpaths.length === 0) {
console.error('No `exports` map found in Source/package.json - nothing to verify.');
process.exit(1);
}

// Fail fast and legibly when the published shape has not been built.
const esmRoot = path.join(packageDir, path.dirname(pkg.module ?? 'dist/esm/index.js'));
if (!existsSync(esmRoot)) {
console.error(`Built output not found at ${esmRoot}.`);
console.error('Run the publish build first: `yarn workspace @cratis/components run prepare`.');
process.exit(1);
}

const modes = [...(checkEsm ? ['import'] : []), ...(checkCjs ? ['require'] : [])];
const rows = [];
let failures = 0;

for (const subpath of subpaths) {
const specifier = subpath === '.' ? pkg.name : `${pkg.name}/${subpath.replace(/^\.\//, '')}`;
const value = exportsMap[subpath];

if (isAsset(targetFor(value, 'import'))) {
// CSS/JSON entries are consumed by bundlers, not by Node's ESM loader.
// Importing them would always yield ERR_UNKNOWN_FILE_EXTENSION, which says
// nothing about #118 - so assert the file is actually shipped instead.
const results = {};
for (const mode of modes) {
const target = targetFor(value, mode);
const abs = path.resolve(packageDir, target);
const present = existsSync(abs);
if (!present) failures++;
results[mode] = present ? 'ASSET OK' : 'MISSING';
if (!present) results[`${mode}Detail`] = `no file at ${path.relative(packageDir, abs)}`;
}
rows.push({ subpath, kind: 'asset', ...results });
continue;
}

const results = {};
for (const mode of modes) {
if (!targetFor(value, mode)) {
results[mode] = 'N/A';
continue;
}
const outcome = loadInChildProcess(specifier, mode);
if (outcome.ok) {
results[mode] = 'PASS';
} else {
failures++;
results[mode] = 'FAIL';
results[`${mode}Detail`] = `${outcome.code}: ${outcome.detail}`;
}
results[`${mode}Resolved`] = outcome.resolved;
}
rows.push({ subpath, kind: 'js', ...results });
}

const pad = (text, width) => String(text).padEnd(width);
const subpathWidth = Math.max(8, ...rows.map((row) => row.subpath.length));

console.log(`\nVerifying published exports of ${pkg.name}@${pkg.version}`);
console.log(`Package root: ${packageDir}`);
console.log(`Built ESM: ${path.relative(packageDir, esmRoot)}\n`);

console.log(`${pad('SUBPATH', subpathWidth)} ${modes.map((mode) => pad(mode.toUpperCase(), 8)).join(' ')}`);
console.log('-'.repeat(subpathWidth + modes.length * 10));
for (const row of rows) {
console.log(`${pad(row.subpath, subpathWidth)} ${modes.map((mode) => pad(row[mode] ?? '-', 8)).join(' ')}`);
}

const detailed = rows.filter((row) => modes.some((mode) => row[`${mode}Detail`]));
if (detailed.length > 0) {
console.log('\nFailures:\n');
for (const row of detailed) {
for (const mode of modes) {
if (!row[`${mode}Detail`]) continue;
console.log(` ${row.subpath} [${mode}]`);
if (row[`${mode}Resolved`]) console.log(` resolved: ${row[`${mode}Resolved`]}`);
console.log(` ${row[`${mode}Detail`]}\n`);
}
}
}

if (failures > 0) {
console.error(`${failures} export check(s) failed across ${rows.length} subpath(s).`);
console.error('See https://github.com/Cratis/Components/issues/118');
process.exit(1);
}

console.log(`\nAll ${rows.length} export subpath(s) load cleanly.`);
Loading