Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 })`.

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
201 changes: 201 additions & 0 deletions tests/smoke/startup-smoke.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading