Browse Source

test: add release artifact and host smoke checks (#316)

* test: add release artifact and host smoke checks

* fix: handle noisy pack output in smoke checks
Alvin 3 months ago
parent
commit
ae6755823d
4 changed files with 533 additions and 1 deletions
  1. 30 1
      .github/workflows/ci.yml
  2. 2 0
      package.json
  3. 306 0
      scripts/verify-opencode-host-smoke.ts
  4. 195 0
      scripts/verify-release-artifact.ts

+ 30 - 1
.github/workflows/ci.yml

@@ -36,4 +36,33 @@ jobs:
         run: bun test
 
       - name: Build
-        run: bun run build
+        run: bun run build
+
+  package-smoke:
+    runs-on: ${{ matrix.os }}
+
+    strategy:
+      matrix:
+        os: [ubuntu-latest, macos-latest]
+        bun-version: [latest]
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Setup Bun
+        uses: oven-sh/setup-bun@v2
+        with:
+          bun-version: ${{ matrix.bun-version }}
+
+      - name: Install dependencies
+        run: bun install --frozen-lockfile
+
+      - name: Build
+        run: bun run build
+
+      - name: Verify packaged release artifact
+        run: bun run verify:release
+
+      - name: Verify OpenCode host smoke test
+        run: bun run verify:host-smoke

+ 2 - 0
package.json

@@ -44,6 +44,8 @@
     "contributors:check": "all-contributors check",
     "contributors:generate": "all-contributors generate",
     "generate-schema": "bun run scripts/generate-schema.ts",
+    "verify:release": "bun run scripts/verify-release-artifact.ts",
+    "verify:host-smoke": "bun run scripts/verify-opencode-host-smoke.ts",
     "typecheck": "tsc --noEmit",
     "test": "bun test",
     "lint": "biome lint .",

+ 306 - 0
scripts/verify-opencode-host-smoke.ts

@@ -0,0 +1,306 @@
+import { spawn, spawnSync } from 'node:child_process';
+import {
+  copyFileSync,
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
+import { createServer } from 'node:net';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(__dirname, '..');
+const distEntry = path.join(repoRoot, 'dist', 'index.js');
+
+function fail(message: string): never {
+  throw new Error(message);
+}
+
+function run(
+  command: string,
+  args: string[],
+  options: { cwd?: string; env?: Record<string, string> } = {},
+) {
+  const result = spawnSync(command, args, {
+    cwd: options.cwd ?? repoRoot,
+    env: {
+      ...process.env,
+      ...options.env,
+    },
+    encoding: 'utf8',
+    stdio: ['ignore', 'pipe', 'pipe'],
+  });
+
+  if (result.status !== 0) {
+    const detail = [result.stdout, result.stderr].filter(Boolean).join('\n');
+    fail(
+      `Command failed: ${command} ${args.join(' ')}${detail ? `\n${detail}` : ''}`,
+    );
+  }
+
+  return result.stdout.trim();
+}
+
+function parsePackJson(output: string) {
+  const start = output.indexOf('[');
+  const end = output.lastIndexOf(']');
+
+  if (start === -1 || end === -1 || end < start) {
+    fail(`Could not locate npm pack JSON output:\n${output}`);
+  }
+
+  return JSON.parse(output.slice(start, end + 1)) as Array<{
+    filename?: string;
+  }>;
+}
+
+function packArtifact() {
+  const output = run('npm', ['pack', '--json', '--ignore-scripts']);
+  const parsed = parsePackJson(output);
+  const tarball = parsed[0]?.filename;
+  if (!tarball) fail(`npm pack did not return a tarball filename:\n${output}`);
+  return path.join(repoRoot, tarball);
+}
+
+async function getFreePort() {
+  const server = createServer();
+  return await new Promise<number>((resolve, reject) => {
+    server.once('error', reject);
+    server.listen(0, '127.0.0.1', () => {
+      const address = server.address();
+      if (!address || typeof address === 'string') {
+        server.close();
+        reject(new Error('Failed to allocate free port'));
+        return;
+      }
+      const { port } = address;
+      server.close((error) => {
+        if (error) reject(error);
+        else resolve(port);
+      });
+    });
+  });
+}
+
+async function waitForHealth(url: string, timeoutMs: number) {
+  const deadline = Date.now() + timeoutMs;
+  let lastError = 'health check did not succeed';
+
+  while (Date.now() < deadline) {
+    try {
+      const response = await fetch(url);
+      if (response.ok) return;
+      lastError = `health check returned ${response.status}`;
+    } catch (error) {
+      lastError = error instanceof Error ? error.message : String(error);
+    }
+    await new Promise((resolve) => setTimeout(resolve, 250));
+  }
+
+  fail(`OpenCode server did not become healthy: ${lastError}`);
+}
+
+async function stopProcess(child: ReturnType<typeof spawn>) {
+  if (child.exitCode !== null) return;
+
+  child.kill('SIGTERM');
+  const exited = await Promise.race([
+    new Promise<boolean>((resolve) => child.once('exit', () => resolve(true))),
+    new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 5000)),
+  ]);
+
+  if (!exited && child.exitCode === null) {
+    child.kill('SIGKILL');
+    await new Promise((resolve) => child.once('exit', resolve));
+  }
+}
+
+function assertNoPluginLoadErrors(logs: string) {
+  const badPatterns = [
+    /failed to load plugin/i,
+    /cannot find module/i,
+    /error=.*failed to load plugin/i,
+  ];
+
+  const match = badPatterns.find((pattern) => pattern.test(logs));
+  if (!match) return;
+
+  const relevantLines = logs
+    .split(/\r?\n/)
+    .filter((line) =>
+      /plugin|failed to load|cannot find module|error=/i.test(line),
+    )
+    .slice(-20)
+    .join('\n');
+
+  fail(
+    `OpenCode logs contain plugin load errors:${relevantLines ? `\n${relevantLines}` : ''}`,
+  );
+}
+
+async function verifyHostSmoke(tarballPath: string) {
+  const tempRoot = mkdtempSync(path.join(tmpdir(), 'omos-opencode-smoke-'));
+  const homeDir = path.join(tempRoot, 'home');
+  const configDir = path.join(tempRoot, 'config');
+  const cacheDir = path.join(tempRoot, 'cache');
+  const dataDir = path.join(tempRoot, 'data');
+  const hostDir = path.join(tempRoot, 'host');
+  const workspaceDir = path.join(tempRoot, 'workspace');
+  const tarballTarget = path.join(tempRoot, path.basename(tarballPath));
+  const port = await getFreePort();
+
+  try {
+    console.log('Packing plugin tarball into isolated test root...');
+    copyFileSync(tarballPath, tarballTarget);
+
+    for (const dir of [
+      homeDir,
+      configDir,
+      cacheDir,
+      dataDir,
+      hostDir,
+      workspaceDir,
+    ]) {
+      mkdirSync(dir, { recursive: true });
+    }
+
+    const pluginDir = path.join(configDir, 'plugins');
+    mkdirSync(pluginDir, { recursive: true });
+
+    writeFileSync(
+      path.join(hostDir, 'package.json'),
+      JSON.stringify(
+        { name: 'verify-opencode-host-smoke', private: true },
+        null,
+        2,
+      ),
+    );
+
+    console.log('Installing opencode-ai into isolated test root...');
+    run('bun', ['add', 'opencode-ai@latest'], { cwd: hostDir });
+
+    const opencodeBin = path.join(hostDir, 'node_modules', '.bin', 'opencode');
+    if (!existsSync(opencodeBin)) {
+      fail(`Expected opencode binary at ${opencodeBin}`);
+    }
+
+    writeFileSync(
+      path.join(configDir, 'package.json'),
+      JSON.stringify(
+        {
+          type: 'module',
+          dependencies: {
+            'oh-my-opencode-slim': `file:${tarballTarget}`,
+          },
+        },
+        null,
+        2,
+      ),
+    );
+    writeFileSync(
+      path.join(pluginDir, 'load-oh-my-opencode-slim.js'),
+      "export { default } from 'oh-my-opencode-slim';\n",
+    );
+
+    const config = JSON.stringify({
+      $schema: 'https://opencode.ai/config.json',
+      autoupdate: false,
+      share: 'disabled',
+      snapshot: false,
+    });
+
+    const env = {
+      HOME: homeDir,
+      XDG_CONFIG_HOME: configDir,
+      XDG_CACHE_HOME: cacheDir,
+      XDG_DATA_HOME: dataDir,
+      OPENCODE_CONFIG_DIR: configDir,
+      OPENCODE_CONFIG_CONTENT: config,
+      OPENCODE_DISABLE_AUTOUPDATE: 'true',
+      OPENCODE_DISABLE_MODELS_FETCH: 'true',
+      OPENCODE_DISABLE_DEFAULT_PLUGINS: 'true',
+    };
+
+    console.log('Starting opencode serve with packaged plugin...');
+    const child = spawn(
+      opencodeBin,
+      [
+        'serve',
+        '--print-logs',
+        '--log-level',
+        'DEBUG',
+        '--hostname',
+        '127.0.0.1',
+        '--port',
+        String(port),
+      ],
+      {
+        cwd: workspaceDir,
+        env: {
+          ...process.env,
+          ...env,
+        },
+        stdio: ['ignore', 'pipe', 'pipe'],
+      },
+    );
+
+    let stdout = '';
+    let stderr = '';
+    child.stdout?.on('data', (chunk) => {
+      stdout += String(chunk);
+    });
+    child.stderr?.on('data', (chunk) => {
+      stderr += String(chunk);
+    });
+
+    const exitPromise = new Promise<never>((_, reject) => {
+      child.once('exit', (code, signal) => {
+        reject(
+          new Error(
+            `opencode serve exited before smoke test completed (code=${code}, signal=${signal})\n${stdout}\n${stderr}`,
+          ),
+        );
+      });
+    });
+
+    await Promise.race([
+      waitForHealth(`http://127.0.0.1:${port}/health`, 30000),
+      exitPromise,
+    ]);
+
+    await new Promise((resolve) => setTimeout(resolve, 1500));
+    assertNoPluginLoadErrors(`${stdout}\n${stderr}`);
+
+    await stopProcess(child);
+  } finally {
+    rmSync(tempRoot, { recursive: true, force: true });
+  }
+}
+
+function cleanupTarball(tarballPath: string) {
+  rmSync(tarballPath, { force: true });
+}
+
+async function main() {
+  if (!existsSync(distEntry)) {
+    fail(
+      'dist/index.js is missing. Run `bun run build` before verify:host-smoke.',
+    );
+  }
+
+  const tarballPath = packArtifact();
+  try {
+    await verifyHostSmoke(tarballPath);
+  } finally {
+    cleanupTarball(tarballPath);
+  }
+
+  console.log('OpenCode host smoke verification passed.');
+}
+
+await main();
+process.exit(0);

