From 03546873cc01488ff959ce57acb6dcc3a1dddf51 Mon Sep 17 00:00:00 2001 From: sumfxn Date: Thu, 14 May 2026 02:54:39 +0200 Subject: [PATCH] chore(repo): add package consumer smoke --- .github/workflows/ci.yml | 3 +- .github/workflows/release.yml | 3 +- apps/demo/src/components/ConnectButton.tsx | 90 +++-- package.json | 8 +- packages/sdk/package.json | 11 +- packages/sdk/test/package-exports.test.ts | 36 ++ packages/widget/README.md | 2 + packages/widget/package.json | 11 +- packages/widget/tailwind-content.cjs | 4 +- packages/widget/test/package-exports.test.ts | 46 +++ packages/widget/tsup.config.ts | 2 +- scripts/consumer-smoke.mjs | 377 +++++++++++++++++++ 12 files changed, 530 insertions(+), 63 deletions(-) create mode 100644 packages/sdk/test/package-exports.test.ts create mode 100644 packages/widget/test/package-exports.test.ts create mode 100644 scripts/consumer-smoke.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 292e003..2f171f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: check: name: lint + typecheck + test runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 permissions: contents: read steps: @@ -31,6 +31,7 @@ jobs: - run: pnpm build - run: pnpm typecheck - run: pnpm test + - run: pnpm smoke:consumer commitlint: name: commitlint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0730bd7..cfc75ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: release: name: Release runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 25 if: github.repository == 'sumfxn/usdh-kit' && vars.RELEASES_ENABLED == 'true' permissions: contents: write @@ -35,6 +35,7 @@ jobs: - run: pnpm build - run: pnpm typecheck - run: pnpm test + - run: pnpm smoke:consumer - name: Verify npm publish credentials env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/apps/demo/src/components/ConnectButton.tsx b/apps/demo/src/components/ConnectButton.tsx index 28a2385..1f07ffb 100644 --- a/apps/demo/src/components/ConnectButton.tsx +++ b/apps/demo/src/components/ConnectButton.tsx @@ -2,49 +2,63 @@ import { ConnectKitButton } from 'connectkit' +type ConnectKitRenderProps = { + isConnected: boolean + isConnecting: boolean + show?: () => void + address?: `0x${string}` + ensName?: string +} + export function ConnectButton() { + return {renderConnectButton} +} + +function renderConnectButton({ + isConnected, + isConnecting, + show, + address, + ensName, +}: ConnectKitRenderProps) { + const openConnectKit = () => show?.() + + if (isConnecting) { + return ( + + Connecting... + + ) + } + if (!isConnected) { + return ( + + ) + } + const label = ensName ?? truncate(address) return ( - - {({ isConnected, isConnecting, show, address, ensName }) => { - if (isConnecting) { - return ( - - Connecting… - - ) - } - if (!isConnected) { - return ( - - ) - } - const label = ensName ?? truncate(address) - return ( - - ) - }} - + ) } function truncate(addr: string | undefined): string { if (!addr) return '' - return `${addr.slice(0, 6)}…${addr.slice(-4)}` + return `${addr.slice(0, 6)}...${addr.slice(-4)}` } diff --git a/package.json b/package.json index 30dfcf8..c205ca4 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ }, "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8", "scripts": { - "build": "pnpm -r --filter './packages/*' build", - "test": "pnpm -r --filter './packages/*' test", + "build": "pnpm -r --filter \"./packages/*\" --filter @usdh-kit-apps/demo build", + "test": "pnpm -r --filter \"./packages/*\" test", "lint": "biome check .", "lint:fix": "biome check --write .", - "typecheck": "pnpm -r --filter './packages/*' typecheck", + "typecheck": "pnpm -r --filter \"./packages/*\" --filter @usdh-kit-apps/demo typecheck", + "verify": "pnpm lint && pnpm build && pnpm typecheck && pnpm test && pnpm smoke:consumer", + "smoke:consumer": "node scripts/consumer-smoke.mjs", "changeset": "changeset", "release": "changeset publish", "version-packages": "changeset version", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index b40df5d..5c88466 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -20,14 +20,9 @@ "types": "./dist/index.d.ts", "exports": { ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" }, "./package.json": "./package.json" }, diff --git a/packages/sdk/test/package-exports.test.ts b/packages/sdk/test/package-exports.test.ts new file mode 100644 index 0000000..c082286 --- /dev/null +++ b/packages/sdk/test/package-exports.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +interface PackageJson { + exports?: { + '.'?: { + types?: string + import?: string + require?: string + } + } + types?: string + main?: string + module?: string +} + +describe('package exports', () => { + it('exposes root types for TypeScript bundler consumers', () => { + const packageJson = readPackageJson() + const rootExport = packageJson.exports?.['.'] + + expect(packageJson.types).toBe('./dist/index.d.ts') + expect(packageJson.main).toBe('./dist/index.cjs') + expect(packageJson.module).toBe('./dist/index.js') + expect(rootExport).toEqual({ + types: './dist/index.d.ts', + import: './dist/index.js', + require: './dist/index.cjs', + }) + }) +}) + +function readPackageJson(): PackageJson { + return JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8')) as PackageJson +} diff --git a/packages/widget/README.md b/packages/widget/README.md index 85b206d..1fa6478 100644 --- a/packages/widget/README.md +++ b/packages/widget/README.md @@ -15,6 +15,8 @@ separately only when your app imports SDK APIs directly. The widget reads the connected wallet from wagmi. Wrap your tree in `WagmiProvider` and `QueryClientProvider` (e.g. via ConnectKit or RainbowKit) before rendering it. +The root widget entry is ESM-only because the React wallet stack it composes is ESM-first. CommonJS projects can still load `@usdh-kit/widget/styles.css` and `@usdh-kit/widget/tailwind-content`, but should import the widget from an ESM module or through their app bundler. + ```tsx import { USDHSwap } from '@usdh-kit/widget' import '@usdh-kit/widget/styles.css' diff --git a/packages/widget/package.json b/packages/widget/package.json index 2aee597..0dc7d31 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -15,19 +15,12 @@ }, "keywords": ["hyperliquid", "usdh", "react", "widget", "swap", "defi"], "type": "module", - "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - } + "types": "./dist/index.d.ts", + "import": "./dist/index.js" }, "./styles.css": "./dist/styles.css", "./tailwind-content": { diff --git a/packages/widget/tailwind-content.cjs b/packages/widget/tailwind-content.cjs index 2554bfe..da22327 100644 --- a/packages/widget/tailwind-content.cjs +++ b/packages/widget/tailwind-content.cjs @@ -15,6 +15,6 @@ const path = require('node:path') * (Tailwind v3 does not deep-merge `content` arrays from presets, so this * is exposed as a plain array instead of a preset object.) */ -const widgetDir = path.dirname(require.resolve('@usdh-kit/widget')) +const widgetDir = __dirname.replaceAll(path.sep, path.posix.sep) -module.exports = [`${widgetDir}/**/*.{js,cjs,mjs}`] +module.exports = [`${widgetDir}/dist/**/*.{js,mjs}`] diff --git a/packages/widget/test/package-exports.test.ts b/packages/widget/test/package-exports.test.ts new file mode 100644 index 0000000..3689b88 --- /dev/null +++ b/packages/widget/test/package-exports.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +interface PackageJson { + exports?: { + '.'?: { + types?: string + import?: string + require?: string + } + './styles.css'?: string + './tailwind-content'?: { + types?: string + default?: string + } + } + types?: string + main?: string + module?: string +} + +describe('package exports', () => { + it('exposes an ESM widget root and CJS-safe secondary entries', () => { + const packageJson = readPackageJson() + const rootExport = packageJson.exports?.['.'] + + expect(packageJson.types).toBe('./dist/index.d.ts') + expect(packageJson.main).toBeUndefined() + expect(packageJson.module).toBe('./dist/index.js') + expect(rootExport).toEqual({ + types: './dist/index.d.ts', + import: './dist/index.js', + }) + expect(rootExport?.require).toBeUndefined() + expect(packageJson.exports?.['./styles.css']).toBe('./dist/styles.css') + expect(packageJson.exports?.['./tailwind-content']).toEqual({ + types: './tailwind-content.d.cts', + default: './tailwind-content.cjs', + }) + }) +}) + +function readPackageJson(): PackageJson { + return JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8')) as PackageJson +} diff --git a/packages/widget/tsup.config.ts b/packages/widget/tsup.config.ts index 579c9a2..d036ba0 100644 --- a/packages/widget/tsup.config.ts +++ b/packages/widget/tsup.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ entry: ['src/index.ts'], - format: ['esm', 'cjs'], + format: ['esm'], dts: true, clean: true, treeshake: 'safest', diff --git a/scripts/consumer-smoke.mjs b/scripts/consumer-smoke.mjs new file mode 100644 index 0000000..bf8c0db --- /dev/null +++ b/scripts/consumer-smoke.mjs @@ -0,0 +1,377 @@ +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const tempRoot = await mkdtemp(join(tmpdir(), 'usdh-kit-consumer-')) +const packageArchiveDir = join(tempRoot, 'packed') +const keepTemp = process.env.USDH_KEEP_CONSUMER_SMOKE === '1' + +const expectedPublishedFiles = { + '@usdh-kit/sdk': ['dist/index.js', 'dist/index.cjs', 'dist/index.d.ts', 'README.md', 'LICENSE'], + '@usdh-kit/widget': [ + 'dist/index.js', + 'dist/index.d.ts', + 'dist/styles.css', + 'tailwind-content.cjs', + 'tailwind-content.d.cts', + 'README.md', + 'LICENSE', + ], +} + +try { + await assertBuiltPackage('packages/sdk', ['dist/index.js', 'dist/index.cjs', 'dist/index.d.ts']) + await assertBuiltPackage('packages/widget', [ + 'dist/index.js', + 'dist/index.d.ts', + 'dist/styles.css', + 'tailwind-content.cjs', + 'tailwind-content.d.cts', + ]) + + const packedPackages = { + sdk: await packPackage('packages/sdk'), + widget: await packPackage('packages/widget'), + } + await writeConsumerProject(packedPackages) + runNode('esm-consumer.mjs') + runNode('cjs-consumer.cjs') + runTsc() + + process.stdout.write(`consumer smoke passed in ${relative(repoRoot, tempRoot)}\n`) +} finally { + if (keepTemp) { + process.stdout.write(`kept consumer smoke fixture at ${tempRoot}\n`) + } else { + await rm(tempRoot, { recursive: true, force: true }) + } +} + +async function assertBuiltPackage(packageDir, files) { + for (const file of files) { + const path = join(repoRoot, packageDir, file) + if (!existsSync(path)) { + throw new Error( + `Missing ${relative(repoRoot, path)}. Run package builds before consumer smoke.`, + ) + } + } +} + +async function packPackage(packageDir) { + await mkdir(packageArchiveDir, { recursive: true }) + const before = new Set(await readdir(packageArchiveDir)) + run(pnpmCommand(), ['pack', '--pack-destination', packageArchiveDir], { + cwd: join(repoRoot, packageDir), + }) + + const after = await readdir(packageArchiveDir) + const packed = after.filter((file) => file.endsWith('.tgz') && !before.has(file)) + if (packed.length !== 1) { + throw new Error( + `Expected exactly one tarball for ${packageDir}, found ${packed.length}: ${packed.join(', ')}`, + ) + } + return join(packageArchiveDir, packed[0]) +} + +async function writeConsumerProject(packedPackages) { + await mkdir(join(tempRoot, 'node_modules', '@usdh-kit'), { recursive: true }) + await installPackedPackage('@usdh-kit/sdk', packedPackages.sdk) + await installPackedPackage('@usdh-kit/widget', packedPackages.widget) + + for (const dependency of [ + 'react', + 'react-dom', + '@types/react', + '@types/react-dom', + '@tanstack/react-query', + '@noble/hashes', + 'viem', + 'wagmi', + ]) { + await linkWorkspaceDependency(dependency) + } + + await writeFile( + join(tempRoot, 'package.json'), + `${JSON.stringify( + { + private: true, + type: 'module', + dependencies: { + '@usdh-kit/sdk': `file:${relative(tempRoot, packedPackages.sdk).replaceAll('\\', '/')}`, + '@usdh-kit/widget': `file:${relative(tempRoot, packedPackages.widget).replaceAll( + '\\', + '/', + )}`, + }, + devDependencies: { + typescript: 'workspace:*', + }, + }, + null, + 2, + )}\n`, + ) + await writeFile(join(tempRoot, 'esm-consumer.mjs'), esmConsumerSource()) + await writeFile(join(tempRoot, 'cjs-consumer.cjs'), cjsConsumerSource()) + await writeFile(join(tempRoot, 'consumer.ts'), tsConsumerSource()) + await writeFile( + join(tempRoot, 'tsconfig.json'), + `${JSON.stringify( + { + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + skipLibCheck: false, + noEmit: true, + jsx: 'react-jsx', + lib: ['ES2022', 'DOM', 'DOM.Iterable'], + }, + include: ['consumer.ts'], + }, + null, + 2, + )}\n`, + ) +} + +async function installPackedPackage(name, tarballPath) { + const linkPath = join(tempRoot, 'node_modules', ...name.split('/')) + const extractRoot = join(tempRoot, 'extracted', name.replace('/', '__')) + await mkdir(dirname(linkPath), { recursive: true }) + await mkdir(extractRoot, { recursive: true }) + + run('tar', ['-xzf', tarballPath, '-C', extractRoot]) + + const extractedPackageDir = join(extractRoot, 'package') + if (!existsSync(extractedPackageDir)) { + throw new Error(`Packed ${name} did not extract to a package directory`) + } + + await cp(extractedPackageDir, linkPath, { recursive: true }) + await assertPublishedFiles(name, linkPath) + await assertNoWorkspaceProtocol(name, join(linkPath, 'package.json')) +} + +async function assertPublishedFiles(name, packageDir) { + const expectedFiles = expectedPublishedFiles[name] + if (expectedFiles === undefined) return + + for (const file of expectedFiles) { + const filePath = join(packageDir, file) + if (!existsSync(filePath)) { + throw new Error(`${name} tarball is missing ${file}`) + } + } + + for (const unpublishedDir of ['src', 'test']) { + const dirPath = join(packageDir, unpublishedDir) + if (existsSync(dirPath)) { + throw new Error(`${name} tarball unexpectedly includes ${unpublishedDir}/`) + } + } +} + +async function assertNoWorkspaceProtocol(name, packageJsonPath) { + const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) + const publishDependencyFields = ['dependencies', 'optionalDependencies', 'peerDependencies'] + for (const field of publishDependencyFields) { + const dependencies = packageJson[field] + if (!dependencies || typeof dependencies !== 'object') continue + for (const [dependencyName, version] of Object.entries(dependencies)) { + if (typeof version === 'string' && version.startsWith('workspace:')) { + throw new Error(`${name} packed ${field}.${dependencyName} still uses ${version}`) + } + } + } +} + +async function linkPackage(name, target) { + const linkPath = join(tempRoot, 'node_modules', ...name.split('/')) + await mkdir(dirname(linkPath), { recursive: true }) + await symlink(target, linkPath, symlinkType()) +} + +async function linkWorkspaceDependency(name) { + const target = resolveWorkspaceDependency(name) + if (!existsSync(target)) return + await linkPackage(name, target) +} + +function resolveWorkspaceDependency(name) { + const segments = name.split('/') + const candidateRoots = [ + repoRoot, + join(repoRoot, 'packages', 'sdk'), + join(repoRoot, 'packages', 'widget'), + join(repoRoot, 'apps', 'demo'), + ] + + for (const candidateRoot of candidateRoots) { + const target = join(candidateRoot, 'node_modules', ...segments) + if (existsSync(target)) return target + } + + return join(repoRoot, 'node_modules', ...segments) +} + +function runNode(file) { + run(process.execPath, [join(tempRoot, file)]) +} + +function runTsc() { + const tscBin = join( + repoRoot, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'tsc.cmd' : 'tsc', + ) + run(tscBin, ['-p', join(tempRoot, 'tsconfig.json')]) +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? tempRoot, + stdio: 'inherit', + shell: process.platform === 'win32' && command.endsWith('.cmd'), + }) + if (result.status !== 0) { + const failure = result.error ? `: ${result.error.message}` : '' + throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}${failure}`) + } +} + +function pnpmCommand() { + return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +} + +function symlinkType() { + return process.platform === 'win32' ? 'junction' : 'dir' +} + +function esmConsumerSource() { + return `import { createRequire } from 'node:module' +import { + createUsdhKit, + listUsdhSpotPairs, + normalizeOutcomeMeta, + outcomeCoin, +} from '@usdh-kit/sdk' +import { USDHSwap, friendlyError } from '@usdh-kit/widget' + +const require = createRequire(import.meta.url) +const cssPath = require.resolve('@usdh-kit/widget/styles.css') +const tailwindContent = require('@usdh-kit/widget/tailwind-content') + +if (typeof createUsdhKit !== 'function') throw new Error('SDK ESM kit export failed') + +const pairs = listUsdhSpotPairs({ + tokens: [ + { name: 'USDC', szDecimals: 6, weiDecimals: 8, index: 0, tokenId: '0x0', isCanonical: true, evmContract: null, fullName: null }, + { name: 'USDH', szDecimals: 6, weiDecimals: 8, index: 150, tokenId: '0x1', isCanonical: false, evmContract: null, fullName: null }, + ], + universe: [{ name: '@230', tokens: [150, 0], index: 230, isCanonical: false }], +}) +if (pairs[0]?.base !== 'USDH') throw new Error('SDK ESM discovery export failed') + +const [market] = normalizeOutcomeMeta({ + outcomes: [ + { + outcome: 20, + name: 'USDH weekly volume clears $5m', + description: 'class:volume|asset:USDH|target:5000000', + sideSpecs: [{ name: 'Yes' }, { name: 'No' }], + }, + ], +}) +if (!market) throw new Error('SDK ESM outcome metadata failed') +if (outcomeCoin(market.outcome, 0) !== '#200') throw new Error('SDK ESM outcome helper failed') +if (typeof USDHSwap !== 'function') throw new Error('Widget ESM export failed') +if (friendlyError(new Error('boom')) !== 'boom') throw new Error('Widget helper export failed') +if (!cssPath.replaceAll('\\\\', '/').endsWith('/dist/styles.css')) { + throw new Error('Widget CSS export failed') +} +if (!Array.isArray(tailwindContent) || tailwindContent.length === 0) { + throw new Error('Widget tailwind-content export failed') +} +` +} + +function cjsConsumerSource() { + return `const sdk = require('@usdh-kit/sdk') +const widgetCss = require.resolve('@usdh-kit/widget/styles.css') +const widgetContent = require('@usdh-kit/widget/tailwind-content') + +if (typeof sdk.createUsdhKit !== 'function') throw new Error('SDK CJS export failed') +try { + require('@usdh-kit/widget') + throw new Error('Widget root unexpectedly allowed CommonJS require') +} catch (error) { + if ( + error?.message === 'Widget root unexpectedly allowed CommonJS require' || + error?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED' + ) { + throw error + } +} +if (!widgetCss.replaceAll('\\\\', '/').endsWith('/dist/styles.css')) { + throw new Error('Widget CSS CJS resolve failed') +} +if (!Array.isArray(widgetContent) || widgetContent.length === 0) { + throw new Error('Widget tailwind-content CJS export failed') +} +` +} + +function tsConsumerSource() { + return `import { + createUsdhKit, + normalizeOutcomeMeta, + outcomeCoin, + type L2Book, +} from '@usdh-kit/sdk' +import { USDHSwap, type USDHSwapProps, type WidgetTheme } from '@usdh-kit/widget' +import widgetContent = require('@usdh-kit/widget/tailwind-content') + +const book: L2Book = { + coin: '@230', + time: 1778427457824, + levels: [ + [{ px: '0.9999', sz: '17000', n: 1 }], + [{ px: '1.0001', sz: '14000', n: 1 }], + ], +} + +const kitFactory: typeof createUsdhKit = createUsdhKit +const [market] = normalizeOutcomeMeta({ + outcomes: [ + { + outcome: 20, + name: 'USDH weekly volume clears $5m', + description: 'class:volume|asset:USDH|target:5000000', + sideSpecs: [{ name: 'Yes' }, { name: 'No' }], + }, + ], +}) +const coin = market ? outcomeCoin(market.outcome, 0) : '#0' +const theme: WidgetTheme = 'dark' +const props: USDHSwapProps = { network: 'mainnet', theme } +const Widget = USDHSwap +const content: string[] = widgetContent + +void book +void kitFactory +void coin +void props +void Widget +void content +` +}