diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index b75a1c45..c8f890ef 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -43,7 +43,7 @@ hunks. Apply each focus area to the diff: 1. **Conventions compliance** — does the code follow `docs/CONVENTIONS.md`? Check type colocation, - naming, comment discipline, error handling via `tryCatch`, env-var access via `Bun.env.*`, + naming, comment discipline, error handling via `tryCatch`, env-var access via `process.env.*`, function organization, and code complexity. 2. **TypeScript correctness** — strict flags are on (`noImplicitReturns`, `noUncheckedIndexedAccess`). Flag `any`, unsafe casts, missing return types on exported @@ -57,9 +57,10 @@ Apply each focus area to the diff: `suggestion` if a close match could be adapted. If the new code is general-purpose and not tied to a specific bot's domain, suggest hoisting it into the appropriate shared package (`@repo/utils` for pure utilities, `@repo/abis` for ABI-adjacent helpers). -5. **Testing quality** — new behavior has a `{module}.test.ts` under the workspace's `test/` tree mirroring `src/`. Tests use `bun test`. - Tests are non-vacuous (they actually fail if the implementation breaks). No mocking of - on-chain behavior where a viem test client / anvil would give real evidence. +5. **Testing quality** — new TypeScript behavior has a `{module}.test.ts` under the workspace's + `test/` tree mirroring `src/` and uses Vitest. The playground's JavaScript harness uses Node + `*.test.mjs` suites. Tests are non-vacuous (they actually fail if the implementation breaks). No + mocking of on-chain behavior where a viem test client / anvil would give real evidence. 6. **Agent infrastructure** — if the diff touches `CLAUDE.md`, `.claude/`, `.mcp.json`, editor configs, or agent definitions, mirror the change across any documented counterpart (`AGENTS.md`, `.cursorrules`) and flag inconsistencies. diff --git a/.claude/commands/babysit-pr.md b/.claude/commands/babysit-pr.md index 82239eaa..9605dd27 100644 --- a/.claude/commands/babysit-pr.md +++ b/.claude/commands/babysit-pr.md @@ -440,7 +440,7 @@ After all comments are addressed, run validation on affected packages: ```bash pnpm --filter run typecheck pnpm lint -bun test +pnpm test pnpm format ``` diff --git a/.claude/commands/review.md b/.claude/commands/review.md index 8761e668..17bc14ef 100644 --- a/.claude/commands/review.md +++ b/.claude/commands/review.md @@ -58,7 +58,7 @@ Review the provided PR thoroughly and interactively guide the user through each destructured object; omit inferable type annotations; helper-before-main ordering. - **Error Handling**: explicit handling, typed errors, structured logs with bot/operation/inputs context, `tryCatch` from `@repo/utils` for promise throws. - - **Environment Variables**: direct `Bun.env.VARIABLE_NAME` access; fail loudly at startup if a + - **Environment Variables**: direct `process.env.VARIABLE_NAME` access; fail loudly at startup if a required var is missing; never commit secrets. - **Performance Considerations**: batch on-chain reads (`readDeploylessBatchLens` for Lens-shaped data, `multicall` for heterogeneous reads); use explicit block tags for deterministic @@ -116,7 +116,7 @@ Review the provided PR thoroughly and interactively guide the user through each 5. **Use Context7 MCP**: When reviewing implementation details, use the Context7 MCP tools to verify against official documentation for: - viem (contract interactions, encoding, decoding, transports) - - pnpm (workspaces, catalog, lockfile semantics) and bun (test runner, runtime) + - pnpm (workspaces, catalog, lockfile semantics), vitest, esbuild bundles on Node 6. **TIB Consideration**: Check if the PR introduces changes that warrant a Technical Intent Brief (TIB) (see `docs/GUIDANCE.md`). Flag as "Minor" severity if the PR: @@ -194,7 +194,7 @@ Review the provided PR thoroughly and interactively guide the user through each `--sandbox danger-full-access`, `git push --force`)? Flag any escalation of tool access or sandbox permissions. - **Evaluation completeness**: For review-type commands or checklists, are there gaps in - coverage given the codebase's stack (viem, multi-chain, pnpm, bun, oxlint)? + coverage given the codebase's stack (viem, multi-chain, pnpm, vitest, esbuild, Node, oxlint)? - **Cross-reference accuracy**: If the file references other files (TIBs, CONVENTIONS.md sections, other commands), verify those references are valid and up to date. diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index a94c9966..865908de 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -1,5 +1,5 @@ name: 'Setup' -description: 'Sets up the repository (pnpm + Node + Bun) and builds dist-emitting workspace packages' +description: 'Sets up the repository (pnpm + Node) and builds dist-emitting workspace packages' inputs: install: description: 'Whether to install dependencies' @@ -34,12 +34,6 @@ runs: with: node-version-file: .nvmrc - # bun remains the runtime and the test runner; pnpm only manages dependencies and tasks. - - name: Set up Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.3.12 - - name: Install dependencies if: ${{ inputs.install == 'true' }} shell: bash diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 7434207e..80444f50 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -52,14 +52,14 @@ jobs: run: forge test --root packages/contracts -vv - name: Run unit tests - run: bun test + run: pnpm test env: # Base (chain 8453) archive RPC for the midnight-liquidation fork suite, which forks at a # pinned block and fails loud if this is unset. See test/fork/harness.ts. RPC_URL_8453: ${{ secrets.RPC_URL_8453 }} - name: Run browser smoke tests - run: bun test:browser + run: pnpm --filter @morpho-org/market-making-bot run playground:smoke:test Dead-Code: runs-on: ubuntu-latest diff --git a/.github/workflows/deploy-market-making-playground.yml b/.github/workflows/deploy-market-making-playground.yml index b552966e..a900517e 100644 --- a/.github/workflows/deploy-market-making-playground.yml +++ b/.github/workflows/deploy-market-making-playground.yml @@ -13,9 +13,11 @@ on: - packages/utils/** - packages/typescript-config/** - package.json - - bun.lock - - bunfig.toml + - pnpm-lock.yaml + - pnpm-workspace.yaml - .npmrc + - .nvmrc + - .github/actions/setup/action.yml - .github/workflows/deploy-market-making-playground.yml workflow_dispatch: @@ -39,19 +41,16 @@ jobs: with: persist-credentials: false - - name: Set up Bun - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - name: Setup + uses: ./.github/actions/setup with: - bun-version: 1.3.12 - - - name: Install dependencies - run: bun install --frozen-lockfile + build-contracts: 'false' - name: Test playground at the GitHub Pages subpath - run: PLAYGROUND_SMOKE_BASE_PATH=/morpho-bots/ bun run --filter @morpho-org/market-making-bot playground:smoke:test + run: PLAYGROUND_SMOKE_BASE_PATH=/morpho-bots/ pnpm --filter @morpho-org/market-making-bot run playground:smoke:test - name: Build playground - run: bun run --filter @morpho-org/market-making-bot playground:build + run: pnpm --filter @morpho-org/market-making-bot run playground:build - name: Upload GitHub Pages artifact uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 diff --git a/.github/workflows/deploy-market-making-production.yml b/.github/workflows/deploy-market-making-production.yml index 2224891e..61fe4438 100644 --- a/.github/workflows/deploy-market-making-production.yml +++ b/.github/workflows/deploy-market-making-production.yml @@ -42,4 +42,4 @@ jobs: RAILWAY_ENVIRONMENT: production RAILWAY_PROJECT_ID: ${{ vars.RAILWAY_PROJECT_ID }} RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} - run: bun run --filter @morpho-org/market-making-bot deploy:railway + run: pnpm --filter @morpho-org/market-making-bot run deploy:railway diff --git a/.gitignore b/.gitignore index 76200c10..fa326487 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # Dependencies node_modules +.pnpm-store # Local env files .env diff --git a/CLAUDE.md b/CLAUDE.md index baea86c0..3826ff22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ commit/push): 2. **Lint**: Run `pnpm lint` from the repo root — zero warnings policy. Lint is a workspace-level concern; oxlint walks the whole tree and per-package `lint` scripts are deliberately omitted. 3. **Format**: Run `pnpm format` — auto-fixes formatting in place. -4. **Existing tests**: Run `bun test` — all must pass. +4. **Existing tests**: Run `pnpm test` — all must pass. **Escalation rule**: After 3 failed fix attempts for the same issue, STOP. Tell the user what you tried, what failed, and ask for guidance. Bad work is worse than no work — do not keep iterating @@ -118,7 +118,7 @@ addressing PR feedback, etc.). - During multi-step work, if you're iterating on earlier changes (bug fixes, feedback), existing verification tests catch if the fix broke earlier changes. - Follow existing test conventions: place tests under `test/` mirroring `src/` as - `{module}.test.ts`, use `bun test`, follow patterns from the nearest existing test file. + `{module}.test.ts`, use `vitest`, follow patterns from the nearest existing test file. - If a test file already exists for the module, add to it rather than creating a new one. **When NOT to write tests:** @@ -186,8 +186,9 @@ which supersedes the now-historical [TIB-2026-07-13-bot-architecture](./docs/decisions/TIB-2026-07-13-bot-architecture.md). **Key technologies**: pnpm 11.1.1 (package manager + workspace resolution + version catalog + task -runner; lifecycle scripts are default-denied via `allowBuilds` in `pnpm-workspace.yaml`), bun 1.3.12 -(runtime + test runner), Node.js 24.14.1, TypeScript 6.0, viem for Web3, oxlint + oxfmt for +runner; lifecycle scripts are default-denied via `allowBuilds` in `pnpm-workspace.yaml`), Node.js +24.14.1 (the only runtime — bots ship as esbuild bundles and run `node dist/src/index.js`), vitest as +the test runner, tsx for TypeScript scripts, TypeScript 6.0, viem for Web3, oxlint + oxfmt for lint/format, knip for dead-code detection. **Node.js requirement**: `24.14.1` (see `.nvmrc`). diff --git a/README.md b/README.md index b9d03420..2af687bc 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ pnpm run lint # oxlint, repo-wide pnpm run lint:fix # oxlint with --fix pnpm format # oxfmt, repo-wide pnpm run knip # dead-code detection -bun test # bun's built-in test runner +pnpm test # vitest projects plus Node playground suites ``` ## Pointers diff --git a/bots/blue-liquidation/Dockerfile b/bots/blue-liquidation/Dockerfile index 6d969723..c496f9de 100644 --- a/bots/blue-liquidation/Dockerfile +++ b/bots/blue-liquidation/Dockerfile @@ -1,15 +1,9 @@ # syntax=docker/dockerfile:1 -# pnpm-workspace image for the blue-liquidation bot. The build context MUST be the repo root so the -# workspace packages (packages/*) resolve — docker-compose.yml sets `context: ../..` and the Railway -# deploy runs `railway up` from the repo root. Borrower discovery polls the Morpho GraphQL API, so -# there is no indexer/database sidecar to build. -# -# pnpm owns installs, but bun is still the runtime, so this image carries both. The base is Node -# because pnpm is activated through corepack, which the oven/bun images do not provide; bun comes in -# as a single copied binary. Both pnpm and bun must stay on PATH in the FINAL image: `start` fires -# `prestart`, which shells out to `pnpm -r run build` to compile @repo/contracts' gitignored dist/. +# Image for the blue-liquidation bot. The build context MUST be the repo root so the workspace packages +# (packages/*) resolve — docker-compose.yml sets `context: ../..` and the Railway deploy runs +# `railway up` from the repo root. Borrower discovery polls the Morpho GraphQL API, so there is no +# indexer/database sidecar to build. Node only: pnpm installs, esbuild bundles, node runs. FROM node:24.14.1-slim -COPY --from=oven/bun:1.3.12-slim /usr/local/bin/bun /usr/local/bin/bun ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 # The container's environment holds a funded liquidator EOA key, so nothing here may run as root — @@ -22,8 +16,8 @@ WORKDIR /repo USER node # Manifests first, so the install layer is cached. `corepack install` pre-fetches the pnpm version -# pinned in package.json#packageManager, so no download happens at container start. -COPY --chown=node:node package.json pnpm-workspace.yaml pnpm-lock.yaml bunfig.toml ./ +# pinned in package.json#packageManager. +COPY --chown=node:node package.json pnpm-workspace.yaml pnpm-lock.yaml ./ RUN corepack install # All members' package.json are needed for pnpm to resolve the `workspace:*` links. @@ -31,7 +25,9 @@ COPY --chown=node:node packages ./packages COPY --chown=node:node bots ./bots RUN pnpm install --frozen-lockfile -# Run the bot from its package dir (`start` builds workspaces via `prestart`, then runs src/index.ts; -# the bot's own bunfig.toml preload compiles its soltag `sol``` lens templates at startup). +# Bundle at image-build time: workspace dists plus this bot's esbuild bundle (soltag `sol``` templates +# compiled in where present), so the container starts a plain `node` with no runtime transform. +RUN pnpm -r --if-present run build + WORKDIR /repo/bots/blue-liquidation -CMD ["bun", "run", "start"] +CMD ["node", "dist/src/index.js"] diff --git a/bots/blue-liquidation/README.md b/bots/blue-liquidation/README.md index 9a0e788e..e9739cdc 100644 --- a/bots/blue-liquidation/README.md +++ b/bots/blue-liquidation/README.md @@ -19,7 +19,7 @@ position (none exist while the market is healthy), `RPC_URL_8453`, and ## Prerequisites -- **pnpm** `11.1.1` (via corepack), **bun** `1.3.12`, **Node** `24.14.1` (`.nvmrc`). +- **pnpm** `11.1.1` (via corepack) and **Node** `24.14.1` (`.nvmrc`). - A chain RPC that both reads and _relays_ transactions — **not** `rpc.morpho.dev/realtime`, which acknowledges sends but never broadcasts them. - A **funded EOA** (native gas) — the liquidator and the recipient of the end-of-exec token sweeps. @@ -89,8 +89,9 @@ export ZEROX_API_KEY=… # or ENABLE_LIFI=true, or ALLOW_DETECTION_ON pnpm --filter @morpho-org/blue-liquidation run start ``` -`prestart` builds the workspace packages (soltag-compiles `@repo/contracts` and materializes the ABI). -Discovery hits the public Morpho GraphQL API — no indexer or database to run. +`prestart` builds this bot and its workspace dependencies (soltag-compiles `@repo/contracts` and +materializes the ABI, then esbuild-bundles `dist/`), so `start` runs a plain `node` against a +freshly built bundle. Discovery hits the public Morpho GraphQL API — no indexer or database to run. ## Running With Docker Compose @@ -255,7 +256,7 @@ the nonce from `getTransactionCount('pending')`. ## Testing -- `bun test` — unit tests for the math, LIF, seize-exact planner (incl. the underflow-safety sweep), +- `pnpm test` — unit tests for the math, LIF, seize-exact planner (incl. the underflow-safety sweep), the id derivation, GraphQL discovery (parsing, pagination, retry semantics), config (incl. venue inference and the zero-venue gate), eligibility, quoting, venues, the queue, and the exec encoder. - **Live read-path probe** — `pnpm --filter @morpho-org/blue-liquidation run probe:lens` (needs diff --git a/bots/blue-liquidation/bunfig.toml b/bots/blue-liquidation/bunfig.toml deleted file mode 100644 index 2a03c377..00000000 --- a/bots/blue-liquidation/bunfig.toml +++ /dev/null @@ -1,6 +0,0 @@ -# Compile soltag `sol``` lens templates at runtime (`bun run start` in the Docker image) and for -# `bun test` when run from this directory. See soltag.preload.ts for why the plugin is hand-rolled. -preload = ["./soltag.preload.ts"] - -[test] -preload = ["./soltag.preload.ts"] diff --git a/bots/blue-liquidation/package.json b/bots/blue-liquidation/package.json index a13fe1b1..b76fda2b 100644 --- a/bots/blue-liquidation/package.json +++ b/bots/blue-liquidation/package.json @@ -5,10 +5,11 @@ "license": "Apache-2.0", "type": "module", "scripts": { - "deploy:railway": "bun run scripts/deploy-railway.ts", - "probe:lens": "bun run scripts/probe-live-lens.ts", - "prestart": "pnpm -r --parallel --if-present run build", - "start": "bun src/index.ts", + "build": "tsx scripts/build.ts", + "deploy:railway": "tsx scripts/deploy-railway.ts", + "prestart": "pnpm --filter \"{.}...\" --if-present run build", + "probe:lens": "pnpm --filter \"{.}...\" --if-present run build && node --env-file-if-exists=.env dist/scripts/probe-live-lens.js", + "start": "node --env-file-if-exists=.env dist/src/index.js", "typecheck": "soltag && tsc --noEmit" }, "dependencies": { @@ -23,7 +24,12 @@ }, "devDependencies": { "@repo/typescript-config": "workspace:*", - "@types/bun": "catalog:", - "typescript": "catalog:" + "@types/node": "catalog:", + "esbuild": "catalog:", + "execa": "catalog:", + "tsx": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" } } diff --git a/bots/blue-liquidation/scripts/build.ts b/bots/blue-liquidation/scripts/build.ts new file mode 100644 index 00000000..d78794d2 --- /dev/null +++ b/bots/blue-liquidation/scripts/build.ts @@ -0,0 +1,55 @@ +import type { Plugin } from 'esbuild' + +import { build as esbuild } from 'esbuild' +import { rmSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { transformSolTemplates } from 'soltag/unplugin' + +import { BundleFailedError } from './bundle-failed.error' + +// Bundles the bot entrypoint (and the soltag-dependent operator script) to `dist/` with the +// `sol``` templates compiled to literal ABIs/bytecode, so production runs a plain `node` with no +// runtime transform. This replaces the bunfig `preload` that used to compile them at startup. + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const DIST_DIR = join(ROOT, 'dist') +rmSync(DIST_DIR, { recursive: true, force: true }) + +const ESCAPED = ROOT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +// This bot's own TS sources only — bundled workspace deps ship prebuilt dist and hold no templates. +const INCLUDE = new RegExp(`^${ESCAPED}/(?:src|scripts)/.*\\.tsx?$`) + +const soltagPlugin: Plugin = { + name: 'soltag', + setup(build) { + build.onLoad({ filter: INCLUDE }, async ({ path }) => { + const source = await readFile(path, 'utf8') + // Enable the optimizer — the lens's per-element computation has enough locals to hit + // "stack too deep" without it. + const transformed = transformSolTemplates(source, path, { + solc: { optimizer: { enabled: true, runs: 200 } } + }) + return { contents: transformed?.code ?? source, loader: 'ts' as const } + }) + } +} + +try { + await esbuild({ + entryPoints: [join(ROOT, 'src/index.ts'), join(ROOT, 'scripts/probe-live-lens.ts')], + outdir: DIST_DIR, + outbase: ROOT, + bundle: true, + platform: 'node', + format: 'esm', + // CJS deps reaching for require() inside an ESM bundle need a real require. + banner: { + js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" + }, + plugins: [soltagPlugin] + }) +} catch (error) { + throw new BundleFailedError(error instanceof Error ? error.message : String(error)) +} diff --git a/bots/blue-liquidation/scripts/bundle-failed.error.ts b/bots/blue-liquidation/scripts/bundle-failed.error.ts new file mode 100644 index 00000000..85b07b64 --- /dev/null +++ b/bots/blue-liquidation/scripts/bundle-failed.error.ts @@ -0,0 +1,11 @@ +/** Signals that the production bundle could not be produced. */ +export class BundleFailedError extends Error { + /** + * Creates a tooling failure from the bundler's own message, without retaining source contents. + * @param detail - Bundler-reported reason for the failure. + */ + constructor(readonly detail: string) { + super(`Bundle failed: ${detail}`) + this.name = 'BundleFailedError' + } +} diff --git a/bots/blue-liquidation/scripts/deploy-railway.ts b/bots/blue-liquidation/scripts/deploy-railway.ts index ea488e64..028f1e4e 100644 --- a/bots/blue-liquidation/scripts/deploy-railway.ts +++ b/bots/blue-liquidation/scripts/deploy-railway.ts @@ -21,7 +21,7 @@ * RAILWAY_PROJECT_ID=… RPC_URL_8453=… RPC_URL_4663=… LIQUIDATOR_PRIVATE_KEY=0x… \ * ALLOW_DETECTION_ONLY_4663=true pnpm --filter @morpho-org/blue-liquidation run deploy:railway * - * The build context MUST be the repo root so the bun workspace (packages/*) resolves — the script + * The build context MUST be the repo root so the pnpm workspace (packages/*) resolves — the script * runs `railway up` with cwd set to the repo root (mirrors the Dockerfile header + compose context). * * Idempotent: existing services / variables are reused; each run redeploys every bot. The venue @@ -36,16 +36,17 @@ * the variable key, never its value; variable values are never logged. */ import { delay, tryCatch } from '@repo/utils' -import { $ } from 'bun' -import { resolve } from 'node:path' +import { $ } from 'execa' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' // The target project is env-driven so no project identifier is baked into this (open-source) file. // RAILWAY_PROJECT_ID is required; RAILWAY_ENVIRONMENT defaults to the conventional `production`. -const PROJECT_ID = required(Bun.env, 'RAILWAY_PROJECT_ID') -const ENVIRONMENT = Bun.env.RAILWAY_ENVIRONMENT?.trim() || 'production' +const PROJECT_ID = required(process.env, 'RAILWAY_PROJECT_ID') +const ENVIRONMENT = process.env.RAILWAY_ENVIRONMENT?.trim() || 'production' const DOCKERFILE_PATH = 'bots/blue-liquidation/Dockerfile' // Repo root is three levels up from this file (scripts → blue-liquidation → bots → repo root). -const REPO_ROOT = resolve(import.meta.dir, '..', '..', '..') +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') type Env = Record type RailwayService = { id: string; name: string } @@ -75,8 +76,8 @@ function str(value: unknown): string { } // Surface a failed `railway` command's stderr so failures are actionable (the CLI writes the real -// reason — plan limits, auth, selection prompts — to stderr). Bun's ShellError carries `.stderr` as -// bytes; fall back to the generic message. Safe for non-secret commands; never used on setSecret. +// reason — plan limits, auth, selection prompts — to stderr). execa's error carries `.stderr` as a +// string; fall back to the generic message. Safe for non-secret commands; never used on setSecret. function stderrOf(error: unknown): string { if (isRecord(error) && 'stderr' in error) { const s = (error as { stderr: unknown }).stderr @@ -120,7 +121,7 @@ function parseLatestStatus(raw: string): string { } async function assertCli(): Promise { - const { error } = await tryCatch(Promise.resolve($`railway --version`.quiet())) + const { error } = await tryCatch($`railway --version`) if (error) throw new Error('Railway CLI not found. Install it: https://docs.railway.com/guides/cli') } @@ -128,13 +129,11 @@ async function assertCli(): Promise { // `railway add` has no --project/--environment flag, so it acts on the linked context. A project // token scopes every command implicitly; otherwise we link the project id once for this run. async function ensureContext(): Promise { - if (Bun.env.RAILWAY_TOKEN) { + if (process.env.RAILWAY_TOKEN) { console.log('Using RAILWAY_TOKEN for project context.') return } - const { error } = await tryCatch( - Promise.resolve($`railway link -p ${PROJECT_ID} -e ${ENVIRONMENT}`.quiet()) - ) + const { error } = await tryCatch($`railway link -p ${PROJECT_ID} -e ${ENVIRONMENT}`) if (error) { throw new Error( `Failed to link ${PROJECT_ID} (${ENVIRONMENT}). Set RAILWAY_TOKEN or run \`railway login\`.` @@ -144,9 +143,7 @@ async function ensureContext(): Promise { } async function listServices(): Promise { - const { data, error } = await tryCatch( - Promise.resolve($`railway service list --json`.quiet().text()) - ) + const { data, error } = await tryCatch($`railway service list --json`.then(r => r.stdout)) return error || typeof data !== 'string' ? [] : parseServices(data) } @@ -156,7 +153,7 @@ async function ensureService(name: string): Promise { return } console.log(`Creating service ${name}…`) - const { error } = await tryCatch(Promise.resolve($`railway add --service ${name} --json`.quiet())) + const { error } = await tryCatch($`railway add --service ${name} --json`) if (error) throw new Error(`Failed to create service ${name}: ${stderrOf(error)}`) } @@ -167,9 +164,7 @@ async function removeService(name: string): Promise { if (!(await listServices()).some(service => service.name === name)) return console.log(`Removing legacy service ${name}…`) const { error } = await tryCatch( - Promise.resolve( - $`railway service delete --service ${name} --environment ${ENVIRONMENT} --yes --json`.quiet() - ) + $`railway service delete --service ${name} --environment ${ENVIRONMENT} --yes --json` ) if (error) console.warn( @@ -182,9 +177,7 @@ async function removeService(name: string): Promise { // Non-secret variable. `kv` is a single "KEY=VALUE" arg; only the key is logged. async function setVar(service: string, kv: string): Promise { const key = kv.split('=')[0] - const { error } = await tryCatch( - Promise.resolve($`railway variable set ${kv} -s ${service} --skip-deploys`.quiet()) - ) + const { error } = await tryCatch($`railway variable set ${kv} -s ${service} --skip-deploys`) if (error) throw new Error(`Failed to set ${key} on ${service}: ${stderrOf(error)}`) console.log(`Set ${key} on ${service}.`) } @@ -193,10 +186,8 @@ async function setVar(service: string, kv: string): Promise { // CLI's JSON includes raw values, so it is parsed in-memory and only key NAMES ever leave here. async function listVarKeys(service: string): Promise> { const { data, error } = await tryCatch( - Promise.resolve( - $`railway variable list -s ${service} -e ${ENVIRONMENT} -p ${PROJECT_ID} --json` - .quiet() - .text() + $`railway variable list -s ${service} -e ${ENVIRONMENT} -p ${PROJECT_ID} --json`.then( + r => r.stdout ) ) if (error || typeof data !== 'string') return new Set() @@ -207,9 +198,7 @@ async function listVarKeys(service: string): Promise> { // Delete a variable (used to clear a stale venue secret). Fatal on failure: a stale key that // survives the delete silently keeps its venue enabled, which is exactly the drift this prevents. async function deleteVar(service: string, key: string): Promise { - const { error } = await tryCatch( - Promise.resolve($`railway variable delete ${key} -s ${service} --skip-deploys`.quiet()) - ) + const { error } = await tryCatch($`railway variable delete ${key} -s ${service} --skip-deploys`) if (error) throw new Error(`Failed to delete ${key} on ${service}: ${stderrOf(error)}`) console.log(`Deleted ${key} on ${service} (stale).`) } @@ -217,9 +206,7 @@ async function deleteVar(service: string, key: string): Promise { // Secret variable: value piped via stdin (never argv), `--json` omitted (it echoes raw values). async function setSecret(service: string, key: string, value: string): Promise { const { error } = await tryCatch( - Promise.resolve( - $`railway variable set ${key} --stdin -s ${service} --skip-deploys < ${Buffer.from(value, 'utf8')}`.quiet() - ) + $({ input: value })`railway variable set ${key} --stdin -s ${service} --skip-deploys` ) if (error) throw new Error(`Failed to set ${key} on ${service}`) console.log(`Set ${key} on ${service} (secret).`) @@ -230,9 +217,7 @@ async function deployService(service: string): Promise { // Pass -p/-e explicitly: `railway link` doesn't reliably carry the environment into this non-TTY // subprocess, so `railway up` otherwise errors "No environment specified". Self-contained > ambient. const { error } = await tryCatch( - Promise.resolve( - $`railway up -s ${service} -e ${ENVIRONMENT} -p ${PROJECT_ID} -d`.cwd(REPO_ROOT).quiet() - ) + $({ cwd: REPO_ROOT })`railway up -s ${service} -e ${ENVIRONMENT} -p ${PROJECT_ID} -d` ) if (error) throw new Error(`Failed to start deploy for ${service}: ${stderrOf(error)}`) } @@ -253,7 +238,7 @@ async function latestStatus(service: string): Promise { '1', '--json' ] - const { data, error } = await tryCatch(Promise.resolve($`${args}`.quiet().text())) + const { data, error } = await tryCatch($(args[0] ?? 'railway', args.slice(1)).then(r => r.stdout)) return error || typeof data !== 'string' ? 'UNKNOWN' : parseLatestStatus(data) } @@ -295,7 +280,7 @@ const LEGACY_BOT_SERVICE = 'bot' // GitHub Environment holds only RAILWAY_TOKEN + RAILWAY_PROJECT_ID, and the services/secrets were // provisioned once by a full (secret-bearing) run of this script. Skips the RPC/key requirements the // full path enforces, so it never needs those secrets in CI. Runs before `chainSecrets` is read. -if (/^(1|true)$/i.test(Bun.env.DEPLOY_ONLY?.trim() ?? '')) { +if (/^(1|true)$/i.test(process.env.DEPLOY_ONLY?.trim() ?? '')) { await ensureContext() const services = CHAINS.map(chain => chain.service) for (const service of services) await deployService(service) @@ -310,7 +295,7 @@ if (/^(1|true)$/i.test(Bun.env.DEPLOY_ONLY?.trim() ?? '')) { // Read a chainId-suffixed env var (e.g. RPC_URL_8453). RPC endpoints differ per chain so these are // effectively required per chain; the private key may instead fall back to a shared unsuffixed key. function suffixed(name: string, chainId: number): string | undefined { - return Bun.env[`${name}_${chainId}`]?.trim() || undefined + return process.env[`${name}_${chainId}`]?.trim() || undefined } function requiredSuffixed(name: string, chainId: number): string { const value = suffixed(name, chainId) @@ -319,7 +304,7 @@ function requiredSuffixed(name: string, chainId: number): string { } // A chainId-suffixed boolean flag with an unsuffixed fallback (e.g. ALLOW_DETECTION_ONLY_4663). function suffixedFlag(name: string, chainId: number): boolean { - const raw = suffixed(name, chainId) ?? Bun.env[name]?.trim() + const raw = suffixed(name, chainId) ?? process.env[name]?.trim() return /^(1|true)$/i.test(raw ?? '') } @@ -331,17 +316,17 @@ const chainSecrets = CHAINS.map(chain => { const rpcUrl = requiredSuffixed('RPC_URL', chain.chainId) // A single funded key may be reused across chains (unsuffixed fallback), or set one per chain. const liquidatorPrivateKey = - suffixed('LIQUIDATOR_PRIVATE_KEY', chain.chainId) ?? Bun.env.LIQUIDATOR_PRIVATE_KEY?.trim() + suffixed('LIQUIDATOR_PRIVATE_KEY', chain.chainId) ?? process.env.LIQUIDATOR_PRIVATE_KEY?.trim() if (!liquidatorPrivateKey) throw new Error( `Missing required env var: LIQUIDATOR_PRIVATE_KEY_${chain.chainId} (or a shared LIQUIDATOR_PRIVATE_KEY)` ) assertPrivateKey(liquidatorPrivateKey) // Venue keys enable their venue on this chain (no per-collateral routing file anymore). - const zeroxApiKey = suffixed('ZEROX_API_KEY', chain.chainId) ?? Bun.env.ZEROX_API_KEY?.trim() + const zeroxApiKey = suffixed('ZEROX_API_KEY', chain.chainId) ?? process.env.ZEROX_API_KEY?.trim() const oneInchApiKey = - suffixed('ONEINCH_API_KEY', chain.chainId) ?? Bun.env.ONEINCH_API_KEY?.trim() - const lifiApiKey = suffixed('LIFI_API_KEY', chain.chainId) ?? Bun.env.LIFI_API_KEY?.trim() + suffixed('ONEINCH_API_KEY', chain.chainId) ?? process.env.ONEINCH_API_KEY?.trim() + const lifiApiKey = suffixed('LIFI_API_KEY', chain.chainId) ?? process.env.LIFI_API_KEY?.trim() const enableLifi = suffixedFlag('ENABLE_LIFI', chain.chainId) || Boolean(lifiApiKey) const allowDetectionOnly = suffixedFlag('ALLOW_DETECTION_ONLY', chain.chainId) const hasVenue = Boolean(zeroxApiKey || oneInchApiKey || enableLifi) @@ -371,8 +356,8 @@ await ensureContext() // Optional BetterStack log shipping, one source for blue-liq shared across its chains (told apart by // the bot/chainId fields the logger stamps). Host is a plain var; token is a secret. Off when unset — // the bot's in-process loglayer transport stays inert, so the container behaves exactly as before. -const betterstackHost = Bun.env.BETTERSTACK_INGESTING_HOST?.trim() -const betterstackToken = Bun.env.BETTERSTACK_SOURCE_TOKEN?.trim() +const betterstackHost = process.env.BETTERSTACK_INGESTING_HOST?.trim() +const betterstackToken = process.env.BETTERSTACK_SOURCE_TOKEN?.trim() // --- bot-: one liquidation runner per chain. The in-container var names stay RPC_URL / // LIQUIDATOR_PRIVATE_KEY (the chainId suffix is only an operator-side convention). The whole venue diff --git a/bots/blue-liquidation/scripts/probe-live-lens.ts b/bots/blue-liquidation/scripts/probe-live-lens.ts index aecc8142..dbfb8ceb 100644 --- a/bots/blue-liquidation/scripts/probe-live-lens.ts +++ b/bots/blue-liquidation/scripts/probe-live-lens.ts @@ -9,7 +9,7 @@ * `ForkFixture` you can paste into `test/fork/liquidation.test.ts` to run the end-to-end suite. * * Usage (needs a Base RPC; no anvil required): - * RPC_URL=https://… bun run bots/blue-liquidation/scripts/probe-live-lens.ts + * RPC_URL=https://… pnpm --filter @morpho-org/blue-liquidation run probe:lens */ import type { Address, Hex } from 'viem' @@ -34,7 +34,7 @@ const LOGS_MAX_CHUNKS = 12 const TARGET_PAIRS = 256 function required(name: string): string { - const value = Bun.env[name] + const value = process.env[name] if (!value?.trim()) throw new Error(`Missing required env var: ${name}`) return value.trim() } diff --git a/bots/blue-liquidation/soltag.preload.ts b/bots/blue-liquidation/soltag.preload.ts deleted file mode 100644 index cd982b58..00000000 --- a/bots/blue-liquidation/soltag.preload.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { transformSolTemplates } from 'soltag/unplugin' - -// soltag is a build-time-only transform: its `sol``` tagged templates throw at runtime unless a -// bundler/loader plugin compiles them (via solc). unplugin's own bun adapter (`unplugin.bun()`) is -// unusable under bun — it returns `undefined` from `onLoad` for files it doesn't transform, which -// bun rejects ("Expected module mock to return an object"). So we drive soltag's exported core -// transform from a bun loader plugin: it matches every TS source under this bot (where soltag is a -// dependency — other workspaces don't use it), and returns files unchanged when they hold no -// template to compile. Wired via bunfig `preload` for both `bun test` and `bun run`. - -const BOT_DIR = import.meta.dir -const ESCAPED = BOT_DIR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') -// Matches `/**/*.{ts,tsx,mts,cts}`, excluding any nested node_modules. -const INCLUDE = new RegExp(`^${ESCAPED}/(?:(?!/node_modules/).)*\\.(?:m|c)?tsx?$`) - -Bun.plugin({ - name: 'soltag', - setup(build) { - build.onLoad({ filter: INCLUDE }, async ({ path }) => { - const source = await Bun.file(path).text() - const loader = path.endsWith('.tsx') ? 'tsx' : 'ts' - // Enable the optimizer — the lens's per-element computation has enough locals to hit - // "stack too deep" without it. - const transformed = transformSolTemplates(source, path, { - solc: { optimizer: { enabled: true, runs: 200 } } - }) - return { contents: transformed?.code ?? source, loader } - }) - } -}) diff --git a/bots/blue-liquidation/src/config.ts b/bots/blue-liquidation/src/config.ts index d35b4239..3a93d6fa 100644 --- a/bots/blue-liquidation/src/config.ts +++ b/bots/blue-liquidation/src/config.ts @@ -282,7 +282,7 @@ function addressListEnv(env: Env, name: string): Address[] { * singleton hold code) are performed in `index.ts` once a public client exists. */ export function loadConfig( - env: Env = Bun.env, + env: Env = process.env, deps: { chainMap?: Record } = {} ): Config { const chainMap = deps.chainMap ?? CHAIN_MAP diff --git a/bots/blue-liquidation/src/index.ts b/bots/blue-liquidation/src/index.ts index c4f98f24..422ece56 100644 --- a/bots/blue-liquidation/src/index.ts +++ b/bots/blue-liquidation/src/index.ts @@ -94,9 +94,9 @@ async function main() { // the no-key → detection-only opt-in). Keys are read HERE, at the point of use, and live only in // this closure — never on the (logged) Config object. const apiKeys: Partial> = {} - if (Bun.env.ZEROX_API_KEY) apiKeys['0x'] = Bun.env.ZEROX_API_KEY - if (Bun.env.ONEINCH_API_KEY) apiKeys['1inch'] = Bun.env.ONEINCH_API_KEY - if (Bun.env.LIFI_API_KEY) apiKeys.lifi = Bun.env.LIFI_API_KEY + if (process.env.ZEROX_API_KEY) apiKeys['0x'] = process.env.ZEROX_API_KEY + if (process.env.ONEINCH_API_KEY) apiKeys['1inch'] = process.env.ONEINCH_API_KEY + if (process.env.LIFI_API_KEY) apiKeys.lifi = process.env.LIFI_API_KEY const venues = config.venues.enabled if (venues.length === 0) { logger.warn('quoting.no_routes', { @@ -250,7 +250,7 @@ async function main() { logger }) const heartbeatMonitor = createHeartbeatMonitor({ - url: Bun.env.BETTERSTACK_HEARTBEAT_URL, + url: process.env.BETTERSTACK_HEARTBEAT_URL, logger }) void heartbeatMonitor.start() diff --git a/bots/blue-liquidation/src/state/lens.sol.ts b/bots/blue-liquidation/src/state/lens.sol.ts index 65759a5f..141ae6b2 100644 --- a/bots/blue-liquidation/src/state/lens.sol.ts +++ b/bots/blue-liquidation/src/state/lens.sol.ts @@ -24,8 +24,8 @@ import { marketId } from '../market' // re-derives the id from the supplied params on-chain, so mismatched params read an uncreated market // and return valid=false. // -// Compiled to a deployless factory by the soltag bun preload (see ../../soltag.preload.ts); `sol``` -// throws if not active. +// Compiled to a deployless factory by soltag's Vitest/esbuild integrations; `sol``` throws if the +// transform is not active. export const BlueLiquidationLens = sol('BlueLiquidationLens')` // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity ^0.8.19; diff --git a/bots/blue-liquidation/test/config.test.ts b/bots/blue-liquidation/test/config.test.ts index 65300a29..9b9a814b 100644 --- a/bots/blue-liquidation/test/config.test.ts +++ b/bots/blue-liquidation/test/config.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'bun:test' import { getAddress } from 'viem' import { base } from 'viem/chains' +import { describe, expect, it } from 'vitest' import type { ChainConfig, Config, QuotingConfig } from '../src/config' diff --git a/bots/blue-liquidation/test/discovery/borrowers.test.ts b/bots/blue-liquidation/test/discovery/borrowers.test.ts index cd63696c..60380169 100644 --- a/bots/blue-liquidation/test/discovery/borrowers.test.ts +++ b/bots/blue-liquidation/test/discovery/borrowers.test.ts @@ -1,8 +1,8 @@ import type { Logger } from '@repo/bot-kit' import type { Address, Hex } from 'viem' -import { describe, expect, it } from 'bun:test' import { getAddress } from 'viem' +import { describe, expect, it } from 'vitest' import type { FetchPositionPage } from '../../src/discovery/borrowers' import type { MarketParams } from '../../src/market' diff --git a/bots/blue-liquidation/test/execution/encode-call.test.ts b/bots/blue-liquidation/test/execution/encode-call.test.ts index 27397dc3..e3f5f5e5 100644 --- a/bots/blue-liquidation/test/execution/encode-call.test.ts +++ b/bots/blue-liquidation/test/execution/encode-call.test.ts @@ -1,8 +1,8 @@ import type { SwapPlan, SwapStep } from '@repo/swaps' import type { Hex } from 'viem' -import { describe, expect, it } from 'bun:test' import { decodeAbiParameters, decodeFunctionData, getAddress } from 'viem' +import { describe, expect, it } from 'vitest' import type { MarketParams } from '../../src/market' diff --git a/bots/blue-liquidation/test/execution/swap-step.test.ts b/bots/blue-liquidation/test/execution/swap-step.test.ts index 2d10e30f..72578565 100644 --- a/bots/blue-liquidation/test/execution/swap-step.test.ts +++ b/bots/blue-liquidation/test/execution/swap-step.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it } from 'vitest' import type { MarketParams } from '../../src/market' import type { LensOut } from '../../src/state/lens.sol' diff --git a/bots/blue-liquidation/test/fork/harness.test.ts b/bots/blue-liquidation/test/fork/harness.test.ts index c134d6f2..9a7a48e0 100644 --- a/bots/blue-liquidation/test/fork/harness.test.ts +++ b/bots/blue-liquidation/test/fork/harness.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'bun:test' import { getAddress } from 'viem' +import { describe, expect, it } from 'vitest' import { loadForkFixtureFromEnv } from './harness' diff --git a/bots/blue-liquidation/test/fork/harness.ts b/bots/blue-liquidation/test/fork/harness.ts index 28a44a17..3f7eea8c 100644 --- a/bots/blue-liquidation/test/fork/harness.ts +++ b/bots/blue-liquidation/test/fork/harness.ts @@ -1,8 +1,10 @@ -import type { Subprocess } from 'bun' +import type { ChildProcess } from 'node:child_process' import type { Address } from 'viem' import { Executor } from '@repo/contracts' import { ensureError } from '@repo/utils' +import { spawn } from 'node:child_process' +import { setTimeout as sleep } from 'node:timers/promises' import { createTestClient, createWalletClient, @@ -119,7 +121,22 @@ const DEPLOYER_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b export const LIQUIDATOR = privateKeyToAccount(LIQUIDATOR_KEY).address export type TestClient = ReturnType -export type ForkHandle = Subprocess +export type ForkHandle = { + kill: (signal: NodeJS.Signals) => void + /** Resolves with the exit code once the child has actually gone (Node has no `.exited`). */ + exited: Promise +} + +// Wraps a ChildProcess in the tiny surface the teardown needs. The `exited` promise is built here, +// at spawn time, so its `exit` listener is attached before the child can possibly exit. +const toForkHandle = (child: ChildProcess): ForkHandle => ({ + kill: signal => { + child.kill(signal) + }, + exited: new Promise(resolve => { + child.once('exit', code => resolve(code)) + }) +}) /** Polls the JSON-RPC endpoint until it answers `eth_blockNumber`, so callers see a ready node. */ async function waitForRpc(url: string, timeoutMs = 30_000): Promise { @@ -139,15 +156,21 @@ async function waitForRpc(url: string, timeoutMs = 30_000): Promise { } catch (error) { lastError = ensureError(error) } - await Bun.sleep(100) + await sleep(100) } const detail = lastError ? `: ${lastError.message}` : '' throw new Error(`anvil RPC at ${url} not ready within ${timeoutMs}ms${detail}`) } +// Anvil port registry — vitest runs test FILES IN PARALLEL (bun's runner was serial, so fixed ports +// used to be safe within a bot only). Every fork suite in the repo must claim a distinct port: +// 8545 bots/blue-liquidation fork/liquidation +// 8546 bots/market-making e2e/setup-check +// 8547 bots/midnight-liquidation fork/liquidation +// 8548 bots/midnight-liquidation fork/queue /** * Boots an anvil instance forking Base at `forkBlock` (chain id pinned to Base so signatures match). - * `port` is explicit so multiple fork test files can run in one `bun test` process without colliding. + * `port` is explicit so parallel fork test files never collide — see the registry above. * Requires the `anvil` binary on PATH (Foundry locally; foundry-toolchain in CI) and `FORK_URL`. */ export async function startFork( @@ -155,19 +178,21 @@ export async function startFork( port = 8545 ): Promise<{ anvil: ForkHandle; rpcUrl: string }> { if (!FORK_URL) throw new Error('RPC_URL_8453 is required to start the fork') - const anvil = Bun.spawn( - [ + const anvil = toForkHandle( + spawn( 'anvil', - '--fork-url', - FORK_URL, - '--fork-block-number', - String(forkBlock), - '--chain-id', - String(base.id), - '--port', - String(port) - ], - { stdout: 'ignore', stderr: 'ignore' } + [ + '--fork-url', + FORK_URL, + '--fork-block-number', + String(forkBlock), + '--chain-id', + String(base.id), + '--port', + String(port) + ], + { stdio: 'ignore' } + ) ) const rpcUrl = `http://127.0.0.1:${port}` await waitForRpc(rpcUrl) diff --git a/bots/blue-liquidation/test/fork/liquidation.test.ts b/bots/blue-liquidation/test/fork/liquidation.test.ts index 7dfa3e81..da0e3adc 100644 --- a/bots/blue-liquidation/test/fork/liquidation.test.ts +++ b/bots/blue-liquidation/test/fork/liquidation.test.ts @@ -10,9 +10,9 @@ import { } from '@repo/bot-kit' import { quoteUniswapV3 } from '@repo/swaps' import { lensKey } from '@repo/utils' -import { afterAll, beforeAll, describe, expect, it } from 'bun:test' import { erc20Abi, parseGwei } from 'viem' import { base } from 'viem/chains' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type { ForkFixture, ForkHandle, TestClient } from './harness' diff --git a/bots/blue-liquidation/test/market.test.ts b/bots/blue-liquidation/test/market.test.ts index 685bf4fb..d5fe860f 100644 --- a/bots/blue-liquidation/test/market.test.ts +++ b/bots/blue-liquidation/test/market.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'bun:test' import { encodeAbiParameters, keccak256 } from 'viem' +import { describe, expect, it } from 'vitest' import type { MarketParams } from '../src/market' diff --git a/bots/blue-liquidation/test/quotes.test.ts b/bots/blue-liquidation/test/quotes.test.ts index 1d282f49..511fb497 100644 --- a/bots/blue-liquidation/test/quotes.test.ts +++ b/bots/blue-liquidation/test/quotes.test.ts @@ -1,8 +1,8 @@ import type { Logger } from '@repo/bot-kit' import type { RateLimitedClient, VenuePair, VenueQuoteEstimate, VenueSelector } from '@repo/swaps' -import { describe, expect, it } from 'bun:test' import { getAddress } from 'viem' +import { describe, expect, it } from 'vitest' import type { MarketParams } from '../src/market' import type { LiquidationPlan } from '../src/sizing/plan' diff --git a/bots/blue-liquidation/test/runner/eligibility.test.ts b/bots/blue-liquidation/test/runner/eligibility.test.ts index b394ad2c..4d1dccd1 100644 --- a/bots/blue-liquidation/test/runner/eligibility.test.ts +++ b/bots/blue-liquidation/test/runner/eligibility.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it } from 'vitest' import type { MarketParams } from '../../src/market' import type { LensOut } from '../../src/state/lens.sol' diff --git a/bots/blue-liquidation/test/runner/tick.test.ts b/bots/blue-liquidation/test/runner/tick.test.ts index ae936ff8..b2f236e5 100644 --- a/bots/blue-liquidation/test/runner/tick.test.ts +++ b/bots/blue-liquidation/test/runner/tick.test.ts @@ -5,8 +5,8 @@ import type { Address } from 'viem' import { createBackoff, createCooldownStore } from '@repo/bot-kit' import { lensKey } from '@repo/utils' -import { describe, expect, it } from 'bun:test' import { getAddress } from 'viem' +import { describe, expect, it } from 'vitest' import type { BorrowerCandidate } from '../../src/discovery/borrowers' import type { MarketParams } from '../../src/market' diff --git a/bots/blue-liquidation/test/sizing/lif.test.ts b/bots/blue-liquidation/test/sizing/lif.test.ts index a652342e..c3488bba 100644 --- a/bots/blue-liquidation/test/sizing/lif.test.ts +++ b/bots/blue-liquidation/test/sizing/lif.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it } from 'vitest' import { LIQUIDATION_CURSOR, MAX_LIQUIDATION_INCENTIVE_FACTOR, WAD } from '../../src/constants' import { lifFromLltv } from '../../src/sizing/lif' diff --git a/bots/blue-liquidation/test/sizing/math.test.ts b/bots/blue-liquidation/test/sizing/math.test.ts index 4c002889..fba9c652 100644 --- a/bots/blue-liquidation/test/sizing/math.test.ts +++ b/bots/blue-liquidation/test/sizing/math.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it } from 'vitest' import { VIRTUAL_ASSETS, VIRTUAL_SHARES, WAD } from '../../src/constants' import { diff --git a/bots/blue-liquidation/test/sizing/plan.test.ts b/bots/blue-liquidation/test/sizing/plan.test.ts index a14b1669..50d797a4 100644 --- a/bots/blue-liquidation/test/sizing/plan.test.ts +++ b/bots/blue-liquidation/test/sizing/plan.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it } from 'vitest' import type { PlanInput } from '../../src/sizing/plan' diff --git a/bots/blue-liquidation/test/state/lens.sol.test.ts b/bots/blue-liquidation/test/state/lens.sol.test.ts index 8a23c595..32b8cc6f 100644 --- a/bots/blue-liquidation/test/state/lens.sol.test.ts +++ b/bots/blue-liquidation/test/state/lens.sol.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'bun:test' import { decodeFunctionResult, encodeFunctionResult, getAddress } from 'viem' +import { describe, expect, it } from 'vitest' import { BlueLiquidationLens } from '../../src/state/lens.sol' @@ -7,7 +7,7 @@ const MORPHO = getAddress('0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb') describe('BlueLiquidationLens', () => { it('compiles via soltag and binds the Morpho address into the factory call', () => { - // Proves the soltag bun preload compiled the inline Solidity (sol``` would otherwise throw) + // Proves the soltag/vite transform compiled the inline Solidity (sol``` would otherwise throw) // and that constructor binding produced a deployless factory call. const compiled = BlueLiquidationLens.with(MORPHO) expect(compiled.factoryData.length).toBeGreaterThan(2) diff --git a/bots/blue-liquidation/test/state/market-params.test.ts b/bots/blue-liquidation/test/state/market-params.test.ts index 3e45a00e..589b2f40 100644 --- a/bots/blue-liquidation/test/state/market-params.test.ts +++ b/bots/blue-liquidation/test/state/market-params.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'bun:test' import { getAddress, type Hex } from 'viem' +import { describe, expect, it } from 'vitest' import type { MarketParams } from '../../src/market' import type { MarketParamsResolver } from '../../src/state/market-params' diff --git a/bots/blue-liquidation/tsconfig.json b/bots/blue-liquidation/tsconfig.json index 7ad64bb1..dccc6c59 100644 --- a/bots/blue-liquidation/tsconfig.json +++ b/bots/blue-liquidation/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@repo/typescript-config/base", "compilerOptions": { - "types": ["bun"], + "types": ["node"], "plugins": [{ "name": "soltag/plugin" }] }, "include": ["src", "test", "scripts", ".soltag/types.d.ts"], diff --git a/bots/blue-liquidation/vitest.config.ts b/bots/blue-liquidation/vitest.config.ts new file mode 100644 index 00000000..827c62aa --- /dev/null +++ b/bots/blue-liquidation/vitest.config.ts @@ -0,0 +1,23 @@ +import { fileURLToPath } from 'node:url' +import soltag from 'soltag/vite' +import { loadEnv } from 'vite' +import { defineConfig } from 'vitest/config' + +const BOT_DIR = fileURLToPath(new URL('.', import.meta.url)) + +// soltag is a build-time-only transform: its `sol``` tagged templates throw at runtime unless a +// plugin compiles them (via solc). The vite plugin transforms this project's TS sources and leaves +// files without templates unchanged — it replaces the hand-rolled bun loader plugin that used to be +// wired through bunfig `preload`. Enable the optimizer: the lens's per-element computation has +// enough locals to hit "stack too deep" without it. +export default defineConfig({ + plugins: [soltag({ solc: { optimizer: { enabled: true, runs: 200 } } })], + test: { + name: 'blue-liquidation', + // bun auto-loaded .env files; vitest does not. The fork suite reads RPC_URL_8453 from + // .env.test.local and skips when it is unset. `loadEnv` with an empty prefix passes an + // already-exported value straight through (so CI's secret survives) and omits the key entirely + // when no file supplies it — it never substitutes an empty string, so CI cannot silently skip. + env: loadEnv('test', BOT_DIR, '') + } +}) diff --git a/bots/market-making/Dockerfile b/bots/market-making/Dockerfile index ca5a3477..20ac61ec 100644 --- a/bots/market-making/Dockerfile +++ b/bots/market-making/Dockerfile @@ -1,12 +1,26 @@ # syntax=docker/dockerfile:1 -# The build context is the repository root so every workspace dependency remains available. -FROM oven/bun:1.3.12-slim +# Image for the market-making bot. The build context MUST be the repository root so workspace +# packages resolve. Node only: pnpm installs, esbuild bundles, node runs. +FROM node:24.14.1-slim +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + +# Runtime configuration may include a funded maker key, so the process must not run as root. +# Corepack writes its pnpm shim to /usr/local/bin; enable it before dropping privileges. +RUN corepack enable pnpm +RUN mkdir -p /repo && chown node:node /repo WORKDIR /repo +USER node + +# Manifests first keep the install layer cacheable. `corepack install` fetches the pnpm version pinned +# in package.json#packageManager. +COPY --chown=node:node package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +RUN corepack install -COPY package.json bun.lock bunfig.toml ./ -COPY packages ./packages -COPY bots ./bots -RUN bun install --frozen-lockfile +# All workspace manifests and sources are needed to resolve links and build the bot bundle. +COPY --chown=node:node packages ./packages +COPY --chown=node:node bots ./bots +RUN pnpm install --frozen-lockfile +RUN pnpm -r --if-present run build WORKDIR /repo/bots/market-making -CMD ["bun", "run", "start", "--", "start", "--verbose"] +CMD ["node", "dist/src/index.js", "start", "--verbose"] diff --git a/bots/market-making/README.md b/bots/market-making/README.md index f876851e..d5005d1a 100644 --- a/bots/market-making/README.md +++ b/bots/market-making/README.md @@ -15,7 +15,7 @@ structure. Run the stateless parameter playground locally from the repository root: ```sh -bun run market-making:playground +pnpm run market-making:playground ``` The launcher performs a frozen-lockfile dependency check before every build, then prints the local @@ -23,7 +23,7 @@ server URL after building the browser artifact. For a production-equivalent build without starting a server, run: ```sh -bun run --filter @morpho-org/market-making-bot playground:build +pnpm --filter @morpho-org/market-making-bot run playground:build ``` After this pull request is merged to `main`, relevant playground, browser-safe bot-kit, package, lock, @@ -43,16 +43,16 @@ pnpm --filter @morpho-org/market-making-bot run start -- setup-check pnpm --filter @morpho-org/market-making-bot run start -- --readonly setup-check # Explicit signer sources (root options precede the command). -bun run --filter @morpho-org/market-making-bot start -- --private-key '' setup-check -bun run --filter @morpho-org/market-making-bot start -- --keystore ./maker.json --interactive setup-check -bun run --filter @morpho-org/market-making-bot start -- --aws setup-check +pnpm --filter @morpho-org/market-making-bot run start -- --private-key '' setup-check +pnpm --filter @morpho-org/market-making-bot run start -- --keystore ./maker.json --interactive setup-check +pnpm --filter @morpho-org/market-making-bot run start -- --aws setup-check ``` For unattended keystore operation, provision `KEYSTORE_PASSWORD` separately through the deployment secret manager or process environment, then run the keystore command without an inline value: ```sh -bun run --filter @morpho-org/market-making-bot start -- --keystore ./maker.json setup-check +pnpm --filter @morpho-org/market-making-bot run start -- --keystore ./maker.json setup-check ``` `--private-key ` and `--password ` remain available for explicit automation, but they @@ -254,8 +254,8 @@ pnpm --filter @morpho-org/market-making-bot run start -- --version The package owns its production [Dockerfile](./Dockerfile), local [docker-compose.yml](./docker-compose.yml), and idempotent [`scripts/deploy-railway.ts`](./scripts/deploy-railway.ts) entrypoint. The Docker build context is the -repository root so Bun can resolve every workspace dependency; the image starts the combined setup, -bootstrap, and ladder monitor. +repository root so pnpm can resolve every workspace dependency; the image builds the workspace and +starts the combined setup, bootstrap, and ladder monitor as an unprivileged Node process. A full deployment creates the `market-making` Railway service, selects the package Dockerfile, provisions a persistent volume at `/state`, and writes the effective environment configuration @@ -263,7 +263,7 @@ through stdin so values never appear in process arguments or logs: ```sh RAILWAY_PROJECT_ID=... \ -bun run --filter @morpho-org/market-making-bot deploy:railway +pnpm --filter @morpho-org/market-making-bot run deploy:railway ``` Provide the required values from [`.env.example`](./.env.example) in the invoking environment. @@ -752,12 +752,12 @@ collections are rejected atomically. The four outputs are Bootstrap JSON, the co `BOOTSTRAP_MARKETS` value, Ladder JSON, and the compact exact `LADDER_MARKETS` value; each collection validates independently. -From the repository root, one command runs `bun install --frozen-lockfile` (also on already-installed +From the repository root, one command runs `pnpm install --frozen-lockfile` (also on already-installed workspaces, where it is fast), creates a fresh isolated build, and serves it on loopback. It does not run test assertions or require Chromium: ```sh -bun run market-making:playground +pnpm run market-making:playground ``` Open the exact URL printed by the command (default `http://127.0.0.1:4173`). Override the listener diff --git a/bots/market-making/docs/architecture.md b/bots/market-making/docs/architecture.md index 4b0c8e95..4cee7300 100644 --- a/bots/market-making/docs/architecture.md +++ b/bots/market-making/docs/architecture.md @@ -149,7 +149,7 @@ historical block and requires `anvil` on `PATH` plus an archive-capable `RPC_URL Run package-focused checks with: ```sh -bun test bots/market-making/test +pnpm --filter @morpho-org/market-making-bot exec vitest run pnpm --filter @morpho-org/market-making-bot run test:e2e pnpm --filter @morpho-org/market-making-bot run typecheck pnpm --filter @morpho-org/market-making-bot run jsdoc:build diff --git a/bots/market-making/package.json b/bots/market-making/package.json index b2f9a03e..d1d41758 100644 --- a/bots/market-making/package.json +++ b/bots/market-making/package.json @@ -4,23 +4,25 @@ "private": true, "license": "Apache-2.0", "bin": { - "mm": "./src/index.ts" + "mm": "./dist/src/index.js" }, "type": "module", "scripts": { - "deploy:railway": "bun run scripts/deploy-railway.ts", - "start": "bun src/index.ts", + "build": "tsx scripts/build.ts", + "deploy:railway": "tsx scripts/deploy-railway.ts", + "jsdoc:build": "pnpm run jsdoc:check && typedoc --options typedoc.json", + "jsdoc:check": "tsx scripts/check-jsdoc.ts", + "prestart": "pnpm --filter \"{.}...\" --if-present run build", "playground:build": "node scripts/playground-build.mjs", - "playground:build:dev": "bun build playground/index.html --outdir playground/dist --target browser --watch", "playground:serve": "node scripts/playground-serve.mjs", "playground:serve:test": "node --test scripts/playground-atomic-publish.test.mjs scripts/playground-build.test.mjs scripts/playground-process.test.mjs scripts/playground-serve.test.mjs", "playground:smoke": "node scripts/playground-smoke.mjs", - "playground:smoke:test": "node --test scripts/playground-smoke.browser.mjs", "playground:smoke:output-probe": "node scripts/playground-smoke-output-probe.mjs", - "test:e2e": "bun test test/e2e", - "typecheck": "tsc --noEmit", - "jsdoc:check": "bun scripts/check-jsdoc.ts", - "jsdoc:build": "pnpm run jsdoc:check && typedoc --options typedoc.json" + "playground:smoke:test": "node --test scripts/playground-smoke.browser.mjs", + "playground:test": "node --test scripts/playground-atomic-publish.test.mjs scripts/playground-build.test.mjs scripts/playground-process.test.mjs scripts/playground-serve.test.mjs scripts/playground-smoke-suite.test.mjs scripts/playground-smoke.test.mjs", + "start": "node --env-file-if-exists=.env dist/src/index.js", + "test:e2e": "vitest run test/e2e", + "typecheck": "tsc --noEmit" }, "dependencies": { "@aws-sdk/client-kms": "3.1101.0", @@ -46,10 +48,15 @@ }, "devDependencies": { "@repo/typescript-config": "workspace:*", - "@types/bun": "catalog:", + "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "esbuild": "catalog:", + "execa": "catalog:", + "tsx": "catalog:", "typedoc": "0.28.20", - "typescript": "catalog:" + "typescript": "catalog:", + "vite": "catalog:", + "vitest": "catalog:" } } diff --git a/bots/market-making/playground/model.ts b/bots/market-making/playground/model.ts index 60393f0e..76fec4e6 100644 --- a/bots/market-making/playground/model.ts +++ b/bots/market-making/playground/model.ts @@ -601,6 +601,12 @@ export const parseCollectionsImport = (text: string): CollectionsImport => { } export const COLLECTION_FRAGMENT_VERSION = 1 +type PlaygroundLocation = { + origin: string + pathname: string + search: string +} + export const encodePlaygroundFragment = (state: PlaygroundState) => { const validation = validatePlaygroundState(state) if (!validation.valid) throw new FragmentCodecError('Fragment state is invalid') @@ -624,10 +630,8 @@ export const encodePlaygroundFragment = (state: PlaygroundState) => { * @returns A canonical absolute URL containing the exact encoded playground fragment. * @throws When either collection is runtime-invalid or the encoded fragment exceeds its size bound. */ -export const createPlaygroundShareUrl = ( - state: PlaygroundState, - location: Pick -) => `${location.origin}${location.pathname}${location.search}${encodePlaygroundFragment(state)}` +export const createPlaygroundShareUrl = (state: PlaygroundState, location: PlaygroundLocation) => + `${location.origin}${location.pathname}${location.search}${encodePlaygroundFragment(state)}` export const decodePlaygroundFragment = (fragment: string): PlaygroundState => { const encoded = fragment.startsWith('#') ? fragment.slice(1) : fragment diff --git a/bots/market-making/scripts/build.ts b/bots/market-making/scripts/build.ts new file mode 100644 index 00000000..5a44eee5 --- /dev/null +++ b/bots/market-making/scripts/build.ts @@ -0,0 +1,30 @@ +import { build as esbuild } from 'esbuild' +import { rmSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { BundleFailedError } from './bundle-failed.error' + +// Bundles the bot entrypoint to `dist/` so production runs a plain `node` with no TypeScript +// transform at startup. This bot holds no soltag `sol``` templates, so no transform plugin is wired. + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const DIST_DIR = join(ROOT, 'dist') +rmSync(DIST_DIR, { recursive: true, force: true }) + +try { + await esbuild({ + entryPoints: [join(ROOT, 'src/index.ts')], + outdir: DIST_DIR, + outbase: ROOT, + bundle: true, + platform: 'node', + format: 'esm', + // CJS deps reaching for require() inside an ESM bundle need a real require. + banner: { + js: "import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);" + } + }) +} catch (error) { + throw new BundleFailedError(error instanceof Error ? error.message : String(error)) +} diff --git a/bots/market-making/scripts/bundle-failed.error.ts b/bots/market-making/scripts/bundle-failed.error.ts new file mode 100644 index 00000000..85b07b64 --- /dev/null +++ b/bots/market-making/scripts/bundle-failed.error.ts @@ -0,0 +1,11 @@ +/** Signals that the production bundle could not be produced. */ +export class BundleFailedError extends Error { + /** + * Creates a tooling failure from the bundler's own message, without retaining source contents. + * @param detail - Bundler-reported reason for the failure. + */ + constructor(readonly detail: string) { + super(`Bundle failed: ${detail}`) + this.name = 'BundleFailedError' + } +} diff --git a/bots/market-making/scripts/check-jsdoc.test.ts b/bots/market-making/scripts/check-jsdoc.test.ts index 045d6f91..77fa423c 100644 --- a/bots/market-making/scripts/check-jsdoc.test.ts +++ b/bots/market-making/scripts/check-jsdoc.test.ts @@ -1,9 +1,6 @@ -import { describe, expect, test } from 'bun:test' -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, relative } from 'node:path' +import { describe, expect, test } from 'vitest' -import { discoverJSDocSourceFiles, inspectJSDocSource } from './check-jsdoc' +import { inspectJSDocSource } from './check-jsdoc' import { JSDocValidationError } from './js-doc-validation.error' const inspect = (source: string) => inspectJSDocSource('fixture.ts', source) @@ -22,39 +19,6 @@ export interface Reader { ` describe('JSDoc contract checker', () => { - test('discovers relevant TypeScript files in deterministic order', async () => { - const packageRoot = await mkdtemp(join(tmpdir(), 'market-making-jsdoc-')) - try { - await Promise.all([ - mkdir(join(packageRoot, 'src/nested'), { recursive: true }), - mkdir(join(packageRoot, 'scripts'), { recursive: true }), - mkdir(join(packageRoot, 'build/generated'), { recursive: true }), - mkdir(join(packageRoot, 'node_modules/dependency'), { recursive: true }) - ]) - await Promise.all([ - writeFile(join(packageRoot, 'src/z.ts'), ''), - writeFile(join(packageRoot, 'src/nested/a.ts'), ''), - writeFile(join(packageRoot, 'src/index.ts'), ''), - writeFile(join(packageRoot, 'scripts/check-jsdoc.ts'), ''), - writeFile(join(packageRoot, 'scripts/js-doc-validation.error.ts'), ''), - writeFile(join(packageRoot, 'scripts/check-jsdoc.test.ts'), ''), - writeFile(join(packageRoot, 'build/generated/output.ts'), ''), - writeFile(join(packageRoot, 'node_modules/dependency/index.ts'), '') - ]) - - const files = await discoverJSDocSourceFiles(packageRoot) - - expect(files.map(file => relative(packageRoot, file))).toEqual([ - 'scripts/check-jsdoc.ts', - 'scripts/js-doc-validation.error.ts', - 'src/nested/a.ts', - 'src/z.ts' - ]) - } finally { - await rm(packageRoot, { recursive: true, force: true }) - } - }) - test('uses a stable typed tooling failure with a safe violation count', () => { const error = new JSDocValidationError(3) diff --git a/bots/market-making/scripts/check-jsdoc.ts b/bots/market-making/scripts/check-jsdoc.ts index fe8e3f6c..968ff5d4 100644 --- a/bots/market-making/scripts/check-jsdoc.ts +++ b/bots/market-making/scripts/check-jsdoc.ts @@ -1,4 +1,6 @@ -import { relative, resolve } from 'node:path' +import { readFile } from 'node:fs/promises' +import { dirname, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import ts from 'typescript' import { JSDocValidationError } from './js-doc-validation.error' @@ -295,7 +297,7 @@ export const inspectJSDocSource = (file: string, source: string): JSDocInspectio return { declarations, failures } } -const packageRoot = resolve(import.meta.dir, '..') +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') /** * Discovers the TypeScript files that define the documented market-making surface. @@ -303,23 +305,21 @@ const packageRoot = resolve(import.meta.dir, '..') * @returns Relevant source and checker files in deterministic package-relative order. * @remarks The scan is read-only and excludes the executable entrypoint and test files. */ -export const discoverJSDocSourceFiles = async (root: string) => { - const glob = new Bun.Glob('{src,scripts}/**/*.ts') - const files: string[] = [] - for await (const file of glob.scan({ cwd: root, absolute: true, onlyFiles: true })) { - const packagePath = relative(root, file) - if (packagePath === 'src/index.ts' || packagePath.endsWith('.test.ts')) continue - files.push(file) - } - return files.toSorted() -} +export const discoverJSDocSourceFiles = async (root: string) => + ts.sys + .readDirectory(root, ['.ts'], ['node_modules'], ['src', 'scripts']) + .filter(file => { + const packagePath = relative(root, file) + return packagePath !== 'src/index.ts' && !packagePath.endsWith('.test.ts') + }) + .toSorted() const run = async () => { const sourceFiles = await discoverJSDocSourceFiles(packageRoot) const failures: JSDocFailure[] = [] const declarations: string[] = [] for (const file of sourceFiles) { - const source = await Bun.file(file).text() + const source = await readFile(file, 'utf8') const inspection = inspectJSDocSource(relative(packageRoot, file), source) declarations.push( ...inspection.declarations.map(item => `${relative(packageRoot, file)} ${item}`) @@ -340,4 +340,6 @@ const run = async () => { } } -if (import.meta.main) await run() +// bun's `import.meta.main`; under Node (and under an esbuild bundle, where it is unreliable) compare +// the resolved entry path instead. +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await run() diff --git a/bots/market-making/scripts/deploy-railway.ts b/bots/market-making/scripts/deploy-railway.ts index e7390765..fa04f9d3 100644 --- a/bots/market-making/scripts/deploy-railway.ts +++ b/bots/market-making/scripts/deploy-railway.ts @@ -7,8 +7,9 @@ * reach a terminal state and succeed only on Railway `SUCCESS`. */ import { delay, tryCatch } from '@repo/utils' -import { $ } from 'bun' -import { resolve } from 'node:path' +import { $ } from 'execa' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { RailwayDeploymentError } from './railway-deployment.error' import { @@ -26,10 +27,10 @@ import { const SERVICE = 'market-making' const DOCKERFILE_PATH = 'bots/market-making/Dockerfile' const STATE_MOUNT_PATH = '/state' -const REPO_ROOT = resolve(import.meta.dir, '..', '..', '..') -const ENVIRONMENT = Bun.env.RAILWAY_ENVIRONMENT?.trim() || 'production' -const DEPLOY_ONLY = /^(1|true)$/i.test(Bun.env.DEPLOY_ONLY?.trim() || '') -const PROJECT_ID = Bun.env.RAILWAY_PROJECT_ID?.trim() +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') +const ENVIRONMENT = process.env.RAILWAY_ENVIRONMENT?.trim() || 'production' +const DEPLOY_ONLY = /^(1|true)$/i.test(process.env.DEPLOY_ONLY?.trim() || '') +const PROJECT_ID = process.env.RAILWAY_PROJECT_ID?.trim() if (!PROJECT_ID) { throw new RailwayDeploymentError('Missing required environment variable: RAILWAY_PROJECT_ID') } @@ -54,7 +55,7 @@ type RequiredRuntimeVariableName = (typeof requiredRuntimeVariableNames)[number] type RuntimeVariable = readonly [name: string, value: string] const required = (name: RequiredRuntimeVariableName) => { - const value = Bun.env[name]?.trim() + const value = process.env[name]?.trim() if (!value) throw new RailwayDeploymentError(`Missing required environment variable: ${name}`) if ((name === 'BOOTSTRAP_MARKETS' || name === 'LADDER_MARKETS') && !isNonEmptyJsonArray(value)) { throw new RailwayDeploymentError(`${name} must be a non-empty JSON array`) @@ -67,9 +68,10 @@ const runtimeVariables = (): RuntimeVariable[] => { const requiredVariables = requiredRuntimeVariableNames.map( name => [name, required(name)] as const ) - const optionalVariables = synchronizedOptionalRailwayVariables(Bun.env) + const optionalVariables = synchronizedOptionalRailwayVariables(process.env) const method = - Bun.env.KEY_STORAGE_METHOD?.trim() || (Bun.env.MAKER_PRIVATE_KEY?.trim() ? 'private-key' : '') + process.env.KEY_STORAGE_METHOD?.trim() || + (process.env.MAKER_PRIVATE_KEY?.trim() ? 'private-key' : '') if (!['private-key', 'keystore', 'aws'].includes(method)) { throw new RailwayDeploymentError('KEY_STORAGE_METHOD must select exactly one signer') } @@ -90,7 +92,7 @@ const runtimeVariables = (): RuntimeVariable[] => { ? ['KEYSTORE_PATH', 'KEYSTORE_PASSWORD'] : ['AWS_KMS_KEY_ID', 'AWS_REGION'] for (const name of signerRequired) { - const value = Bun.env[name]?.trim() + const value = process.env[name]?.trim() if (!value) throw new RailwayDeploymentError(`Missing required environment variable: ${name}`) signerValues[name] = value } @@ -100,25 +102,23 @@ const runtimeVariables = (): RuntimeVariable[] => { } const assertCli = async () => { - const { error } = await tryCatch(Promise.resolve($`railway --version`.quiet())) + const { error } = await tryCatch($`railway --version`) if (error) throw new RailwayDeploymentError('Railway CLI is unavailable') } const ensureContext = async () => { - if (Bun.env.RAILWAY_TOKEN) return + if (process.env.RAILWAY_TOKEN) return const { error } = await tryCatch( - Promise.resolve($`railway link --project ${PROJECT_ID} --environment ${ENVIRONMENT}`.quiet()) + $`railway link --project ${PROJECT_ID} --environment ${ENVIRONMENT}` ) if (error) throw new RailwayDeploymentError('Failed to select the Railway project environment') } const listServices = async () => { const { data, error } = await tryCatch( - Promise.resolve( - $`railway service list --project ${PROJECT_ID} --environment ${ENVIRONMENT} --json` - .quiet() - .text() + $`railway service list --project ${PROJECT_ID} --environment ${ENVIRONMENT} --json`.then( + result => result.stdout ) ) if (error || typeof data !== 'string') { @@ -133,9 +133,9 @@ const ensureService = async () => { const existingService = services.find(service => service.name === SERVICE) if (existingService) return { service: existingService, isFreshService: false } - assertFreshRailwayReferenceProvisioning(Bun.env, true) + assertFreshRailwayReferenceProvisioning(process.env, true) const { data, error } = await tryCatch( - Promise.resolve($`railway add --service ${SERVICE} --json`.quiet().text()) + $`railway add --service ${SERVICE} --json`.then(result => result.stdout) ) if (error || typeof data !== 'string') { throw new RailwayDeploymentError('Failed to create the Railway service') @@ -151,10 +151,8 @@ const ensureService = async () => { const listVolumes = async () => { const { data, error } = await tryCatch( - Promise.resolve( - $`railway volume list --service ${SERVICE} --project ${PROJECT_ID} --environment ${ENVIRONMENT} --json` - .quiet() - .text() + $`railway volume list --service ${SERVICE} --project ${PROJECT_ID} --environment ${ENVIRONMENT} --json`.then( + result => result.stdout ) ) if (error || typeof data !== 'string') { @@ -189,9 +187,7 @@ const ensureStateVolume = async (serviceId: string | undefined) => { } const { error } = await tryCatch( - Promise.resolve( - $`railway volume add --service ${serviceId} --project ${PROJECT_ID} --environment ${ENVIRONMENT} --mount-path ${STATE_MOUNT_PATH} --json`.quiet() - ) + $`railway volume add --service ${serviceId} --project ${PROJECT_ID} --environment ${ENVIRONMENT} --mount-path ${STATE_MOUNT_PATH} --json` ) if (error) throw new RailwayDeploymentError('Failed to create the Railway state volume') @@ -205,9 +201,9 @@ const ensureStateVolume = async (serviceId: string | undefined) => { const setRuntimeVariable = async ([name, value]: RuntimeVariable) => { const { error } = await tryCatch( - Promise.resolve( - $`railway variable set ${name} --stdin --service ${SERVICE} --environment ${ENVIRONMENT} --skip-deploys < ${Buffer.from(value, 'utf8')}`.quiet() - ) + $({ + input: value + })`railway variable set ${name} --stdin --service ${SERVICE} --environment ${ENVIRONMENT} --skip-deploys` ) if (error) throw new RailwayDeploymentError(`Failed to set Railway variable: ${name}`) @@ -216,10 +212,8 @@ const setRuntimeVariable = async ([name, value]: RuntimeVariable) => { const latestDeploymentJson = async () => { const { data, error } = await tryCatch( - Promise.resolve( - $`railway deployment list --service ${SERVICE} --project ${PROJECT_ID} --environment ${ENVIRONMENT} --limit 1 --json` - .quiet() - .text() + $`railway deployment list --service ${SERVICE} --project ${PROJECT_ID} --environment ${ENVIRONMENT} --limit 1 --json`.then( + result => result.stdout ) ) if (error || typeof data !== 'string') { @@ -232,11 +226,9 @@ const latestDeploymentJson = async () => { const startDeployment = async () => { const message = `deploy market-making ${ENVIRONMENT}` const { error } = await tryCatch( - Promise.resolve( - $`railway up --service ${SERVICE} --project ${PROJECT_ID} --environment ${ENVIRONMENT} --detach --message ${message}` - .cwd(REPO_ROOT) - .quiet() - ) + $({ + cwd: REPO_ROOT + })`railway up --service ${SERVICE} --project ${PROJECT_ID} --environment ${ENVIRONMENT} --detach --message ${message}` ) if (error) throw new RailwayDeploymentError('Failed to start the Railway deployment') } diff --git a/bots/market-making/scripts/playground-build-arguments.mjs b/bots/market-making/scripts/playground-build-arguments.mjs index 25b071fa..8d3e0266 100644 --- a/bots/market-making/scripts/playground-build-arguments.mjs +++ b/bots/market-making/scripts/playground-build-arguments.mjs @@ -1,17 +1,16 @@ export const productionPlaygroundBuildArguments = outdir => [ 'build', - 'playground/index.html', - '--outdir', + 'playground', + '--outDir', outdir, '--target', - 'browser', - '--production', - '--entry-naming', - '[name].[ext]', - '--asset-naming', - '[name].[ext]', - '--define', - 'process.env.NODE_ENV="production"', - '--sourcemap=none', - '--env=disable' + 'es2022', + '--minify', + 'esbuild', + '--assetsDir', + '.', + '--base', + './', + '--sourcemap', + 'false' ] diff --git a/bots/market-making/scripts/playground-build.mjs b/bots/market-making/scripts/playground-build.mjs index 436f5536..5f4a2dd1 100644 --- a/bots/market-making/scripts/playground-build.mjs +++ b/bots/market-making/scripts/playground-build.mjs @@ -103,8 +103,8 @@ const buildIdentity = { dev: Number(created.dev), ino: Number(created.ino) } let keepTemporary = false try { - const bun = process.env.BUN_EXE || 'bun' - const child = spawn(bun, productionPlaygroundBuildArguments(buildOutdir), { + const vite = process.env.VITE_EXE || 'vite' + const child = spawn(vite, productionPlaygroundBuildArguments(buildOutdir), { cwd: packageRoot, env: { ...process.env, NODE_ENV: 'production' }, shell: false, diff --git a/bots/market-making/scripts/playground-build.test.mjs b/bots/market-making/scripts/playground-build.test.mjs index df366453..207b0ec0 100644 --- a/bots/market-making/scripts/playground-build.test.mjs +++ b/bots/market-making/scripts/playground-build.test.mjs @@ -14,7 +14,7 @@ import { } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, join } from 'node:path' -import { afterEach, test } from 'node:test' +import { afterEach, beforeEach, test } from 'node:test' import { fileURLToPath } from 'node:url' import { CANONICAL_PUBLISH_TEMP_MARKER } from './playground-atomic-publish.mjs' @@ -28,6 +28,11 @@ const buildScript = join(packageRoot, 'scripts/playground-build.mjs') const canonical = join(packageRoot, 'playground', 'dist') const staleCanonicalAsset = join(canonical, 'offline-clean-only.stale') const owned = new Set() + +beforeEach(t => { + if (process.platform !== 'linux') t.skip('requires Linux canonical-path semantics') +}) + const makeCanonicalChain = async () => { const container = await mkdtemp(join(tmpdir(), 'playground-path-chain-')) owned.add(container) @@ -37,10 +42,10 @@ const makeCanonicalChain = async () => { await mkdir(playground, { recursive: true }) return { container, packageRoot, playground, repoRoot } } -const makeFakeBun = async source => { - const root = await mkdtemp(join(tmpdir(), 'market-making-fake-bun-')) +const makeFakeVite = async source => { + const root = await mkdtemp(join(tmpdir(), 'market-making-fake-vite-')) owned.add(root) - const executable = join(root, 'bun') + const executable = join(root, 'vite') await writeFile(executable, `#!/usr/bin/env node\n${source}`) await chmod(executable, 0o755) return executable @@ -103,17 +108,17 @@ test('CLI exposes only canonical default and --temporary; arbitrary output flags }) test('--temporary creates a private owned OS-temp directory, reports its exact path, and leaves cleanup to caller', async () => { - const fakeBun = await makeFakeBun(` + const fakeVite = await makeFakeVite(` const { writeFileSync } = require('node:fs') const { tmpdir } = require('node:os') const { basename, dirname } = require('node:path') -const outdir = process.argv[process.argv.indexOf('--outdir') + 1] +const outdir = process.argv[process.argv.indexOf('--outDir') + 1] if (dirname(outdir) !== tmpdir()) throw new Error('not under OS temp') if (!basename(outdir).startsWith('market-making-playground-dist-')) throw new Error('wrong prefix') writeFileSync(outdir + '/index.html', '') writeFileSync(outdir + '/index.js', 'globalThis.temporary = true') `) - const result = await runBuild(['--temporary'], { BUN_EXE: fakeBun }) + const result = await runBuild(['--temporary'], { VITE_EXE: fakeVite }) assert.equal(result.code, 0, result.stderr) const record = outputRecord(result.stdout) assert.deepEqual(Object.keys(record).sort(), ['kind', 'mode', 'path']) @@ -131,13 +136,13 @@ test('failed temporary build removes its internally-created partial output', asy const before = new Set( (await readdir(tmpdir())).filter(name => name.startsWith('market-making-playground-dist-')) ) - const fakeBun = await makeFakeBun(` + const fakeVite = await makeFakeVite(` const { writeFileSync } = require('node:fs') -const outdir = process.argv[process.argv.indexOf('--outdir') + 1] +const outdir = process.argv[process.argv.indexOf('--outDir') + 1] writeFileSync(outdir + '/partial.bin', 'partial') process.exit(23) `) - const result = await runBuild(['--temporary'], { BUN_EXE: fakeBun }) + const result = await runBuild(['--temporary'], { VITE_EXE: fakeVite }) assert.notEqual(result.code, 0) assert.match(result.stderr, /Production playground build failed with exit code 23/) const after = (await readdir(tmpdir())).filter( @@ -149,18 +154,18 @@ process.exit(23) test('canonical build stages in owned OS temp, publishes finalized files, and retains old assets', async () => { await mkdir(canonical, { recursive: true }) await writeFile(staleCanonicalAsset, 'retained until an explicit offline clean') - const fakeBun = await makeFakeBun(` + const fakeVite = await makeFakeVite(` const { writeFileSync } = require('node:fs') const { tmpdir } = require('node:os') const { basename, dirname } = require('node:path') -const outdir = process.argv[process.argv.indexOf('--outdir') + 1] +const outdir = process.argv[process.argv.indexOf('--outDir') + 1] if (dirname(outdir) !== tmpdir()) throw new Error('canonical staging not in OS temp') if (!basename(outdir).startsWith('market-making-playground-staging-')) throw new Error('wrong staging prefix') writeFileSync(outdir + '/index.html', '') writeFileSync(outdir + '/index.css', 'body { color: red }') writeFileSync(outdir + '/index.js', 'globalThis.built = true') `) - const result = await runBuild([], { BUN_EXE: fakeBun }) + const result = await runBuild([], { VITE_EXE: fakeVite }) assert.equal(result.code, 0, result.stderr) assert.equal(result.stdout, '') const html = await readFile(join(canonical, 'index.html'), 'utf8') @@ -180,13 +185,13 @@ test('failed canonical build preserves the prior index and removes OS-temp stagi const beforeTemp = new Set( (await readdir(tmpdir())).filter(name => name.startsWith('market-making-playground-staging-')) ) - const fakeBun = await makeFakeBun(` + const fakeVite = await makeFakeVite(` const { writeFileSync } = require('node:fs') -const outdir = process.argv[process.argv.indexOf('--outdir') + 1] +const outdir = process.argv[process.argv.indexOf('--outDir') + 1] writeFileSync(outdir + '/partial', 'never publish me') process.exit(29) `) - const result = await runBuild([], { BUN_EXE: fakeBun }) + const result = await runBuild([], { VITE_EXE: fakeVite }) assert.notEqual(result.code, 0) assert.match(result.stderr, /exit code 29/) assert.deepEqual(await readFile(join(canonical, 'index.html')), beforeIndex) diff --git a/bots/market-making/scripts/playground-process.test.mjs b/bots/market-making/scripts/playground-process.test.mjs index 13698d9e..3803c5f4 100644 --- a/bots/market-making/scripts/playground-process.test.mjs +++ b/bots/market-making/scripts/playground-process.test.mjs @@ -26,7 +26,7 @@ test('pre-aborted commands never spawn', async () => { } }) await assert.rejects( - run({ executable: 'bun', args: [], signal: controller.signal }), + run({ executable: 'tool', args: [], signal: controller.signal }), /already stopped/ ) assert.equal(spawns, 0) @@ -49,7 +49,7 @@ test('an abort raised synchronously inside spawn is caught by the registered lis } }) await assert.rejects( - run({ executable: 'bun', args: [], signal: controller.signal }), + run({ executable: 'tool', args: [], signal: controller.signal }), /spawn-window abort/ ) assert.deepEqual(kills, [ @@ -77,7 +77,7 @@ for (const platform of ['linux', 'darwin']) { } }) const controller = new AbortController() - const result = run({ executable: 'bun', args: ['install'], signal: controller.signal }) + const result = run({ executable: 'tool', args: ['install'], signal: controller.signal }) controller.abort(new Error('cancelled')) await assert.rejects(result, /cancelled/) assert.equal(spawnOptions.detached, true) @@ -97,7 +97,7 @@ test('Windows runner terminates a task tree with argument arrays and no shell', terminationGraceMs: 1, forceKillGraceMs: 10, spawnProcess(executable, args, options) { - if (executable === 'bun.exe') return child + if (executable === 'tool.exe') return child commands.push({ executable, args, options }) const taskkill = fakeChild(999) queueMicrotask(() => taskkill.emit('close', 0, null)) @@ -106,7 +106,7 @@ test('Windows runner terminates a task tree with argument arrays and no shell', } }) const controller = new AbortController() - const result = run({ executable: 'bun.exe', args: ['build'], signal: controller.signal }) + const result = run({ executable: 'tool.exe', args: ['build'], signal: controller.signal }) controller.abort(new Error('cancelled')) await assert.rejects(result, /cancelled/) assert.deepEqual( diff --git a/bots/market-making/scripts/playground-serve-support.mjs b/bots/market-making/scripts/playground-serve-support.mjs index 6629c875..a71671c8 100644 --- a/bots/market-making/scripts/playground-serve-support.mjs +++ b/bots/market-making/scripts/playground-serve-support.mjs @@ -174,7 +174,7 @@ const unresolvedDependencies = packageRoot => { export const ensureFrozenDependencies = async ({ repoRoot, packageRoot, - executable = 'bun', + executable = 'pnpm', env = process.env, chmodFile = chmod, platform = process.platform, @@ -185,7 +185,7 @@ export const ensureFrozenDependencies = async ({ const snapshots = await snapshotWorkspaceBinModes(repoRoot, { packageRoot, platform }) let installError try { - console.log('Checking workspace dependencies with bun install --frozen-lockfile...') + console.log(`Checking workspace dependencies with ${executable} install --frozen-lockfile...`) const result = await processRunner({ executable, args: ['install', '--frozen-lockfile'], diff --git a/bots/market-making/scripts/playground-serve.test.mjs b/bots/market-making/scripts/playground-serve.test.mjs index 2ba075fc..e872d80c 100644 --- a/bots/market-making/scripts/playground-serve.test.mjs +++ b/bots/market-making/scripts/playground-serve.test.mjs @@ -30,6 +30,10 @@ import { } from './playground-serve-support.mjs' import { prepareFreshDist, startStaticServer } from './playground-smoke-support.mjs' +test.beforeEach(t => { + if (process.platform !== 'linux') t.skip('requires Linux process and socket semantics') +}) + const root = fileURLToPath(new URL('..', import.meta.url)) const launcher = fileURLToPath(new URL('./playground-serve.mjs', import.meta.url)) const temporaryDirectories = [] @@ -68,13 +72,17 @@ const waitFor = async operation => { throw lastError } -const assertProcessNotLive = async pid => { - let state = 'missing' +const readProcessState = async (pid, readProcessStat = readFile) => { try { - state = (await readFile(`/proc/${pid}/stat`, 'utf8')).split(' ')[2] + return (await readProcessStat(`/proc/${pid}/stat`, 'utf8')).split(' ')[2] } catch (error) { - if (error.code !== 'ENOENT') throw error + if (error.code === 'ENOENT' || error.code === 'ESRCH') return 'missing' + throw error } +} + +const assertProcessNotLive = async pid => { + const state = await readProcessState(pid) assert.ok(state === 'missing' || state === 'Z', `process ${pid} remains live in state ${state}`) } @@ -93,8 +101,8 @@ const rawHttpRequest = (server, method, path = '/malformed%') => }) }) -const writeBlockingBun = async root => { - const executable = join(root, 'blocking-bun') +const writeBlockingPnpm = async root => { + const executable = join(root, 'blocking-pnpm') await writeFile( executable, `#!/bin/sh\ntrap '' TERM\nsh -c 'trap "" TERM; echo $$ > "$PID_FILE"; while :; do sleep 1; done' &\nwait\n`, @@ -132,7 +140,7 @@ test('a frozen install runs unconditionally and resolves partial workspace depen await writeResolvableDependencies(packageRoot, ['viem']) await mkdir(bin) await writeFile( - join(bin, 'bun'), + join(bin, 'pnpm'), `#!/usr/bin/env node\nconst { mkdirSync, writeFileSync } = require('node:fs')\nconst { join } = require('node:path')\nwriteFileSync(process.env.INSTALL_LOG, JSON.stringify(process.argv.slice(2)))\nfor (const name of ['viem', '@repo/bot-kit']) { const root = join(process.env.PACKAGE_ROOT, 'node_modules', name); mkdirSync(root, { recursive: true }); writeFileSync(join(root, 'package.json'), JSON.stringify({ name, main: 'index.js' })); writeFileSync(join(root, 'index.js'), '') }\n`, { mode: 0o755 } ) @@ -140,7 +148,7 @@ test('a frozen install runs unconditionally and resolves partial workspace depen await ensureFrozenDependencies({ repoRoot, packageRoot, - executable: join(bin, 'bun'), + executable: join(bin, 'pnpm'), env: { ...process.env, INSTALL_LOG: log, PACKAGE_ROOT: packageRoot } }) @@ -162,8 +170,8 @@ test('installed package dependencies still run the fast frozen lockfile check', } }) assert.deepEqual( - calls.map(call => call.args), - [['install', '--frozen-lockfile']] + calls.map(call => [call.executable, call.args]), + [['pnpm', ['install', '--frozen-lockfile']]] ) }) @@ -182,7 +190,7 @@ for (const [platform, originalMode] of [ const repoRoot = await temporaryDirectory(`playground-clean-install-${platform}-`) const packageRoot = join(repoRoot, 'bots/market-making') const entrypoint = join(packageRoot, 'src/index.ts') - const executable = join(repoRoot, 'fake-bun') + const executable = join(repoRoot, 'fake-pnpm') const source = 'console.log("package bin content must stay unchanged")\n' await writeResolvableDependencies(packageRoot) await mkdir(join(packageRoot, 'src'), { recursive: true }) @@ -348,12 +356,12 @@ test('workspace bin discovery skips escaping and symlink targets', async () => { test('frozen install failures report the exact command and exit code', async () => { const repoRoot = await temporaryDirectory('playground-install-failure-') const packageRoot = join(repoRoot, 'bots/market-making') - const executable = join(repoRoot, 'bun-failure') + const executable = join(repoRoot, 'pnpm-failure') await writeResolvableDependencies(packageRoot, []) await writeFile(executable, '#!/usr/bin/env node\nprocess.exit(23)\n', { mode: 0o755 }) await assert.rejects(ensureFrozenDependencies({ repoRoot, packageRoot, executable }), error => { - assert.match(error.message, /bun-failure install --frozen-lockfile failed with exit code 23/) + assert.match(error.message, /pnpm-failure install --frozen-lockfile failed with exit code 23/) return true }) }) @@ -361,7 +369,7 @@ test('frozen install failures report the exact command and exit code', async () test('successful install clearly lists dependencies that remain unresolved', async () => { const repoRoot = await temporaryDirectory('playground-unresolved-') const packageRoot = join(repoRoot, 'bots/market-making') - const executable = join(repoRoot, 'bun-noop') + const executable = join(repoRoot, 'pnpm-noop') await writeResolvableDependencies(packageRoot, []) await writeFile(executable, '#!/usr/bin/env node\n', { mode: 0o755 }) @@ -399,7 +407,7 @@ test('signal during frozen install kills its descendant tree', { timeout: 10_000 const packageRoot = join(repoRoot, 'bots/market-making') const pidFile = join(repoRoot, 'descendant.pid') await writeResolvableDependencies(packageRoot) - const executable = await writeBlockingBun(repoRoot) + const executable = await writeBlockingPnpm(repoRoot) const controller = new AbortController() const pending = ensureFrozenDependencies({ repoRoot, @@ -415,6 +423,11 @@ test('signal during frozen install kills its descendant tree', { timeout: 10_000 await assertProcessNotLive(descendantPid) }) +test('process liveness treats an already-reaped child as missing', async () => { + const error = Object.assign(new Error('no such process'), { code: 'ESRCH' }) + assert.equal(await readProcessState(42, async () => Promise.reject(error)), 'missing') +}) + test('fresh build preserves canonical dist, uses only temporary output, and validates index', async () => { const packageRoot = await temporaryDirectory('playground-fresh-injected-') const stale = join(packageRoot, 'playground/dist') @@ -455,7 +468,7 @@ test( async () => { const packageRoot = await temporaryDirectory('playground-build-signal-') const pidFile = join(packageRoot, 'descendant.pid') - const executable = await writeBlockingBun(packageRoot) + const executable = await writeBlockingPnpm(packageRoot) const controller = new AbortController() let reported = false const runner = createPortableProcessRunner({ terminationGraceMs: 25, forceKillGraceMs: 250 }) diff --git a/bots/market-making/scripts/playground-smoke-suite.test.mjs b/bots/market-making/scripts/playground-smoke-suite.test.mjs index 613cde4b..c4052a0c 100644 --- a/bots/market-making/scripts/playground-smoke-suite.test.mjs +++ b/bots/market-making/scripts/playground-smoke-suite.test.mjs @@ -11,7 +11,7 @@ const scriptsDirectory = fileURLToPath(new URL('.', import.meta.url)) const readJson = async path => JSON.parse(await readFile(path, 'utf8')) -test('CI keeps real-browser tests out of Bun discovery and runs them explicitly after Bun', async () => { +test('CI runs every Node playground suite after the Vitest unit suite', async () => { const [rootPackage, marketMakingPackage, workflow, scriptNames] = await Promise.all([ readJson(join(root, 'package.json')), readJson(join(root, 'bots/market-making/package.json')), @@ -19,30 +19,35 @@ test('CI keeps real-browser tests out of Bun discovery and runs them explicitly readdir(scriptsDirectory) ]) - assert.equal(rootPackage.scripts.test, 'bun test') assert.equal( - rootPackage.scripts['test:browser'], - 'bun run --filter @morpho-org/market-making-bot playground:smoke:test' + rootPackage.scripts.test, + 'vitest run && pnpm --filter @morpho-org/market-making-bot run playground:test' + ) + assert.equal( + marketMakingPackage.scripts['playground:test'], + 'node --test scripts/playground-atomic-publish.test.mjs scripts/playground-build.test.mjs scripts/playground-process.test.mjs scripts/playground-serve.test.mjs scripts/playground-smoke-suite.test.mjs scripts/playground-smoke.test.mjs' ) assert.equal( marketMakingPackage.scripts['playground:smoke:test'], 'node --test scripts/playground-smoke.browser.mjs' ) - const unitStep = workflow.indexOf('- name: Run unit tests\n run: bun test') + const unitStep = workflow.indexOf('- name: Run unit tests\n run: pnpm test') const browserStep = workflow.indexOf( - '- name: Run browser smoke tests\n run: bun test:browser' + '- name: Run browser smoke tests\n run: pnpm --filter @morpho-org/market-making-bot run playground:smoke:test' ) - assert.notEqual(unitStep, -1) - assert.ok(browserStep > unitStep, 'browser smoke command must run after the Bun suite') + assert.ok(unitStep >= 0) + assert.ok(browserStep > unitStep, 'browser smoke command must run after the unit suite') - const bunDiscoveredTests = scriptNames.filter( - name => /\.test\.[cm]?[jt]s$/.test(name) && name !== 'playground-smoke-suite.test.mjs' + const nodeTests = scriptNames.filter( + name => /\.test\.mjs$/.test(name) && name !== 'playground-smoke-suite.test.mjs' ) - const bunSources = await Promise.all( - bunDiscoveredTests.map(name => readFile(join(scriptsDirectory, name), 'utf8')) + for (const name of nodeTests) + assert.ok(marketMakingPackage.scripts['playground:test'].includes(name)) + const nodeSources = await Promise.all( + nodeTests.map(name => readFile(join(scriptsDirectory, name), 'utf8')) ) - for (const source of bunSources) { + for (const source of nodeSources) { assert.doesNotMatch(source, /after Chromium readiness/) assert.doesNotMatch(source, /two complete smoke runs/) assert.doesNotMatch(source, /test\.skip/) @@ -115,7 +120,7 @@ test('browser lifecycle uses separate bounded build, startup, body, UI, CDP, and assert.match(smokeSource, /panel: 'panel-ladder-string'/) }) -test('the declared root lint path effectively checks only the playground smoke mjs files', async () => { +test('the root lint command explicitly checks the playground smoke mjs files', async () => { const [rootPackage, workflow] = await Promise.all([ readJson(join(root, 'package.json')), readFile(join(root, '.github/workflows/checks.yml'), 'utf8') @@ -125,7 +130,7 @@ test('the declared root lint path effectively checks only the playground smoke m rootPackage.scripts['lint:playground-smoke'], 'oxlint --config bots/market-making/scripts/playground-smoke.oxlintrc.json bots/market-making/scripts/playground-smoke*.mjs' ) - assert.match(rootPackage.scripts.lint, /bun run lint:playground-smoke/) + assert.match(rootPackage.scripts.lint, /pnpm run lint:playground-smoke/) assert.match(workflow, /- name: Run lint\n run: pnpm lint/) }) diff --git a/bots/market-making/scripts/playground-smoke.browser.mjs b/bots/market-making/scripts/playground-smoke.browser.mjs index 33af5bd1..920968f8 100644 --- a/bots/market-making/scripts/playground-smoke.browser.mjs +++ b/bots/market-making/scripts/playground-smoke.browser.mjs @@ -1,11 +1,14 @@ import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' import { chmod, mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises' import { connect } from 'node:net' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { productionPlaygroundBuildArguments } from './playground-build-arguments.mjs' import { closeOwnedProcessTreeGracefully, discoverChromium, @@ -19,6 +22,7 @@ import { const temporaryDirectories = [] const harnessRuns = new Set() +const execFileAsync = promisify(execFile) const smokeScript = fileURLToPath(new URL('./playground-smoke.mjs', import.meta.url)) const chromiumPath = await discoverChromium() const { cleanupTimeout, outerReadinessTimeout, browserTestTimeout } = smokeBudgets(process.env) @@ -27,6 +31,19 @@ const temporaryDirectory = async prefix => { temporaryDirectories.push(directory) return directory } +const writeFakeVite = async (path, html) => { + await writeFile( + path, + `#!/usr/bin/env node +const { mkdirSync, writeFileSync } = require('node:fs') +const outdir = process.argv[process.argv.indexOf('--outDir') + 1] +if (!outdir) throw new Error('missing --outDir') +mkdirSync(outdir, { recursive: true }) +writeFileSync(outdir + '/index.html', ${JSON.stringify(html)}) +` + ) + await chmod(path, 0o755) +} test.after(async () => { await Promise.allSettled([...harnessRuns].map(run => cleanupHarnessRun(run))) @@ -35,6 +52,17 @@ test.after(async () => { ) }) +test('fake Vite lifecycle builder follows the production --outDir contract', async () => { + const directory = await temporaryDirectory('playground-fake-vite-contract-') + const executable = join(directory, 'vite') + const outdir = join(directory, 'dist') + await writeFakeVite(executable, 'contract') + + await execFileAsync(executable, productionPlaygroundBuildArguments(outdir)) + + assert.equal(await readFile(join(outdir, 'index.html'), 'utf8'), 'contract') +}) + const processIdentity = async pid => { try { const stat = await readFile(`/proc/${pid}/stat`, 'utf8') @@ -95,7 +123,7 @@ const assertPortClosed = port => const spawnSmoke = ({ env = process.env } = {}) => { const child = spawnOwnedProcess(process.execPath, [smokeScript], { - env, + env: { ...env, NODE_DISABLE_COMPILE_CACHE: '1' }, stdio: ['ignore', 'pipe', 'pipe'] }) child.stdout.setEncoding('utf8') @@ -184,7 +212,7 @@ for (const signal of ['SIGTERM', 'SIGINT']) { test(testName, { timeout: browserTestTimeout }, async () => { const isolatedTmp = await temporaryDirectory(`playground-browser-${signal.toLowerCase()}-`) const bin = join(isolatedTmp, 'bin') - const fakeBun = join(bin, 'bun') + const fakeVite = join(bin, 'vite') const chromiumLink = join(bin, 'chromium') const wrapper = join(isolatedTmp, 'chromium-wrapper') const wrapperPidFile = join(isolatedTmp, 'chromium-wrapper-pid') @@ -196,17 +224,7 @@ for (const signal of ['SIGTERM', 'SIGINT']) { /
[^<]+<\/div><\/div>