+ 195 - 0
scripts/verify-release-artifact.ts

@@ -0,0 +1,195 @@
+import { spawnSync } from 'node:child_process';
+import {
+  copyFileSync,
+  mkdirSync,
+  mkdtempSync,
+  readdirSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const repoRoot = path.resolve(__dirname, '..');
+const distDir = path.join(repoRoot, 'dist');
+
+const suspiciousPathPatterns = [
+  /\/Users\/[^\s'"`]+(?:node_modules|oh-my-opencode-slim)[^\s'"`]*/,
+  /\/home\/[^\s'"`]+(?:node_modules|oh-my-opencode-slim)[^\s'"`]*/,
+];
+
+const packagedRequiredFiles = [
+  'package.json',
+  'README.md',
+  'LICENSE',
+  'dist/index.js',
+  'dist/index.d.ts',
+  'dist/cli/index.js',
+  'oh-my-opencode-slim.schema.json',
+  'src/skills/cartography/SKILL.md',
+];
+
+function fail(message: string): never {
+  throw new Error(message);
+}
+
+function run(command: string, args: string[], options: { cwd?: string } = {}) {
+  const result = spawnSync(command, args, {
+    cwd: options.cwd ?? repoRoot,
+    encoding: 'utf8',
+    stdio: ['ignore', 'pipe', 'pipe'],
+  });
+
+  if (result.status !== 0) {
+    const detail = [result.stdout, result.stderr].filter(Boolean).join('\n');
+    fail(
+      `Command failed: ${command} ${args.join(' ')}${detail ? `\n${detail}` : ''}`,
+    );
+  }
+
+  return result.stdout.trim();
+}
+
+function parsePackJson(output: string) {
+  const start = output.indexOf('[');
+  const end = output.lastIndexOf(']');
+
+  if (start === -1 || end === -1 || end < start) {
+    fail(`Could not locate npm pack JSON output:\n${output}`);
+  }
+
+  return JSON.parse(output.slice(start, end + 1)) as Array<{
+    filename?: string;
+    files?: Array<{ path: string }>;
+  }>;
+}
+
+function walkFiles(dir: string): string[] {
+  const entries = readdirSync(dir, { withFileTypes: true });
+  return entries.flatMap((entry) => {
+    const fullPath = path.join(dir, entry.name);
+    if (entry.isDirectory()) return walkFiles(fullPath);
+    return [fullPath];
+  });
+}
+
+function verifyDistHasNoLeakedPaths() {
+  console.log('Checking dist for leaked machine paths...');
+  const files = walkFiles(distDir).filter((file) =>
+    /\.(?:js|d\.ts|map|json)$/.test(file),
+  );
+
+  const leaks: string[] = [];
+  for (const file of files) {
+    const content = readFileSync(file, 'utf8');
+    for (const pattern of suspiciousPathPatterns) {
+      const match = content.match(pattern);
+      if (!match) continue;
+      leaks.push(`${path.relative(repoRoot, file)}: ${match[0]}`);
+    }
+  }
+
+  if (leaks.length > 0) {
+    fail(
+      `Built artifact contains machine-specific paths:\n${leaks.join('\n')}`,
+    );
+  }
+}
+
+function packArtifact() {
+  console.log('Packing npm artifact...');
+  const output = run('npm', ['pack', '--json', '--ignore-scripts'], {
+    cwd: repoRoot,
+  });
+  const parsed = parsePackJson(output);
+  const tarball = parsed[0]?.filename;
+
+  if (!tarball) {
+    fail(`npm pack did not return a tarball filename:\n${output}`);
+  }
+
+  const packagedFiles = new Set(
+    (parsed[0]?.files ?? []).map((file) => file.path),
+  );
+  for (const requiredFile of packagedRequiredFiles) {
+    if (!packagedFiles.has(requiredFile)) {
+      fail(`npm pack artifact is missing required file: ${requiredFile}`);
+    }
+  }
+
+  return path.join(repoRoot, tarball);
+}
+
+function verifyFreshInstall(tarballPath: string) {
+  const tempRoot = mkdtempSync(path.join(tmpdir(), 'omos-release-'));
+
+  try {
+    console.log('Installing packed artifact into clean temp project...');
+    const installDir = path.join(tempRoot, 'install');
+    const tarballTarget = path.join(tempRoot, path.basename(tarballPath));
+
+    copyFileSync(tarballPath, tarballTarget);
+    mkdirSync(installDir, { recursive: true });
+    writeFileSync(
+      path.join(installDir, 'package.json'),
+      JSON.stringify(
+        { name: 'verify-release-artifact', private: true },
+        null,
+        2,
+      ),
+    );
+    run('bun', ['add', '--ignore-scripts', tarballTarget], {
+      cwd: installDir,
+    });
+
+    const installedEntry = path.join(
+      installDir,
+      'node_modules',
+      'oh-my-opencode-slim',
+      'dist',
+      'index.js',
+    );
+    const installedEntryContent = readFileSync(installedEntry, 'utf8');
+    for (const pattern of suspiciousPathPatterns) {
+      const match = installedEntryContent.match(pattern);
+      if (match) {
+        fail(
+          `Installed package still contains machine-specific path: ${match[0]}`,
+        );
+      }
+    }
+
+    const smokeScript = [
+      "import pkg from 'oh-my-opencode-slim';",
+      "if (typeof pkg !== 'function') throw new Error('default export is not a function');",
+      "console.log('package loads');",
+      'process.exit(0);',
+    ].join('\n');
+    console.log('Importing installed package entrypoint...');
+    run('node', ['--input-type=module', '--eval', smokeScript], {
+      cwd: installDir,
+    });
+  } finally {
+    rmSync(tempRoot, { recursive: true, force: true });
+  }
+}
+
+function cleanupTarball(tarballPath: string) {
+  rmSync(tarballPath, { force: true });
+}
+
+function main() {
+  verifyDistHasNoLeakedPaths();
+  const tarballPath = packArtifact();
+  try {
+    verifyFreshInstall(tarballPath);
+  } finally {
+    cleanupTarball(tarballPath);
+  }
+  console.log('Release artifact verification passed.');
+}
+
+main();