diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 5b315b9..240aa4f 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -32,6 +32,10 @@ jobs: - name: Run repository verification run: npm run verify + - name: Run startup smoke test + if: ${{ matrix.os == 'macos-latest' || matrix.os == 'windows-latest' }} + run: npm run smoke:startup + - name: Generate coverage report if: ${{ matrix.os == 'ubuntu-latest' }} run: npx vitest run --coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index bea7c80..a2a7073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to Tandem Browser will be documented in this file. ## Unreleased +### Test + +- test: Add a Node startup smoke runner that boots Tandem through the + cross-platform startup helper, polls `http://127.0.0.1:8765/status`, and runs + in GitHub Actions on Windows and macOS. + ### Changed - chore: Add a separate Windows release-build workflow that runs on manual diff --git a/TODO.md b/TODO.md index cde852d..dd51cb7 100644 --- a/TODO.md +++ b/TODO.md @@ -4,7 +4,7 @@ > Historical release summaries belong in `CHANGELOG.md`. > Architecture and product context belong in `PROJECT.md`. -Last updated: May 4, 2026 +Last updated: May 5, 2026 ## Purpose @@ -64,6 +64,7 @@ Last updated: May 4, 2026 - [ ] Make `ContextBridge` summaries natively actor/workspace-aware so `/context/summary` and other non-MCP consumers stop relying on MCP-side enrichment for ownership context - [ ] Expand the new handoff system beyond the first Activity-tab inbox with a dedicated handoff history/detail view; task-linked ready/resume/approve/reject actions now flow through a shared task↔handoff coordinator - [x] Add GitHub Actions verification for `npm run verify` on pushes and pull requests +- [x] Add Windows and macOS startup smoke coverage in GitHub Actions by booting Tandem and polling `http://127.0.0.1:8765/status` - [ ] Remove deprecated voice-transcription and live-mode main-process code after the shell-side cleanup lands (PR #TBD): preload bindings `window.tandem.transcribeAudio` and `window.tandem.onLiveModeChanged`, IPC handlers, HTTP route `POST /live/toggle` on port 8765, audio transcription pipeline, and MCP tools `tandem_live_toggle`, `tandem_live_status`, `tandem_audio_start`, `tandem_audio_stop`, `tandem_audio_status`, `tandem_audio_recordings`. The shell no longer references any of these. - [ ] Restore image support in Wingman chat by routing image sends through the OpenClaw gateway. The legacy `GET/POST /chat` polling bridge was disabled on 17 March 2026 (commit `ede27d82`) and text sends were migrated to the gateway WebSocket, but the image path was not. Today `IpcChannels.CHAT_SEND_IMAGE` in `src/ipc/handlers.ts` only saves the base64 to `~/.tandem/chat-images/` and fires `PanelManager.fireWebhook()` with a `[image attached]` marker — no bytes or URL travel to OpenClaw. Fix: send images through the same gateway path as text (multimodal payload, or upload via `src/api/routes/media.ts` `media-chat-image` bucket and include the URL in the gateway message). Shell-side, collapse `window.tandem.sendChatImage` into `router.sendMessage(text, { image })`. diff --git a/package.json b/package.json index 48cc555..a2c553a 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "postinstall": "electron-rebuild -f -w better-sqlite3", "rebuild": "electron-rebuild -f -w better-sqlite3", "verify": "npm run compile && npm run lint && npm test && npm run check-consistency", + "smoke:startup": "node tests/smoke/startup-smoke.js", "test": "vitest run", "test:watch": "vitest", "test:security": "vitest run src/security/tests/", diff --git a/tests/smoke/startup-smoke.js b/tests/smoke/startup-smoke.js new file mode 100644 index 0000000..2a0fac2 --- /dev/null +++ b/tests/smoke/startup-smoke.js @@ -0,0 +1,201 @@ +#!/usr/bin/env node +const { execFile, spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const root = path.join(__dirname, '..', '..'); +const startScript = path.join(root, 'scripts', 'start.js'); +const statusUrl = process.env.TANDEM_SMOKE_STATUS_URL || 'http://127.0.0.1:8765/status'; +const timeoutMs = Number.parseInt(process.env.TANDEM_SMOKE_TIMEOUT_MS || '60000', 10); +const pollIntervalMs = Number.parseInt(process.env.TANDEM_SMOKE_POLL_MS || '1000', 10); +const requestTimeoutMs = Number.parseInt(process.env.TANDEM_SMOKE_REQUEST_MS || '2500', 10); + +let child = null; +let childError = null; +let cleanupStarted = false; + +function log(message) { + console.log(`[smoke:startup] ${message}`); +} + +function assertCompiledAppExists() { + const mainPath = path.join(root, 'dist', 'main.js'); + if (!fs.existsSync(mainPath)) { + throw new Error(`Compiled app entry not found at ${mainPath}. Run npm run compile before the startup smoke test.`); + } +} + +function pipeOutput(stream, label) { + stream.setEncoding('utf8'); + stream.on('data', (chunk) => { + for (const line of chunk.split(/\r?\n/)) { + if (line.trim()) { + console.log(`[smoke:startup:${label}] ${line}`); + } + } + }); +} + +function spawnTandem() { + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + delete env.ATOM_SHELL_INTERNAL_RUN_AS_NODE; + + log('Starting Tandem Browser via scripts/start.js --skip-compile'); + const spawned = spawn(process.execPath, [startScript, '--skip-compile'], { + cwd: root, + env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + pipeOutput(spawned.stdout, 'stdout'); + pipeOutput(spawned.stderr, 'stderr'); + spawned.on('exit', (code, signal) => { + if (!cleanupStarted) { + log(`Tandem process exited before smoke completion (code=${code}, signal=${signal || 'none'})`); + } + }); + spawned.on('error', (error) => { + childError = error; + }); + + return spawned; +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function hasChildExited() { + return !child || child.exitCode !== null || child.signalCode !== null; +} + +function describeChildExit() { + if (!child) return 'process unavailable'; + return `code=${child.exitCode}, signal=${child.signalCode || 'none'}`; +} + +async function fetchWithTimeout(url) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), requestTimeoutMs); + try { + return await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +async function waitForStatus() { + const deadline = Date.now() + timeoutMs; + let attempts = 0; + let lastError = 'not attempted yet'; + + while (Date.now() < deadline) { + attempts += 1; + + if (childError) { + throw childError; + } + + if (hasChildExited()) { + throw new Error(`Tandem exited before ${statusUrl} became reachable (${describeChildExit()})`); + } + + let response; + try { + response = await fetchWithTimeout(statusUrl); + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + log(`Waiting for API (${attempts}): ${lastError}`); + await delay(pollIntervalMs); + continue; + } + + if (!response.ok) { + const body = await response.text(); + throw new Error(`${statusUrl} returned HTTP ${response.status}: ${body}`); + } + + const body = await response.text(); + log(`Reached ${statusUrl} after ${attempts} attempt(s): ${body}`); + return; + } + + throw new Error(`Timed out after ${timeoutMs}ms waiting for ${statusUrl}. Last error: ${lastError}`); +} + +function execFileAsync(command, args) { + return new Promise((resolve) => { + execFile(command, args, { cwd: root, windowsHide: true }, (error, stdout, stderr) => { + resolve({ error, stdout: stdout || '', stderr: stderr || '' }); + }); + }); +} + +async function stopWindowsProcessTree(pid) { + const result = await execFileAsync('taskkill.exe', ['/PID', String(pid), '/T', '/F']); + if (result.error) { + log(`taskkill reported: ${result.error.message}`); + } +} + +async function waitForChildExit(ms) { + if (hasChildExited()) return; + + await Promise.race([ + new Promise((resolve) => child.once('exit', resolve)), + delay(ms), + ]); +} + +async function cleanup() { + if (cleanupStarted || !child) return; + cleanupStarted = true; + + if (hasChildExited()) return; + + log('Stopping Tandem Browser'); + if (process.platform === 'win32') { + if (!child.pid) return; + await stopWindowsProcessTree(child.pid); + return; + } + + child.kill('SIGTERM'); + await waitForChildExit(5000); + if (!hasChildExited()) { + log('Tandem did not exit after SIGTERM; sending SIGKILL'); + child.kill('SIGKILL'); + await waitForChildExit(2000); + } +} + +async function main() { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error('TANDEM_SMOKE_TIMEOUT_MS must be a positive integer'); + } + + assertCompiledAppExists(); + child = spawnTandem(); + + try { + await waitForStatus(); + } finally { + await cleanup(); + } +} + +process.on('SIGINT', () => { + void cleanup().finally(() => process.exit(130)); +}); + +process.on('SIGTERM', () => { + void cleanup().finally(() => process.exit(143)); +}); + +main().catch(async (error) => { + console.error(`[smoke:startup] ${error instanceof Error ? error.message : error}`); + await cleanup(); + process.exit(1); +});