diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..847cffa8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +# Runs the workspace's typecheck, production build, and full test suite on every +# pull request and on pushes to main. Purpose: catch compile errors and test +# regressions before merge instead of relying on each contributor running tests +# locally. Once this check is green on a PR, enable branch protection on `main` +# ("Require status checks to pass" → select "Build & test") to make it blocking. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +# A newer commit on the same branch/PR supersedes in-flight runs — cancel the +# stale one so a rapid push sequence doesn't queue redundant CI. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: Build & test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.34.3 + + # Node 20 is the project's supported runtime (package.json engines pins + # >=20 <21, .nvmrc says 20). Running the matrix on 20 also means the fresh + # install below pulls better-sqlite3's prebuilt binary for the correct ABI, + # which is what makes the server suite runnable in CI. + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + # The desktop postinstall tries to rebuild the native uiohook-napi module. + # It fails gracefully (|| warn) without X11 dev headers, and nothing in CI + # needs the native binary — the desktop tests and TS compile are pure JS — + # so we intentionally skip installing those headers to keep CI fast. + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Compiles shared → server → web (each runs tsc; web also runs the Vite + # production build). This is the typecheck + build gate for those three. + - name: Build shared, server & web + run: pnpm build + + # Desktop is not part of `pnpm build` (that produces an Electron installer, + # which release.yml owns). Type-check its source here so desktop TS errors + # surface on PRs rather than only at release-tag time. + - name: Typecheck desktop + run: pnpm --filter @backspace/desktop build:ts + + # Runs every package's `test` script (server, web, desktop) via vitest. + - name: Test + run: pnpm -r test diff --git a/package.json b/package.json index 7e679181..a2e04279 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "build:server": "pnpm --filter @backspace/server build", "build:web": "pnpm --filter @backspace/web build", "build": "pnpm --filter @backspace/shared build && pnpm --filter @backspace/server build && pnpm --filter @backspace/web build", + "typecheck": "pnpm --filter @backspace/shared build && pnpm -r typecheck", + "test": "pnpm -r test", "dev:desktop": "pnpm --filter @backspace/desktop dev", "build:desktop": "pnpm --filter @backspace/desktop build", "gen-icons": "node scripts/gen-icons.mjs" diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index 9605564b..97777c83 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -1,7 +1,13 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig, configDefaults } from 'vitest/config'; export default defineConfig({ test: { setupFiles: ['./test/setup-env.ts'], + // Vitest 4's default `exclude` is only node_modules/.git — it no longer + // ignores dist/. Once `pnpm build` (tsc) has emitted the compiled test files + // into dist/, vitest would otherwise run those stale .js copies alongside the + // real src/*.test.ts — and they fail, because compiled vi.mock() paths + // resolve differently than the source. Never run build output as tests. + exclude: [...configDefaults.exclude, 'dist/**'], }, }); diff --git a/packages/web/package.json b/packages/web/package.json index e1cf77ba..928f0c69 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -8,6 +8,9 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "preview": "vite preview", "e2e:identity-deletion": "playwright test e2e/identity-deletion.spec.ts" }, diff --git a/packages/web/src/test/setup.ts b/packages/web/src/test/setup.ts index 4fad696b..d7d1a0ca 100644 --- a/packages/web/src/test/setup.ts +++ b/packages/web/src/test/setup.ts @@ -70,6 +70,25 @@ if (typeof ClipboardItem === 'undefined') { }; } +// jsdom's Blob implementation has no `.stream()` method on Node 20 (Node 25+ +// happens to provide one, which is why this only surfaces on the pinned target +// runtime). undici's `Response` constructor extracts a Blob body by calling +// `blob.stream()`, so `new Response(blob)` throws "object.stream is not a +// function" without this. Back it with the blob's own arrayBuffer(). +if (typeof Blob !== 'undefined' && typeof Blob.prototype.stream !== 'function') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Blob.prototype as any).stream = function stream(this: Blob): ReadableStream { + const blob = this; + return new ReadableStream({ + async start(controller) { + const buffer = await blob.arrayBuffer(); + controller.enqueue(new Uint8Array(buffer)); + controller.close(); + }, + }); + }; +} + // Patch globalThis.Response to preserve Blob content-type in jsdom. // jsdom's fetch Response.blob() drops the Blob's MIME type; this shim // restores it so tests that construct `new Response(blob)` behave correctly. diff --git a/packages/web/src/vite-env.d.ts b/packages/web/src/vite-env.d.ts index a84033a1..d46205b2 100644 --- a/packages/web/src/vite-env.d.ts +++ b/packages/web/src/vite-env.d.ts @@ -1,4 +1,5 @@ /// +/// interface ImportMetaEnv { readonly VITE_FORCE_BOOT_STALL?: string;