diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 649ff0f..5c0bd1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,12 @@ permissions: contents: read jobs: + # The whole suite runs once, at Node 22.12.0. Node 22.8.0 takes npm test's + # --experimental-test-isolation=none, but there every test file fails to + # load with 'Unknown file extension ".ts"'. engines' floor, 22.0.0, and the + # newest Node line get the install and start check below. typecheck-and-test: - name: Typecheck & Test (node 22) + name: Typecheck & Test (node 22.12.0) runs-on: ubuntu-latest timeout-minutes: 10 @@ -23,7 +27,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: - node-version: 22 + node-version: 22.12.0 # npm ci installs the lock verbatim: npm >= 11 'npm install' silently # re-resolves platform packages missing from the lock, so only npm ci @@ -36,3 +40,31 @@ jobs: - name: Test run: npm test + + install-and-start: + name: Install & Start (node ${{ matrix.node }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + node: ['22.0.0', '24'] + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: ${{ matrix.node }} + + - name: Install dependencies (strict lockfile) + run: npm ci + + - name: Build + run: npm run build + + - name: Start + run: node scripts/ci-start-check.mjs diff --git a/README.md b/README.md index dfa2ec7..aa9670d 100644 --- a/README.md +++ b/README.md @@ -461,9 +461,9 @@ npm test npm run build # tsc into dist/ ``` -Node 22, ESM, TypeScript. Stdout carries only JSON-RPC: every diagnostic goes -through the `Logger`, which writes to stderr. No test in this package reaches -the network. +Node 22 (`npm test` runs on 22.12 or newer), ESM, TypeScript. Stdout +carries only JSON-RPC: every diagnostic goes through the `Logger`, which +writes to stderr. No test in this package reaches the network. | File | Work package | Role | | --- | --- | --- | diff --git a/scripts/ci-start-check.mjs b/scripts/ci-start-check.mjs new file mode 100644 index 0000000..55ec7c7 --- /dev/null +++ b/scripts/ci-start-check.mjs @@ -0,0 +1,116 @@ +import { spawn } from "node:child_process"; +import { generateKeyPairSync } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SERVER = fileURLToPath(new URL("../dist/src/index.js", import.meta.url)); +const ANSWER_MS = 30_000; +const EXIT_MS = 10_000; + +const dir = mkdtempSync(join(tmpdir(), "github-mcpl-start-")); +const keyFile = join(dir, "app.pem"); +writeFileSync( + keyFile, + generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + }).privateKey, + { mode: 0o600 }, +); + +const child = spawn(process.execPath, [SERVER, "--stdio"], { + env: { + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_FILE: keyFile, + GITHUB_REPOS: "example/repo", + GITHUB_BOT_LOGIN: "example[bot]", + GITHUB_POLL_INTERVAL_MS: "0", + }, + stdio: ["pipe", "pipe", "pipe"], +}); + +let stdout = ""; +let stderr = ""; +let answered = false; +let finished = false; +let timer = setTimeout(() => finish(1, `no answer to initialize within ${ANSWER_MS / 1000} s`), ANSWER_MS); + +function finish(code, message) { + if (finished) return; + finished = true; + clearTimeout(timer); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + rmSync(dir, { recursive: true, force: true }); + const line = `ci-start-check (Node ${process.version}): ${message}`; + if (code === 0) console.log(line); + else { + console.error(line); + if (stderr.trim()) console.error(`server stderr:\n${stderr.trimEnd()}`); + } + process.exitCode = code; +} + +function clip(text, max) { + return text.length > max ? `${text.slice(0, max)}…` : text; +} + +function onAnswer(line) { + let msg; + try { + msg = JSON.parse(line); + } catch { + finish(1, `the server wrote a line that isn't JSON: ${clip(line, 200)}`); + return; + } + if (msg === null || typeof msg !== "object" || Array.isArray(msg)) { + finish(1, `the server wrote a line that isn't a JSON-RPC message: ${clip(line, 200)}`); + return; + } + if (msg.id !== 1) return; + const mcpl = msg.result?.capabilities?.experimental?.mcpl; + if (msg.jsonrpc !== "2.0" || "error" in msg || mcpl === null || typeof mcpl !== "object") { + finish(1, `initialize was answered without an MCPL result: ${clip(line, 300)}`); + return; + } + answered = true; + clearTimeout(timer); + timer = setTimeout(() => finish(1, `the server didn't exit within ${EXIT_MS / 1000} s of its stdin closing`), EXIT_MS); + child.stdin.end(); +} + +child.stdout.setEncoding("utf8"); +child.stdout.on("data", (chunk) => { + stdout += chunk; + let nl; + while (!answered && !finished && (nl = stdout.indexOf("\n")) >= 0) { + const line = stdout.slice(0, nl).trim(); + stdout = stdout.slice(nl + 1); + if (line) onAnswer(line); + } +}); +child.stderr.setEncoding("utf8"); +child.stderr.on("data", (chunk) => (stderr += chunk)); +child.on("error", (err) => finish(1, `couldn't start the server: ${err.message}`)); +child.on("close", (code, signal) => { + if (finished) return; + if (!answered) finish(1, `the server exited (${signal ?? `code ${code}`}) before answering initialize`); + else if (code !== 0) finish(1, `the server exited (${signal ?? `code ${code}`}) after answering initialize, not with 0`); + else finish(0, "the built server answered initialize over --stdio and exited 0"); +}); + +child.stdin.on("error", () => {}); +child.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: { experimental: { mcpl: { version: "0.5" } } }, + clientInfo: { name: "ci-start-check", version: "0" }, + }, + }) + "\n", +);