diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0976e48 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + +jobs: + node: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22, 24] + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + registry-url: 'https://registry.npmjs.org' + cache: 'npm' + + - name: Install deps + run: npm ci + + - name: Build + run: npm run build + + - name: Run Node.js tests + run: npm test + + bun: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install deps + run: npm ci + + - name: Build + run: bun run build + + - name: Run Bun tests + run: bun test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8be3996..4c95eff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,7 @@ jobs: with: node-version: '22.x' registry-url: 'https://registry.npmjs.org' + cache: 'npm' - name: Ensure package version is 3.x.x run: | @@ -28,7 +29,8 @@ jobs: 3.*) echo "OK: version is 3.x.x" ;; *) echo "ERROR: package.json version must be 3.x.x for v3 tags" && exit 1 ;; esac + - name: Publish to NPM with dist-tag "latest" run: npm run prepack && npm publish --tag latest --//registry.npmjs.org/:_authToken="$NPM_TOKEN" env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 76588e4..47b1694 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules *js +!test/fixtures/**/*.mjs +!test/scripts/**/*.mjs cjs esm .idea diff --git a/package.json b/package.json index 5b1d0c2..27666cd 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "A library for integrating with Chargebee.", "scripts": { "prepack": "npm install && npm run build", - "test": "mocha -r ts-node/register 'test/**/*.test.ts'", + "test": "TS_NODE_PROJECT=./tsconfig.test.json mocha -r ts-node/register 'test/**/*.test.ts'", "build": "npm run build-esm && npm run build-cjs", "build-esm": "rm -rf esm && mkdir -p esm && tsc -p tsconfig.esm.json && echo '{\"type\":\"module\"}' > esm/package.json", "build-cjs": "rm -rf cjs && mkdir -p cjs && tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > cjs/package.json", diff --git a/test/fixtures/load-esm-worker-bundle.mjs b/test/fixtures/load-esm-worker-bundle.mjs new file mode 100644 index 0000000..e5ecac9 --- /dev/null +++ b/test/fixtures/load-esm-worker-bundle.mjs @@ -0,0 +1,36 @@ +/** + * Runs inside a Node worker thread ({ type: 'module' }) to ensure the ESM + * worker bundle parses and exposes webhook exports in an isolated context. + */ +import { parentPort, workerData } from 'node:worker_threads'; +import { pathToFileURL } from 'node:url'; +import path from 'node:path'; + +const required = [ + 'default', + 'WebhookEventType', + 'WebhookContentType', + 'basicAuthValidator', + 'WebhookError', + 'WebhookAuthenticationError', + 'WebhookPayloadValidationError', + 'WebhookPayloadParseError', +]; + +try { + const root = workerData.root; + const bundlePath = path.join(root, 'esm/chargebee.esm.worker.js'); + const mod = await import(pathToFileURL(bundlePath).href); + const missing = required.filter((k) => mod[k] === undefined); + if (missing.length) { + parentPort.postMessage({ ok: false, error: 'missing exports', missing }); + } else { + parentPort.postMessage({ ok: true }); + } +} catch (err) { + parentPort.postMessage({ + ok: false, + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); +} diff --git a/test/worker-bundle.test.ts b/test/worker-bundle.test.ts new file mode 100644 index 0000000..862b9fb --- /dev/null +++ b/test/worker-bundle.test.ts @@ -0,0 +1,116 @@ +import { expect } from 'chai'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { Worker } from 'node:worker_threads'; + +/** `type` is supported at runtime for ESM workers; widen options until typings always include it. */ +type WorkerThreadOptions = NonNullable[1]> & { + type?: 'module' | 'classic'; +}; + +const testDir = path.resolve(process.cwd(), 'test'); +const repoRoot = path.join(testDir, '..'); +const esmWorkerPath = path.join(repoRoot, 'esm/chargebee.esm.worker.js'); + +const hasBuiltEsmWorker = fs.existsSync(esmWorkerPath); + +function describeIfEsmBuilt(title: string, fn: () => void): void { + if (hasBuiltEsmWorker) { + describe(title, fn); + return; + } + describe.skip( + `${title} (skipped: run \`npm run build\` or \`npm run test:worker-bundle\`)`, + fn, + ); +} + +const REQUIRED_VALUE_EXPORTS = [ + 'WebhookEventType', + 'WebhookContentType', + 'basicAuthValidator', + 'WebhookError', + 'WebhookAuthenticationError', + 'WebhookPayloadValidationError', + 'WebhookPayloadParseError', +] as const; + +function assertWebhookExports(mod: Record, label: string): void { + for (const name of REQUIRED_VALUE_EXPORTS) { + expect(mod, `${label} must export ${name}`).to.have.property(name); + expect(mod[name], `${label}.${name}`).to.not.equal(undefined); + } + + expect(mod.default, `${label} default export`).to.be.a('function'); + expect(mod.WebhookEventType, `${label}.WebhookEventType`).to.be.an('object'); + expect(mod.WebhookContentType, `${label}.WebhookContentType`).to.equal(mod.WebhookEventType); + expect(mod.basicAuthValidator, `${label}.basicAuthValidator`).to.be.a('function'); + + const Err = mod.WebhookError as typeof Error; + expect(new Err('x')).to.be.instanceof(Error); + expect((new Err('x') as Error).name).to.equal('WebhookError'); + + const AuthErr = mod.WebhookAuthenticationError as typeof Error; + expect(new AuthErr('a')).to.be.instanceof(Err); + + const ValErr = mod.WebhookPayloadValidationError as typeof Error; + expect(new ValErr('v')).to.be.instanceof(Err); + + const ParseErr = mod.WebhookPayloadParseError as typeof Error; + expect(new ParseErr('p')).to.be.instanceof(Err); +} + +describeIfEsmBuilt('Worker entry bundle (ESM)', () => { + it('exposes webhook value exports from built ESM worker entry', async function () { + try { + const mod = (await import(pathToFileURL(esmWorkerPath).href)) as Record; + assertWebhookExports(mod, 'esm/chargebee.esm.worker.js'); + } catch (error) { + const mod = require(esmWorkerPath); + assertWebhookExports(mod, 'esm/chargebee.esm.worker.js'); + } + }); +}); + +describeIfEsmBuilt('Worker thread can load ESM worker bundle', () => { + it('parses the bundle and receives all webhook exports inside a worker', function (done) { + const fixture = path.join(testDir, 'fixtures', 'load-esm-worker-bundle.mjs'); + const workerOptions: WorkerThreadOptions = { + workerData: { root: repoRoot }, + type: 'module', + }; + const worker = new Worker(fixture, workerOptions); + let settled = false; + const finish = (err?: Error): void => { + if (settled) { + return; + } + settled = true; + void worker.terminate().finally(() => { + if (err) { + done(err); + } else { + done(); + } + }); + }; + worker.on('message', (msg: { ok: boolean; error?: string; missing?: string[]; stack?: string }) => { + try { + expect(msg.ok, JSON.stringify(msg)).to.be.true; + finish(); + } catch (e) { + finish(e as Error); + } + }); + worker.on('error', finish); + worker.on('exit', (code) => { + if (settled) { + return; + } + if (code !== 0) { + finish(new Error(`worker exited with code ${code}`)); + } + }); + }); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..0fdfe8d --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "module": "ES2022", + "moduleResolution": "node", + "target": "ES2022", + "types": ["node", "mocha"], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"], + "ts-node": { + "experimentalResolver": true, + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "node" + } + } +}