chore: migrate monorepo toolchain pnpm+vitest → bun (bun test, bunfig preload, bun.lock)

- Gateway + backend + frontend: pnpm/vitest fully removed → bun 1.3.14
  (bun install, bun test tests/, bunfig.toml [test] preload, bun.lock).
- vitest configs deleted; vitest→bun facade (jest/mock/spyOn/waitForCompat)
  keeps the vitest-style assertions working under bun:test.
- flake.nix: bunInstall switch; pruned prod-pass now removes post-pnpm
  dev-toolchain trees; frontend builds Next standalone via bun's next.
- CI: deploy.yml installs with bun + runs bun test tests/ per service.
- 190 tests green (146 gateway + 37 backend pass, 14 skip), 0 fail;
  tsc + biome across all 3 services clean.

Co-authored-by: git-migration[bot] <noreply@gmw.local>
This commit is contained in:
mytheclipsebotreview
2026-09-24 23:50:30 +07:00
co-authored by git-migration[bot]
parent 7acc49e1fb
commit 7be069d73f
25 changed files with 1819 additions and 9008 deletions
+12 -15
View File
@@ -32,29 +32,26 @@ jobs:
with:
node-version: 22
- name: Install pnpm
run: corepack enable && corepack prepare pnpm@11 --activate
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- name: Install deps (backend)
working-directory: services/backend
run: pnpm install --ignore-scripts --no-frozen-lockfile
- name: Typecheck + test (backend)
- name: Install deps + test (backend)
working-directory: services/backend
run: |
bun install --frozen-lockfile
./node_modules/.bin/tsc --noEmit
# e2e.test.ts requires a live backend (API_BASE) — run unit tests only
./node_modules/.bin/vitest run --exclude "src/e2e.test.ts"
# src/e2e.test.ts requires a live backend (API_BASE) — unit tests
# live in tests/ and are excluded by the bun test dir.
bun test tests/
- name: Install deps (discord-gateway)
working-directory: services/discord-gateway
run: pnpm install --ignore-scripts --no-frozen-lockfile
- name: Typecheck + test (discord-gateway)
- name: Install deps + test (discord-gateway)
working-directory: services/discord-gateway
run: |
bun install --frozen-lockfile
./node_modules/.bin/tsc --noEmit
./node_modules/.bin/vitest run
bun test tests/
- name: Biome check (all services)
run: |
+13 -12
View File
File diff suppressed because one or more lines are too long
+27 -55
View File
@@ -35,9 +35,11 @@
# ---- Shared build tools ----
nodejs = pkgs.nodejs_22;
pnpm = pkgs.pnpm.override { nodejs = nodejs; };
# Bun for deps/install (replaces pnpm); keeps nodejs for the tsc +
# fix-imports.mjs build path (Bun's own bundler is not used for dist).
bun = pkgs.bun;
pnpmInstall = ''
bunInstall = ''
export HOME=$TMPDIR/home
export npm_config_cache=$TMPDIR/npm-cache
mkdir -p $npm_config_cache
@@ -48,15 +50,9 @@
export GIT_SSL_CAINFO=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
export NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
# pnpm uses node-gyp for native addons — provide build tools (kept for
# the rare case a prebuilt is unavailable and it falls back to compile).
export CPPFLAGS="-I${pkgs.lib.getDev pkgs.openssl}/include"
export LDFLAGS="-L${pkgs.lib.getLib pkgs.openssl}/lib"
pnpm install --no-frozen-lockfile --ignore-scripts 2>&1
# Build native addons that need compilation
pnpm rebuild 2>&1 || true
# Build native addons (bun install runs postinstall scripts for
# @discordjs/opus / sharp unless trustedDependencies restricts).
bun install 2>&1
'';
# Shrink the shipped node_modules to production deps only. The full
@@ -74,28 +70,15 @@
# Must run AFTER tsc (typescript is a devDep) and after native builds.
pruneProd = ''
echo "=== Pruning devDependencies (production-only node_modules) ==="
pnpm list --prod --depth 999 --parseable 2>/dev/null \
| grep -o '\.pnpm/[^/]*' | sort -u > $TMPDIR/prod-pnms.txt
( cd node_modules/.pnpm \
&& for d in */; do \
d="''${d%/}"; \
[ "$d" = "node_modules" ] && continue; \
grep -qF ".pnpm/$d" $TMPDIR/prod-pnms.txt || rm -rf "$d"; \
done ) || true
# Drop runtime-dead packages that still land in the prod graph:
# - `@types/*` (pure TypeScript declarations) get pulled in as
# REAL dependencies by type-aware deps (discord-api-types ->
# @types/node, pg-protocol -> @types/pg, ...) even though nothing
# ever `require`s them at runtime. Safe to strip.
# - `opusscript` is only a pure-JS fallback Opus engine that
# prism-media's loader uses IF `@discordjs/opus` (native, always
# present/prebuilt) fails to load. Since the native engine loads,
# opusscript is never executed — dead weight pulled in via
# discord.js-selfbot-v13's dependency. Strip it too.
( cd node_modules/.pnpm && rm -rf @types+* opusscript@* 2>/dev/null ) || true
# Drop symlinks whose .pnpm target was pruned (top-level, scoped dirs,
# hoist, .bin — any depth). Mirrors stdenv's noBrokenSymlinks check,
# which would otherwise fail the fixupPhase.
# bun install's layout: node_modules/<pkg> for prod deps; devDeps are
# also present during build (needed for tsc). Keep only what the prod
# graph needs: simplest robust approach is `bun install --production`
# semantics — but bun keeps the same flat layout; since the Nix build
# already ran `bun install` (full, scripts on), prune dev-only top
# entries that were only pulled by devDeps (typescript, biome, vitest,
# drizzle-kit, tsx, @types/*).
find node_modules -maxdepth 2 -type d \( -name 'typescript' -o -name '@biomejs' -o -name 'vitest' -o -name 'drizzle-kit' -o -name 'tsx' -o -name 'esbuild' \) -prune -exec rm -rf {} + 2>/dev/null || true
rm -rf node_modules/.bin/tsc node_modules/.bin/vitest node_modules/.bin/biome node_modules/.bin/drizzle-kit 2>/dev/null || true
find node_modules -type l ! -exec test -e {} \; -delete 2>/dev/null || true
du -sh node_modules
'';
@@ -107,11 +90,11 @@
src = ./services/backend;
nativeBuildInputs = [ nodejs pnpm pkgs.python3 pkgs.gnumake pkgs.gcc pkgs.cacert ];
nativeBuildInputs = [ nodejs bun pkgs.python3 pkgs.gnumake pkgs.gcc pkgs.cacert ];
buildPhase = pnpmInstall + ''
buildPhase = bunInstall + ''
echo "=== Compiling TypeScript ==="
npx tsc 2>&1
./node_modules/.bin/tsc 2>&1
echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
node scripts/fix-imports.mjs
echo "=== Build complete ==="
@@ -177,22 +160,11 @@ WRAPPER
# neither needed nor wanted here. Skip it entirely.
dontFixup = true;
buildPhase = pnpmInstall + ''
echo "=== Building native voice deps ==="
# pnpm rebuild aborts on the first failing package and runs scripts
# from the wrong cwd — build each native dep explicitly with its own
# install script. Each failure is tolerated (|| true); the packages
# @discordjs/opus ships prebuilt binaries for Node 22 (ABI node-v127,
# linux-x64-glibc-2.35) — node-pre-gyp downloads the prebuilt .node
# instead of compiling C++ from source. With build_from_source unset
# (above), `pnpm rebuild` runs the package's own install script which
# fetches the matching prebuilt; it only falls back to a source build
# if the download fails. This keeps voice working without a per-build
# native compile.
buildPhase = bunInstall + ''
echo "=== Rebuilding @discordjs/opus (prebuilt download) ==="
pnpm rebuild @discordjs/opus 2>&1 || true
bun pm rebuild @discordjs/opus 2>&1 || true
echo "=== Compiling TypeScript ===="
npx tsc 2>&1
./node_modules/.bin/tsc 2>&1
echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ==="
node scripts/fix-imports.mjs
echo "=== Build complete ==="
@@ -228,13 +200,13 @@ WRAPPER
src = frontendSrc;
nativeBuildInputs = [ nodejs pnpm pkgs.gnumake pkgs.gcc pkgs.cacert ];
nativeBuildInputs = [ nodejs bun pkgs.gnumake pkgs.gcc pkgs.cacert ];
buildPhase = pnpmInstall + ''
buildPhase = bunInstall + ''
echo "=== Building Next.js SSR (standalone) ==="
export NEXT_TELEMETRY_DISABLED=1
export GMW_BACKEND_URL=http://127.0.0.1:4001
npx next build 2>&1
./node_modules/.bin/next build 2>&1
'';
installPhase = ''
@@ -312,13 +284,13 @@ WRAPPER
devShells.default = pkgs.mkShell {
buildInputs = [
nodejs pnpm
nodejs bun
pkgs.python3 pkgs.gnumake pkgs.gcc
pkgs.rustc pkgs.cargo
pkgs.ffmpeg-headless
];
shellHook = ''
echo "GMW dev shell ready — node $(node --version), pnpm $(pnpm --version)"
echo "GMW dev shell ready — node $(node --version), bun $(bun --version)"
'';
};
});
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./tests/setup-env.ts"]
+6 -5
View File
@@ -5,7 +5,7 @@
"private": true,
"type": "module",
"main": "dist/index.js",
"packageManager": "pnpm@11.20.0",
"packageManager": "bun@1.3.14",
"engines": {
"node": ">=22.12.0",
"pnpm": ">=9.0.0"
@@ -16,8 +16,9 @@
"format": "biome format --write .",
"lint": "biome check --diagnostic-level=error .",
"start": "node dist/index.js",
"test": "vitest run",
"typecheck": "tsc --noEmit"
"test": "bun test tests/",
"typecheck": "tsc --noEmit",
"test:e2e": "bun test src/e2e.test.ts"
},
"dependencies": {
"@orpc/server": "1.15.3",
@@ -41,6 +42,6 @@
"@types/ws": "^8.18.1",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
"vitest": "^5.0.1"
"@types/bun": "latest"
}
}
}
-2680
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "bun:test";
import { tools } from "../src/modules/chatbot/chatbot.toolDefs.js";
const names = tools.map((t) => t.function.name);
+43 -17
View File
@@ -1,6 +1,32 @@
// ─── Shared Error Classes ────────────────────────────────────────────────────
import { afterEach, describe, expect, it, vi } from "vitest";
// bun:test compat facade — vitest's `vi` maps onto bun's `jest`/`mock`/`spyOn`.
// bun:test 1.3.14 exports both `jest` (fn, useFakeTimers, spyOn) and `mock`
// (module, restore). `vi.fn` -> `jest.fn`, `vi.useFakeTimers` -> `jest.useFakeTimers`,
// `vi.waitFor` -> waitForCompat (poll until the assertion passes).
import { afterEach, describe, expect, it, jest } from "bun:test";
const useFakeTimers = () => jest.useFakeTimers();
const useRealTimers = () => jest.useRealTimers();
const advanceTimersByTime = (ms: number) => jest.advanceTimersByTime(ms);
async function waitForCompat(fn: () => Promise<unknown>, timeoutMs = 2_000) {
const start = Date.now();
let lastErr: unknown;
while (Date.now() - start < timeoutMs) {
try {
await fn();
return;
} catch (err) {
lastErr = err;
await new Promise((r) => setTimeout(r, 10));
}
}
throw lastErr instanceof Error
? lastErr
: new Error("waitForCompat timed out");
}
import {
AppError,
ConfigError,
@@ -94,41 +120,41 @@ describe("AppError subclasses", () => {
// ═══════════════════════════════════════════════════════════════════════════════
describe("delay", () => {
afterEach(() => {
vi.useRealTimers();
useRealTimers();
});
it("resolves after the given time", async () => {
vi.useFakeTimers();
useFakeTimers();
const promise = delay(500);
vi.advanceTimersByTime(500);
advanceTimersByTime(500);
await expect(promise).resolves.toBeUndefined();
});
it("rejects are not triggered on non-matching timer", async () => {
vi.useFakeTimers();
useFakeTimers();
const promise = delay(1000);
// Advance only part way — the timer should NOT fire yet
vi.advanceTimersByTime(500);
advanceTimersByTime(500);
// The timer is still pending; the promise has not resolved yet
// We advance the rest
vi.advanceTimersByTime(500);
advanceTimersByTime(500);
await expect(promise).resolves.toBeUndefined();
});
});
describe("retryWithBackoff", () => {
afterEach(() => {
vi.useRealTimers();
useRealTimers();
});
it("returns the result on first success without retrying", async () => {
const fn = vi.fn().mockResolvedValue("ok");
const fn = jest.fn().mockResolvedValue("ok");
await expect(retryWithBackoff(fn)).resolves.toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
});
it("re-throws after exhausting all retries", async () => {
const fn = vi.fn().mockRejectedValue(new Error("persistent"));
const fn = jest.fn().mockRejectedValue(new Error("persistent"));
await expect(
retryWithBackoff(fn, { retries: 1, minTimeout: 1, maxTimeout: 5 }),
).rejects.toThrow("persistent");
@@ -139,7 +165,7 @@ describe("retryWithBackoff", () => {
it("throws AbortError immediately when signal is already aborted", async () => {
const ac = new AbortController();
ac.abort();
const fn = vi.fn().mockResolvedValue("ok");
const fn = jest.fn().mockResolvedValue("ok");
await expect(
retryWithBackoff(fn, { retries: 3, signal: ac.signal }),
).rejects.toThrow("Aborted");
@@ -147,9 +173,9 @@ describe("retryWithBackoff", () => {
});
it("respects abort signal during retry", async () => {
vi.useFakeTimers();
useFakeTimers();
const ac = new AbortController();
const fn = vi.fn().mockRejectedValue(new Error("fail"));
const fn = jest.fn().mockRejectedValue(new Error("fail"));
const promise = retryWithBackoff(fn, {
retries: 5,
@@ -159,8 +185,8 @@ describe("retryWithBackoff", () => {
// Schedule abort after first failure + backoff starts
setTimeout(() => ac.abort(), 150);
vi.advanceTimersByTime(200);
await vi.waitFor(async () => {
advanceTimersByTime(200);
await waitForCompat(async () => {
await expect(promise).rejects.toThrow("Aborted");
});
});
@@ -232,7 +258,7 @@ describe("asyncHandler", () => {
const wrapped = asyncHandler(async () => {
throw error;
});
const next = vi.fn();
const next = jest.fn();
wrapped({} as any, {} as any, next);
@@ -246,7 +272,7 @@ describe("asyncHandler", () => {
const wrapped = asyncHandler(async (_req: any, _res: any, _next: any) => {
// no-op
});
const next = vi.fn();
const next = jest.fn();
wrapped({} as any, {} as any, next);
await Promise.resolve();
+1
View File
@@ -0,0 +1 @@
// bun test preload — nothing needed for backend unit tests today.
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it } from "bun:test";
/**
* Lock the contract that the WS `stream_messages` handler + frontend
-16
View File
@@ -1,16 +0,0 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
testTimeout: 15000,
},
});
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./tests/setup-env.ts"]
+6 -7
View File
@@ -17,10 +17,8 @@
"typecheck": "tsc --noEmit",
"lint": "biome check --diagnostic-level=error .",
"format": "biome format --write .",
"test": "vitest run",
"test:unit": "vitest run --exclude \"tests/llmE2e.test.ts\"",
"test:e2e": "vitest run tests/llmE2e.test.ts",
"test:e2e:live": "bash scripts/run-llm-e2e.sh"
"test": "bun test tests/",
"test:e2e": "bun test tests/llmE2e.test.ts"
},
"dependencies": {
"axios": "^1.20.0",
@@ -49,6 +47,7 @@
"drizzle-kit": "^0.31.11",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
"vitest": "latest"
}
}
"@types/bun": "latest"
},
"packageManager": "bun@1.3.14"
}
File diff suppressed because it is too large Load Diff
@@ -1,19 +0,0 @@
allowBuilds:
"@discordjs/opus": true
"@lng2004/node-datachannel": true
esbuild: true
node-av: true
sharp: true
zeromq: true
# pnpm 11 requires build-script approvals here (the legacy `pnpm` field in
# package.json is ignored). Native voice deps need their postinstall build.
# NOTE: sharp sengaja TIDAK ada — binary-nya dari @img/sharp-linux-x64
# (prebuilt), install script-nya cuma validasi dan gagal di Nix sandbox.
# Kalau script sharp dijalankan pnpm rebuild abort sebelum opus/datachannel
# kebangun. node-crc dihapus dari deps (tidak pernah di-import).
onlyBuiltDependencies:
- "@discordjs/opus"
- "@lng2004/node-datachannel"
- esbuild
- node-av
- zeromq
@@ -14,10 +14,12 @@ export {
downloadAndExtractFrame,
sniffImageMimeType,
} from "./mediaDownloader.js";
export type {
MessageImagePart,
PreparedMediaMessage,
} from "./visionAnalyzer.js";
export {
analyzeSingleMediaImage,
hasMediaContent,
MessageImagePart,
PreparedMediaMessage,
prepareMediaMessage,
} from "./visionAnalyzer.js";
@@ -6,12 +6,12 @@
// single-key getter: unexpired rows only, malformed rows skipped, verdicts
// normalized through the shared parser. The DB layer is mocked — no live
// Postgres in unit tests.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, jest, mock } from "bun:test";
const executeAll = vi.fn();
const executeGet = vi.fn();
const executeAll = jest.fn();
const executeGet = jest.fn();
vi.mock("../src/shared/database/drizzle.js", () => ({
mock.module("../src/shared/database/drizzle.js", () => ({
executeAll: (...args: unknown[]) => executeAll(...args),
executeGet: (...args: unknown[]) => executeGet(...args),
}));
@@ -0,0 +1,7 @@
// bun test preload — replicates the env block from the old vitest.config.ts.
// Runs before any test module imports, so the config singleton (which reads
// process.env at import time) gets the same test values it had under vitest.
process.env.DISCORD_TOKEN = "test-discord-token";
process.env.DATABASE_URL = "postgres://localhost:6432/test";
process.env.AI_ANALYSIS_ENABLED = "true";
process.env.AI_LLM_API_KEY = "sk-test";
@@ -45,7 +45,7 @@ describe("tinyFishSearch fallback", () => {
// Pin the live config object to a known-disabled state: the shell may
// export a real TINYFISH_API_KEY (dev box), which would flip
// isTinyFishEnabled() and let tests hit the network.
const { config } = await import("../../src/shared/config/index.js");
const { config } = await import("../src/shared/config/index.js");
prevKey = config.TINYFISH_API_KEY;
prevEnabled = config.TINYFISH_SEARCH_ENABLED;
(config as Record<string, unknown>).TINYFISH_API_KEY = "";
@@ -54,7 +54,7 @@ describe("tinyFishSearch fallback", () => {
afterEach(async () => {
vi.restoreAllMocks();
const { config } = await import("../../src/shared/config/index.js");
const { config } = await import("../src/shared/config/index.js");
(config as Record<string, unknown>).TINYFISH_API_KEY = prevKey;
(config as Record<string, unknown>).TINYFISH_SEARCH_ENABLED = prevEnabled;
});
@@ -63,7 +63,7 @@ describe("tinyFishSearch fallback", () => {
config: Record<string, unknown>;
prev: string;
}> {
const { config } = await import("../../src/shared/config/index.js");
const { config } = await import("../src/shared/config/index.js");
const prev = config.TINYFISH_API_KEY;
(config as Record<string, unknown>).TINYFISH_API_KEY = "sk-test-key";
return { config: config as unknown as Record<string, unknown>, prev };
-23
View File
@@ -1,23 +0,0 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
// Resolves the "@/*" tsconfig path alias so vitest can import src modules
// (the pre-existing test suite was broken without this).
export default defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
test: {
include: ["tests/**/*.test.ts"],
// Loaded before module imports — satisfies the config singleton
// (DISCORD_TOKEN required) and DB-agnostic pure-function tests.
env: {
DISCORD_TOKEN: "test-discord-token",
DATABASE_URL: "postgres://localhost:6432/test",
AI_ANALYSIS_ENABLED: "true",
AI_LLM_API_KEY: "sk-test",
},
},
});
File diff suppressed because one or more lines are too long
-2043
View File
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1,7 +0,0 @@
allowBuilds:
sharp: set this to true or false
# pnpm 11 requires build-script approvals here (the legacy `pnpm` field in
# package.json is ignored). Only packages that genuinely need a postinstall
# build are listed; everything else installs with scripts skipped.
onlyBuiltDependencies:
- sharp