From 779718e5d93d7c687b8db9bfc4b6d25f8b638081 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Thu, 30 Apr 2026 21:03:38 -0700 Subject: [PATCH 1/3] test(integration): wire up Harper v5 integration test harness Adds @harperfast/integration-testing-based integration tests that boot a real Harper child process with the OAuth plugin installed via npm pack. Mirrors the HarperFast/nextjs PR #40 pattern. First test asserts ${VAR} substitution in provider config reaches the authorization redirect's client_id. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/integration-tests.yml | 54 +++++++ .gitignore | 4 + .nvmrc | 1 + CLAUDE.md | 11 +- integrationTests/env-var-substitution.test.ts | 66 +++++++++ .../fixtures/oauth-app/config.yaml | 19 +++ .../fixtures/oauth-app/package.json | 6 + integrationTests/tsconfig.json | 15 ++ package-lock.json | 140 +++++++++++------- package.json | 7 +- scripts/install-fixtures.js | 57 +++++++ 11 files changed, 326 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/integration-tests.yml create mode 100644 .nvmrc create mode 100644 integrationTests/env-var-substitution.test.ts create mode 100644 integrationTests/fixtures/oauth-app/config.yaml create mode 100644 integrationTests/fixtures/oauth-app/package.json create mode 100644 integrationTests/tsconfig.json create mode 100644 scripts/install-fixtures.js diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..ac7c617 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,54 @@ +name: Integration Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + integration-tests: + name: Integration Tests (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + node-version: [22, 24] + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build plugin + run: npm run build + + # Linux loopback already covers 127.0.0.0/8; no alias setup needed (the + # setup-loopback.sh script targets macOS lo0 and is a no-op here). + + - name: Install fixtures + run: npm run install:fixtures + + - name: Run integration tests + run: npm run test:integration + timeout-minutes: 5 + env: + HARPER_INTEGRATION_TEST_LOG_DIR: /tmp/harper-test-logs + FORCE_COLOR: '1' + + - name: Upload Harper logs on failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: harper-logs-node-${{ matrix.node-version }} + path: /tmp/harper-test-logs + retention-days: 7 diff --git a/.gitignore b/.gitignore index 3948170..1c9a1aa 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ dist/ .env .env.* !.env.example + +# Integration test fixtures +integrationTests/fixtures/**/node_modules +integrationTests/fixtures/**/package-lock.json diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CLAUDE.md b/CLAUDE.md index a47181c..2bd4f34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,10 +110,19 @@ request.session = { ## Testing -Node.js built-in test runner (`node:test`) and Bun. Tests import from compiled `dist/`. Use `node:assert/strict`. Bun uses a preload script (`.bun/preload.js`) to mock the `harper` module. +**Unit tests** (`test/`): Node.js built-in test runner (`node:test`) and Bun. Tests import from compiled `dist/`. Use `node:assert/strict`. Bun uses a preload script (`.bun/preload.js`) to mock the `harper` module. Test scripts use `$(find test -name '*.test.js' -type f)`-style globbing for Node 20 compatibility; once Node 20 support ends, switch to `node --test test` or `node --test "test/**/*.test.js"` on Node 22+. +**Integration tests** (`integrationTests/`): Boot a real Harper child process with the plugin installed, via `@harperfast/integration-testing` (`harper-integration-test-run`). Tests are `.test.ts` (Node 22+ type stripping). Run order: + +```bash +npm run install:fixtures # npm pack + install the plugin into each fixture +npm run test:integration +``` + +The fixture install uses `npm pack` (not a `file:` symlink) because Harper's default VM sandbox rejects symlinked plugins with "Can not load module outside of allowed path". Adding a new fixture: drop a directory under `integrationTests/fixtures/` with a `config.yaml` and a `package.json` (no deps — install-fixtures injects the OAuth tarball). + ## Dependencies - **Runtime:** `jsonwebtoken`, `jwks-rsa` (required by Okta / JWT-based providers) diff --git a/integrationTests/env-var-substitution.test.ts b/integrationTests/env-var-substitution.test.ts new file mode 100644 index 0000000..e542045 --- /dev/null +++ b/integrationTests/env-var-substitution.test.ts @@ -0,0 +1,66 @@ +/** + * Verifies that ${VAR} placeholders in the OAuth plugin's provider config + * are substituted with values from process.env when the plugin loads under + * Harper v5. Boots a real Harper instance with the OAuth plugin installed + * (via npm pack into the fixture) and asserts the substituted client_id + * appears on the authorization redirect. + */ +import { suite, test, before, after } from 'node:test'; +import { strictEqual } from 'node:assert/strict'; +import { join, dirname } from 'node:path'; +import { createRequire } from 'node:module'; +import { + setupHarperWithFixture, + teardownHarper, + type ContextWithHarper, +} from '@harperfast/integration-testing'; + +const require = createRequire(import.meta.url); + +function getHarperBinPath(): string { + return join(dirname(require.resolve('harper')), 'bin', 'harper.js'); +} + +const fixturePath = join(import.meta.dirname, 'fixtures', 'oauth-app'); + +const EXPECTED_CLIENT_ID = 'integration-test-client-id'; +const EXPECTED_CLIENT_SECRET = 'integration-test-client-secret'; + +suite('OAuth plugin env-var substitution under Harper v5', (ctx: ContextWithHarper) => { + before(async () => { + await setupHarperWithFixture(ctx, fixturePath, { + harperBinPath: getHarperBinPath(), + env: { + OAUTH_TEST_CLIENT_ID: EXPECTED_CLIENT_ID, + OAUTH_TEST_CLIENT_SECRET: EXPECTED_CLIENT_SECRET, + }, + config: { + logging: { stdStreams: true }, + }, + }); + }); + + after(async () => { + await teardownHarper(ctx); + }); + + test('login redirect carries the substituted client_id', async () => { + const response = await fetch(`${ctx.harper.httpURL}/oauth/test-provider/login`, { + redirect: 'manual', + }); + strictEqual(response.status, 302, `expected 302, got ${response.status}`); + const location = response.headers.get('location'); + strictEqual(typeof location, 'string', 'Location header missing'); + const url = new URL(location!); + strictEqual( + url.origin + url.pathname, + 'http://example.test/authorize', + 'authorization URL was not constructed from configured authorizationUrl' + ); + strictEqual( + url.searchParams.get('client_id'), + EXPECTED_CLIENT_ID, + 'client_id was not substituted from ${OAUTH_TEST_CLIENT_ID}' + ); + }); +}); diff --git a/integrationTests/fixtures/oauth-app/config.yaml b/integrationTests/fixtures/oauth-app/config.yaml new file mode 100644 index 0000000..61d264e --- /dev/null +++ b/integrationTests/fixtures/oauth-app/config.yaml @@ -0,0 +1,19 @@ +# Integration test fixture: exercises ${VAR} substitution in OAuth provider config. +# +# The plugin's config.ts/expandEnvVar replaces ${VAR} placeholders with values +# from process.env. The test harness sets OAUTH_TEST_CLIENT_ID etc. on the +# Harper child process before boot; we then assert that the values reach the +# generated authorization URL. +rest: true + +'@harperfast/oauth': + package: '@harperfast/oauth' + providers: + test-provider: + provider: generic + clientId: ${OAUTH_TEST_CLIENT_ID} + clientSecret: ${OAUTH_TEST_CLIENT_SECRET} + authorizationUrl: 'http://example.test/authorize' + tokenUrl: 'http://example.test/token' + userInfoUrl: 'http://example.test/userinfo' + scope: 'openid profile email' diff --git a/integrationTests/fixtures/oauth-app/package.json b/integrationTests/fixtures/oauth-app/package.json new file mode 100644 index 0000000..ba3aaec --- /dev/null +++ b/integrationTests/fixtures/oauth-app/package.json @@ -0,0 +1,6 @@ +{ + "name": "oauth-app-fixture", + "private": true, + "type": "module", + "description": "Integration-test fixture. The @harperfast/oauth dep is installed at test setup time via scripts/install-fixtures.js (npm pack + install) so the plugin lives as real files under node_modules — required because Harper's default VM sandbox rejects symlinked file: deps with 'Can not load module outside of allowed path'." +} diff --git a/integrationTests/tsconfig.json b/integrationTests/tsconfig.json new file mode 100644 index 0000000..1ed72ef --- /dev/null +++ b/integrationTests/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true + }, + "include": ["**/*.ts"], + "exclude": ["fixtures/**/node_modules"] +} diff --git a/package-lock.json b/package-lock.json index 702dae3..cca1fb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,11 @@ }, "devDependencies": { "@harperdb/code-guidelines": "^0.0.5", + "@harperfast/integration-testing": "0.3.0", "@types/jsonwebtoken": "^9.0.5", "@types/node": "^20.11.0", "eslint": "^9.35.0", - "harper": "^5.0.0", + "harper": "5.0.7", "prettier": "^3.6.2", "typescript": "^5.3.3" }, @@ -278,7 +279,6 @@ "integrity": "sha512-c8iDFppzyhQUTTPsUWDy43mSKzQsTIi+RkY9u9fHPDiu1bUJWO/2xhuFx9j6l0+29HKqlQx8yJGe8lRF3xSw3w==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", @@ -1739,35 +1739,60 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@harperfast/integration-testing": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@harperfast/integration-testing/-/integration-testing-0.3.0.tgz", + "integrity": "sha512-q8R6k+aYtYQ7iyVuiWFJ9uB2f1OPEh4hXd07VTv12LxsmUY3XFXGuiLh2buDi36SAB4Y5++IZcF7lZQ/CIDbvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tar-fs": "3.1.2" + }, + "bin": { + "harper-integration-test-run": "dist/run.js", + "harper-integration-test-setup-loopback": "scripts/setup-loopback.sh" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "harper": "^5.0.0" + }, + "peerDependenciesMeta": { + "harper": { + "optional": false + } + } + }, "node_modules/@harperfast/rocksdb-js": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js/-/rocksdb-js-1.0.1.tgz", - "integrity": "sha512-XqSdZC8ChvqBcOjTAQafmVJaVKf4govEwcCiiqAOJ35cUCU8jcM6MnuPbx7mbQRQ39qecHpGewaDxfIM1LIJFQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js/-/rocksdb-js-1.1.1.tgz", + "integrity": "sha512-KlpkEESg7dPjaSCECFP0fOjZh36X7ckHG8gnRj3EQZHOUf9bBPS5dfu4UdtMKOnd7VfzogKiAOrhJaOPqqrJaA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@harperfast/extended-iterable": "1.0.3", - "msgpackr": "1.11.9", + "msgpackr": "1.11.10", "ordered-binary": "1.6.1" }, "engines": { "node": ">=18" }, "optionalDependencies": { - "@harperfast/rocksdb-js-darwin-arm64": "1.0.1", - "@harperfast/rocksdb-js-darwin-x64": "1.0.1", - "@harperfast/rocksdb-js-linux-arm64-glibc": "1.0.1", - "@harperfast/rocksdb-js-linux-arm64-musl": "1.0.1", - "@harperfast/rocksdb-js-linux-x64-glibc": "1.0.1", - "@harperfast/rocksdb-js-linux-x64-musl": "1.0.1", - "@harperfast/rocksdb-js-win32-arm64": "1.0.1", - "@harperfast/rocksdb-js-win32-x64": "1.0.1" + "@harperfast/rocksdb-js-darwin-arm64": "1.1.1", + "@harperfast/rocksdb-js-darwin-x64": "1.1.1", + "@harperfast/rocksdb-js-linux-arm64-glibc": "1.1.1", + "@harperfast/rocksdb-js-linux-arm64-musl": "1.1.1", + "@harperfast/rocksdb-js-linux-x64-glibc": "1.1.1", + "@harperfast/rocksdb-js-linux-x64-musl": "1.1.1", + "@harperfast/rocksdb-js-win32-arm64": "1.1.1", + "@harperfast/rocksdb-js-win32-x64": "1.1.1" } }, "node_modules/@harperfast/rocksdb-js-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-darwin-arm64/-/rocksdb-js-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-b7Tn2TZgQ23iwIv6BQHnaXUgoUSaJ7A26GW0kjhTq8ylK7YzdtbFW1pSkAIQU5e9mQIXhbo0/N+u+MLCvafGgQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-darwin-arm64/-/rocksdb-js-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-lisyo7P9Rcu/keml+w/iqxrasZbuqOqyVYfdFMnO5YRNbMpMRufVrYw4VJURx41KQ1P2odAUqHUwPS2TcBMI0Q==", "cpu": [ "arm64" ], @@ -1782,9 +1807,9 @@ } }, "node_modules/@harperfast/rocksdb-js-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-darwin-x64/-/rocksdb-js-darwin-x64-1.0.1.tgz", - "integrity": "sha512-VswnRKHvvbgheb/PNy4ZtfNdLvqQ0eUPwY5jxvbgMZy+yD030FO84IbOvBaKl7Z8u2GpI2p9wG9/qwLtmz8XaQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-darwin-x64/-/rocksdb-js-darwin-x64-1.1.1.tgz", + "integrity": "sha512-sCS+xo97bjK3JClcwVybGcQLh3Qxp30onSSL67NHYsMvQRyW2Fft3NYPq9YWEqRyhhXnsr2F9biQj6/dNnRelA==", "cpu": [ "x64" ], @@ -1799,13 +1824,16 @@ } }, "node_modules/@harperfast/rocksdb-js-linux-arm64-glibc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-arm64-glibc/-/rocksdb-js-linux-arm64-glibc-1.0.1.tgz", - "integrity": "sha512-rKCGOpdrOVqCOahWAGk+v3aCRNV0acUwJ+EBnEXMlsiMubpFt0wxQg+EWIhWaOyZQoQ/omCSXzRLXBS/G0L6HA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-arm64-glibc/-/rocksdb-js-linux-arm64-glibc-1.1.1.tgz", + "integrity": "sha512-koyTqfdtjmaT+D89AzzuqwEMGTxV4cysJjsMdPIm+5Bbv7+pg19thY4jtO60wJx6lsq7bu7CJ+/9iLTsPge09A==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1816,13 +1844,16 @@ } }, "node_modules/@harperfast/rocksdb-js-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-arm64-musl/-/rocksdb-js-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-ylJruv9WBvT4CACvXlLW610O53z3d2otfpnRSOcGBQVzZsGbd9n1L1cEZbAiiyJPBCFk5tBCueU6n7w6Mnh4Qg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-arm64-musl/-/rocksdb-js-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-HnWUhjO5MZ/FBSxJkuG/9V1ihjFIpy7bYPvKLVNzRNmWj+oJEZC8BVVyf+HkDweqPNttTmAA6poM/K/U+S8mgA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1833,13 +1864,16 @@ } }, "node_modules/@harperfast/rocksdb-js-linux-x64-glibc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-x64-glibc/-/rocksdb-js-linux-x64-glibc-1.0.1.tgz", - "integrity": "sha512-bx3q7VYPWyq1HhhhHIEqEMPIRR1DHn0VEuOmyrHgxaaRhpk21TECSgQDGe1DZYmppJfvj6IRI5pdfQjJL/pZNw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-x64-glibc/-/rocksdb-js-linux-x64-glibc-1.1.1.tgz", + "integrity": "sha512-Yws3BMQl1ZpfZVQLXwuylx/VLCzYxnIhg8u8upvnLERf5pVGsYs5ES98IFbnFyipFS0nIHwszQIKGAugnIpNsA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1850,13 +1884,16 @@ } }, "node_modules/@harperfast/rocksdb-js-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-x64-musl/-/rocksdb-js-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-nwoCS1/k7a43NmrKk1bPOu5V/lkENVs6fXGh4y0mgQfjcQ+csJhzXnlFQaE4RTSD0DLWuob7EoBaA0ipF5xLJQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-linux-x64-musl/-/rocksdb-js-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-0kRhi5xOtkZd6bzrZ4BwrPuL7Pj7UWqOEoWdC45B9HaQo5PJiV/7oev5pw6cyv4k4vLA5Gd5kV18QIIbKEKoSw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1867,9 +1904,9 @@ } }, "node_modules/@harperfast/rocksdb-js-win32-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-win32-arm64/-/rocksdb-js-win32-arm64-1.0.1.tgz", - "integrity": "sha512-h7VYXXa2pGgyidCgfRdJIGxcAmcJVtO5lQjxO3WH5qpknm7+D0arZg8ebDyicgqZkb+uN/zi0blIi6z1bc6f6Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-win32-arm64/-/rocksdb-js-win32-arm64-1.1.1.tgz", + "integrity": "sha512-ODgYMsjfJ23oCGyj/0XW8OX1D48FLcnuZ+g+5hxi/6od2a/ycX/AEfECRkX2+jRsYMPJwk2lrrmwdD78pZXA1g==", "cpu": [ "arm64" ], @@ -1884,9 +1921,9 @@ } }, "node_modules/@harperfast/rocksdb-js-win32-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-win32-x64/-/rocksdb-js-win32-x64-1.0.1.tgz", - "integrity": "sha512-Ikxl3CMsRutsfpaGX2JU9LLPH6tUcmMD5id5gzaNj2LE0O7ApJYX/ROqe21B6x8vDfxRk77qb8fcmvPHojNQXQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@harperfast/rocksdb-js-win32-x64/-/rocksdb-js-win32-x64-1.1.1.tgz", + "integrity": "sha512-mRojPy4rczwMFIlPqRjGAxGHjjQ0/b8O4o49lGdQ7oIMo9Jguqbg9bDMoReMAjM5HQccfb5HbeF4rx0cy6SuQg==", "cpu": [ "x64" ], @@ -1900,6 +1937,16 @@ "node": ">=18" } }, + "node_modules/@harperfast/rocksdb-js/node_modules/msgpackr": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", + "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -3423,7 +3470,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3464,7 +3510,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3971,7 +4016,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -4710,7 +4754,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4771,7 +4814,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5642,7 +5684,6 @@ "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -5705,9 +5746,9 @@ } }, "node_modules/harper": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/harper/-/harper-5.0.2.tgz", - "integrity": "sha512-mpRtW8Xm6pMdY3s6AV9sjFsYtZxCB5qyKgTTvIXaZXXeFVukUV2UHsXGOkgLws55zl/uaMbj7en0fXCDYg9pKA==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/harper/-/harper-5.0.7.tgz", + "integrity": "sha512-/NfkupUlVptzjQg3kbonlLmKMtg6d4AvO70mRsYAOxcn8FqJJfnHGeEHhF8D7kF3/q9AAfb/MdKDaeXE82l7rQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5720,7 +5761,7 @@ "@fastify/cors": "^11.2.0", "@fastify/static": "^9.0.0", "@harperfast/extended-iterable": "^1.0.1", - "@harperfast/rocksdb-js": "^1.0.1", + "@harperfast/rocksdb-js": "^1.1.0", "@turf/area": "6.5.0", "@turf/boolean-contains": "6.5.0", "@turf/boolean-disjoint": "6.5.0", @@ -7835,7 +7876,6 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9002,7 +9042,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9055,7 +9094,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, diff --git a/package.json b/package.json index 955500c..4f1deb6 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,9 @@ "test:watch": "npm run build && node --test --watch \"test/**/*.test.js\"", "test:coverage": "npm run build && node --enable-source-maps --test --experimental-test-coverage --test-coverage-exclude='test/**' --test-coverage-exclude='**/harper/**' \"test/**/*.test.js\"", "test:node-twenty": "npm run build && node --test test/", - "lint": "eslint . --ignore-pattern 'dist/**'", + "test:integration": "harper-integration-test-run \"integrationTests/**/*.test.ts\"", + "install:fixtures": "node scripts/install-fixtures.js", + "lint": "eslint . --ignore-pattern 'dist/**' --ignore-pattern 'integrationTests/fixtures/**'", "format": "prettier .", "format:check": "npm run format -- --check", "format:write": "npm run format -- --write", @@ -61,10 +63,11 @@ }, "devDependencies": { "@harperdb/code-guidelines": "^0.0.5", + "@harperfast/integration-testing": "0.3.0", "@types/jsonwebtoken": "^9.0.5", "@types/node": "^20.11.0", "eslint": "^9.35.0", - "harper": "^5.0.0", + "harper": "5.0.7", "prettier": "^3.6.2", "typescript": "^5.3.3" }, diff --git a/scripts/install-fixtures.js b/scripts/install-fixtures.js new file mode 100644 index 0000000..4080e26 --- /dev/null +++ b/scripts/install-fixtures.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * Installs each fixture under integrationTests/fixtures/ with a real + * (non-symlinked) copy of the @harperfast/oauth plugin so Harper's default + * VM sandbox accepts the dist/ files at the fixture's allowed path. + * + * Pipeline: build the plugin, npm pack it into a tarball, then `npm install` + * the tarball into each fixture. Avoids the file:../../../ symlink pattern, + * which Harper rejects with "Can not load module outside of allowed path". + */ +import { mkdtempSync, rmSync, readdirSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const fixturesDir = join(repoRoot, 'integrationTests', 'fixtures'); + +function run(cmd, args, cwd) { + const result = spawnSync(cmd, args, { cwd, stdio: 'inherit' }); + if (result.status !== 0) { + throw new Error(`${cmd} ${args.join(' ')} failed in ${cwd} (exit ${result.status})`); + } +} + +console.log('Building plugin...'); +run('npm', ['run', 'build'], repoRoot); + +const packDir = mkdtempSync(join(tmpdir(), 'oauth-fixture-pack-')); +try { + console.log(`Packing plugin into ${packDir}...`); + const packResult = spawnSync('npm', ['pack', '--pack-destination', packDir, '--json'], { + cwd: repoRoot, + encoding: 'utf8', + }); + if (packResult.status !== 0) { + console.error(packResult.stderr); + throw new Error(`npm pack failed (exit ${packResult.status})`); + } + const tarballPath = join(packDir, JSON.parse(packResult.stdout)[0].filename); + + const fixtures = readdirSync(fixturesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('_')) + .map((entry) => entry.name); + + for (const fixture of fixtures) { + console.log(`Installing ${fixture} dependencies...`); + const fixturePath = join(fixturesDir, fixture); + rmSync(join(fixturePath, 'node_modules'), { recursive: true, force: true }); + rmSync(join(fixturePath, 'package-lock.json'), { force: true }); + run('npm', ['install', '--no-save', tarballPath], fixturePath); + console.log(''); + } +} finally { + rmSync(packDir, { recursive: true, force: true }); +} From c9c99b72a63e27ca178f700ef1571289e3373f26 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Thu, 30 Apr 2026 22:33:57 -0700 Subject: [PATCH 2/3] test: mock harper for Node unit tests, drop Node 20, fix CI fallout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin harper to 5.0.7 (was ^5.0.0 → 5.0.2 in lockfile). Importing real harper 5.0.7 eagerly opens the system RocksDB (regressed in dist/resources/databases.js where `RocksDatabase.open(store)` became `new RocksIndexStore().open()` — opens synchronously). Under unit-test parallelism the eager open contends for the LOCK and surfaces a "Cannot read properties of undefined (reading 'localhost')" async rejection that Node's test runner reports as a flaky failure. Fix: mirror the existing Bun preload mock for Node — add test/helpers/harper-mock.mjs (a `module.register` ESM loader) and wire it into every unit-test script via `--import`. Integration tests do not load it; they need the real harper module. Also: - Drop Node 20 (out of LTS): remove from PR Checks matrix, drop the test:node-twenty script, bump engines.node to >=22, bump @types/node to ^22. - bunfig.toml: scope `bun test` to ./test so it doesn't try to run the integration tests under the bun runner. - .prettierignore: exclude .claude/ and integrationTests/fixtures/**/ node_modules/. - integrationTests/env-var-substitution.test.ts: prettier-formatted. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pr-checks.yml | 10 +--- .prettierignore | 2 + CLAUDE.md | 5 +- bunfig.toml | 3 ++ integrationTests/env-var-substitution.test.ts | 6 +-- package-lock.json | 10 ++-- package.json | 13 +++-- test/helpers/harper-mock.mjs | 50 +++++++++++++++++++ 8 files changed, 72 insertions(+), 27 deletions(-) create mode 100644 test/helpers/harper-mock.mjs diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 8ca8955..b424635 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - node-version: [20, 22, 24] + node-version: [22, 24] steps: - name: Checkout code @@ -34,15 +34,9 @@ jobs: - name: Check formatting run: npm run format:check - # Coverage was failing to run on Node 20, so run it only on 22+ - - name: Run tests with coverage (Node 22+) - if: matrix.node-version >= 22 + - name: Run tests with coverage run: npm run test:coverage - - name: Run tests (Node 20) - if: matrix.node-version == 20 - run: npm run test:node-twenty - test-bun: name: Test with Bun runs-on: ubuntu-latest diff --git a/.prettierignore b/.prettierignore index 1eae0cf..049f7a9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,4 @@ dist/ node_modules/ +.claude/ +integrationTests/fixtures/**/node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md index 2bd4f34..d587dad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,9 +110,10 @@ request.session = { ## Testing -**Unit tests** (`test/`): Node.js built-in test runner (`node:test`) and Bun. Tests import from compiled `dist/`. Use `node:assert/strict`. Bun uses a preload script (`.bun/preload.js`) to mock the `harper` module. +**Unit tests** (`test/`): Node.js built-in test runner (`node:test`) and Bun. Tests import from compiled `dist/`. Use `node:assert/strict`. Both runners mock the `harper` module to keep unit tests off the real RocksDB: -Test scripts use `$(find test -name '*.test.js' -type f)`-style globbing for Node 20 compatibility; once Node 20 support ends, switch to `node --test test` or `node --test "test/**/*.test.js"` on Node 22+. +- Bun: `.bun/preload.js` (`mock.module`). +- Node: `test/helpers/harper-mock.mjs` registered via `--import` in the npm test scripts. Required because importing real `harper` (5.0.7+) eagerly opens the system RocksDB — fine when running Harper, but in unit tests it surfaces async lock-contention errors after tests finish. Remove this once harper provides an opt-in deferred init. **Integration tests** (`integrationTests/`): Boot a real Harper child process with the plugin installed, via `@harperfast/integration-testing` (`harper-integration-test-run`). Tests are `.test.ts` (Node 22+ type stripping). Run order: diff --git a/bunfig.toml b/bunfig.toml index 14a58a1..92e23f8 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,5 @@ [test] preload = ["./.bun/preload.js"] +# integrationTests/ uses @harperfast/integration-testing's harper-integration-test-run +# (Node test runner only); they are not unit tests and should not run under `bun test`. +root = "./test" diff --git a/integrationTests/env-var-substitution.test.ts b/integrationTests/env-var-substitution.test.ts index e542045..a392fa7 100644 --- a/integrationTests/env-var-substitution.test.ts +++ b/integrationTests/env-var-substitution.test.ts @@ -9,11 +9,7 @@ import { suite, test, before, after } from 'node:test'; import { strictEqual } from 'node:assert/strict'; import { join, dirname } from 'node:path'; import { createRequire } from 'node:module'; -import { - setupHarperWithFixture, - teardownHarper, - type ContextWithHarper, -} from '@harperfast/integration-testing'; +import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing'; const require = createRequire(import.meta.url); diff --git a/package-lock.json b/package-lock.json index cca1fb3..4c54099 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@harperdb/code-guidelines": "^0.0.5", "@harperfast/integration-testing": "0.3.0", "@types/jsonwebtoken": "^9.0.5", - "@types/node": "^20.11.0", + "@types/node": "^22.0.0", "eslint": "^9.35.0", "harper": "5.0.7", "prettier": "^3.6.2", @@ -24,7 +24,7 @@ }, "engines": { "bun": ">=1.0", - "node": ">=20" + "node": ">=22" }, "peerDependencies": { "harper": ">=5.0.0" @@ -3466,9 +3466,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.39", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", - "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" diff --git a/package.json b/package.json index 4f1deb6..04200f9 100644 --- a/package.json +++ b/package.json @@ -45,10 +45,9 @@ "scripts": { "build": "tsc || true", "dev": "tsc --watch", - "test": "npm run build && node --test \"test/**/*.test.js\"", - "test:watch": "npm run build && node --test --watch \"test/**/*.test.js\"", - "test:coverage": "npm run build && node --enable-source-maps --test --experimental-test-coverage --test-coverage-exclude='test/**' --test-coverage-exclude='**/harper/**' \"test/**/*.test.js\"", - "test:node-twenty": "npm run build && node --test test/", + "test": "npm run build && node --import ./test/helpers/harper-mock.mjs --test \"test/**/*.test.js\"", + "test:watch": "npm run build && node --import ./test/helpers/harper-mock.mjs --test --watch \"test/**/*.test.js\"", + "test:coverage": "npm run build && node --import ./test/helpers/harper-mock.mjs --enable-source-maps --test --experimental-test-coverage --test-coverage-exclude='test/**' --test-coverage-exclude='**/harper/**' \"test/**/*.test.js\"", "test:integration": "harper-integration-test-run \"integrationTests/**/*.test.ts\"", "install:fixtures": "node scripts/install-fixtures.js", "lint": "eslint . --ignore-pattern 'dist/**' --ignore-pattern 'integrationTests/fixtures/**'", @@ -65,7 +64,7 @@ "@harperdb/code-guidelines": "^0.0.5", "@harperfast/integration-testing": "0.3.0", "@types/jsonwebtoken": "^9.0.5", - "@types/node": "^20.11.0", + "@types/node": "^22.0.0", "eslint": "^9.35.0", "harper": "5.0.7", "prettier": "^3.6.2", @@ -75,13 +74,13 @@ "harper": ">=5.0.0" }, "engines": { - "node": ">=20", + "node": ">=22", "bun": ">=1.0" }, "devEngines": { "runtime": { "name": "node", - "version": ">=20", + "version": ">=22", "onFail": "error" }, "packageManager": { diff --git a/test/helpers/harper-mock.mjs b/test/helpers/harper-mock.mjs new file mode 100644 index 0000000..032caba --- /dev/null +++ b/test/helpers/harper-mock.mjs @@ -0,0 +1,50 @@ +/** + * Node ESM loader that mocks the `harper` module for unit tests. + * + * Mirrors the Bun preload (../../.bun/preload.js) so Node and Bun see the + * same Resource shape under unit tests. Importing the real `harper` package + * eagerly opens RocksDB (regressed in harper 5.0.7's `dist/resources/ + * databases.js` line 113 — open() became an instance method that opens + * synchronously). With multiple test-file subprocesses or any other process + * holding the system DB lock, the eager open fails and surfaces an async + * "Cannot read properties of undefined (reading 'localhost')" rejection that + * the Node test runner reports as a flaky test failure. + * + * Wired in via `--import ./test/helpers/harper-mock.mjs` in npm test + * scripts. Removing this once harper provides a lazy / opt-in DB init + * mode is the right long-term fix. + */ +import { register } from 'node:module'; + +const HARPER_MOCK_SOURCE = ` +export class Resource { + static loadAsInstance = false; + _context = null; + getContext() { return this._context; } + setContext(c) { this._context = c; } +} +export class RequestTarget {} +`; + +const HARPER_MOCK_URL = 'harper-mock:harper'; + +const loaderSource = ` +const HARPER_MOCK_URL = ${JSON.stringify(HARPER_MOCK_URL)}; +const HARPER_MOCK_SOURCE = ${JSON.stringify(HARPER_MOCK_SOURCE)}; + +export async function resolve(specifier, context, nextResolve) { + if (specifier === 'harper') { + return { url: HARPER_MOCK_URL, format: 'module', shortCircuit: true }; + } + return nextResolve(specifier, context); +} + +export async function load(url, context, nextLoad) { + if (url === HARPER_MOCK_URL) { + return { format: 'module', source: HARPER_MOCK_SOURCE, shortCircuit: true }; + } + return nextLoad(url, context); +} +`; + +register(`data:text/javascript,${encodeURIComponent(loaderSource)}`, import.meta.url); From 10ef781269b45c8e43c50828c64113c8c89a800b Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Tue, 5 May 2026 08:22:04 -0700 Subject: [PATCH 3/3] docs: correct framing of harper-mock workaround MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5.0.7-regression framing in the mock's docblock and CLAUDE.md was wrong (per harper team feedback): RocksDB simply doesn't support multi-process read-write access to the same database. The 5.0.2 → 5.0.7 change in databases.js only changed how the contention surfaces, not whether it happens. Reframe accordingly and point at Chris's in-flight read-only RocksDB mode as the path to dropping this mock. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- test/helpers/harper-mock.mjs | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d587dad..41e68e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ request.session = { **Unit tests** (`test/`): Node.js built-in test runner (`node:test`) and Bun. Tests import from compiled `dist/`. Use `node:assert/strict`. Both runners mock the `harper` module to keep unit tests off the real RocksDB: - Bun: `.bun/preload.js` (`mock.module`). -- Node: `test/helpers/harper-mock.mjs` registered via `--import` in the npm test scripts. Required because importing real `harper` (5.0.7+) eagerly opens the system RocksDB — fine when running Harper, but in unit tests it surfaces async lock-contention errors after tests finish. Remove this once harper provides an opt-in deferred init. +- Node: `test/helpers/harper-mock.mjs` registered via `--import` in the npm test scripts. Importing real `harper` opens the system RocksDB at module-load time, and RocksDB doesn't allow multi-process read-write access — so `node --test`'s per-file subprocesses contend for the LOCK and surface flaky errors. Drop this mock once harper exposes a read-only RocksDB mode (in flight); RocksDB does support multi-process read-only access. **Integration tests** (`integrationTests/`): Boot a real Harper child process with the plugin installed, via `@harperfast/integration-testing` (`harper-integration-test-run`). Tests are `.test.ts` (Node 22+ type stripping). Run order: diff --git a/test/helpers/harper-mock.mjs b/test/helpers/harper-mock.mjs index 032caba..2f9677e 100644 --- a/test/helpers/harper-mock.mjs +++ b/test/helpers/harper-mock.mjs @@ -3,16 +3,19 @@ * * Mirrors the Bun preload (../../.bun/preload.js) so Node and Bun see the * same Resource shape under unit tests. Importing the real `harper` package - * eagerly opens RocksDB (regressed in harper 5.0.7's `dist/resources/ - * databases.js` line 113 — open() became an instance method that opens - * synchronously). With multiple test-file subprocesses or any other process - * holding the system DB lock, the eager open fails and surfaces an async - * "Cannot read properties of undefined (reading 'localhost')" rejection that - * the Node test runner reports as a flaky test failure. + * opens the system RocksDB at module-load time, and RocksDB does not + * support multiple processes accessing the same database read-write — so + * `node --test` running test files as parallel subprocesses contends for + * the LOCK file, surfacing as a flaky `IO error: ... Resource temporarily + * unavailable opening database` (sometimes via a downstream + * `TypeError: Cannot read properties of undefined (reading 'localhost')` + * unhandled rejection that the test runner reports as a failed test). * * Wired in via `--import ./test/helpers/harper-mock.mjs` in npm test - * scripts. Removing this once harper provides a lazy / opt-in DB init - * mode is the right long-term fix. + * scripts. Once harper exposes a read-only RocksDB mode (in flight) this + * mock can be dropped — RocksDB *does* support multi-process access in + * read-only mode, so plugin unit tests could open the real DB read-only + * without contention. */ import { register } from 'node:module';