diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb8ec791f..6568157f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,10 +25,59 @@ concurrency: cancel-in-progress: true jobs: + packaged_changes: + name: Detect packaged smoke changes + runs-on: ubuntu-latest + outputs: + required: ${{ steps.detect.outputs.required }} + + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + + - name: Detect desktop/sidecar/packaging changes + id: detect + shell: bash + run: | + set -euo pipefail + required=false + if [ "${{ github.event_name }}" = "pull_request" ]; then + git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" > "$RUNNER_TEMP/changed-files.txt" + patterns=( + "apps/desktop/" + "apps/packaged/" + "apps/daemon/src/sidecar/" + "apps/web/sidecar/" + "packages/platform/" + "packages/sidecar/" + "packages/sidecar-proto/" + "tools/pack/" + "e2e/lib/desktop/" + ) + while IFS= read -r file; do + for pattern in "${patterns[@]}"; do + if [[ "$file" == "$pattern"* ]]; then + required=true + fi + done + if [[ "$file" == "e2e/specs/mac.spec.ts" || "$file" == "e2e/specs/win.spec.ts" || "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/release-beta.yml" ]]; then + required=true + fi + if [ "$required" = "true" ]; then + break + fi + done < "$RUNNER_TEMP/changed-files.txt" + else + required=true + fi + echo "required=$required" >> "$GITHUB_OUTPUT" + validate: name: Validate workspace runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 45 steps: - name: Checkout @@ -102,3 +151,291 @@ jobs: # races outweigh the lost parallelism; revisit if the package count grows. - name: Build workspaces run: pnpm -r --workspace-concurrency=1 --if-present run build + + packaged_smoke_mac: + name: Packaged mac smoke + needs: [validate, packaged_changes] + if: ${{ needs.packaged_changes.outputs.required == 'true' }} + runs-on: macos-14 + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + with: + version: 10.33.2 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build PR mac artifacts + run: | + set -euo pipefail + pnpm exec tools-pack mac build \ + --dir "$RUNNER_TEMP/tools-pack" \ + --namespace ci-pr-mac \ + --mac-compression normal \ + --to all \ + --json + + - name: Smoke PR mac packaged runtime + working-directory: e2e + env: + OD_PACKAGED_E2E_MAC: "1" + OD_PACKAGED_E2E_NAMESPACE: ci-pr-mac + OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack + run: pnpm test specs/mac.spec.ts + + packaged_smoke_win: + name: Packaged windows smoke + needs: [validate, packaged_changes] + if: ${{ needs.packaged_changes.outputs.required == 'true' }} + runs-on: windows-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + with: + version: 10.33.2 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Compute Windows tools-pack cache key + id: win_tools_pack_cache_key + shell: pwsh + run: | + $epoch = (Get-Date).ToUniversalTime().ToString("yyyy-MM") + "epoch=$epoch" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + - name: Restore Windows tools-pack cache + id: win_tools_pack_cache_restore + uses: actions/cache/restore@v5 + continue-on-error: true + with: + path: ${{ runner.temp }}/tools-pack-cache + key: tools-pack-win-v6-${{ runner.os }}-${{ steps.win_tools_pack_cache_key.outputs.epoch }}-${{ github.sha }} + restore-keys: | + tools-pack-win-v6-${{ runner.os }}-${{ steps.win_tools_pack_cache_key.outputs.epoch }}- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Setup NSIS + shell: pwsh + run: | + if ((Get-Command makensis.exe -ErrorAction SilentlyContinue) -or (Test-Path "C:\Program Files (x86)\NSIS\makensis.exe")) { + exit 0 + } + choco install nsis -y --no-progress + + - name: Build PR windows artifacts + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $toolsPackDir = "${{ runner.temp }}/tools-pack" + $cacheDir = "${{ runner.temp }}/tools-pack-cache" + $buildJsonPath = Join-Path $env:RUNNER_TEMP "windows-tools-pack-build.json" + $buildArgs = @( + "exec", "tools-pack", "win", "build", + "--dir", $toolsPackDir, + "--cache-dir", $cacheDir, + "--namespace", "ci-pr-win", + "--portable", + "--to", "nsis", + "--json" + ) + try { + $buildOutput = pnpm @buildArgs + if ($LASTEXITCODE -ne 0) { + throw "Windows tools-pack cached build exited with code $LASTEXITCODE" + } + } catch { + Write-Warning "Windows tools-pack cached build failed; removing restored cache and retrying with a clean cache. Failure: $_" + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $cacheDir + $buildOutput = pnpm exec tools-pack win build ` + --dir $toolsPackDir ` + --cache-dir $cacheDir ` + --namespace ci-pr-win ` + --portable ` + --to nsis ` + --json + if ($LASTEXITCODE -ne 0) { + throw "Windows tools-pack clean-cache fallback build exited with code $LASTEXITCODE" + } + } + $buildOutput | Set-Content -Path $buildJsonPath + $buildOutput + + - name: Smoke PR windows packaged runtime + working-directory: e2e + env: + OD_PACKAGED_E2E_WIN: "1" + OD_PACKAGED_E2E_NAMESPACE: ci-pr-win + OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack + OD_PACKAGED_E2E_SCREENSHOT_PATH: ${{ runner.temp }}/open-design-win-smoke.png + run: pnpm test specs/win.spec.ts + + - name: Prune Windows tools-pack cache + if: ${{ !cancelled() }} + shell: pwsh + continue-on-error: true + run: | + $cacheRoot = Join-Path $env:RUNNER_TEMP "tools-pack-cache" + if (!(Test-Path $cacheRoot)) { + "tools-pack cache root does not exist; nothing to prune" + exit 0 + } + + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue (Join-Path $cacheRoot "locks") + + $maxBytes = 6GB + $entryRoot = Join-Path $cacheRoot "entries" + if (!(Test-Path $entryRoot)) { + "tools-pack cache entries root does not exist; nothing to prune" + exit 0 + } + + $discardedBytes = 0L + $discardedCount = 0 + $packagedAppRoot = Join-Path $entryRoot "win.packaged-app" + if (Test-Path $packagedAppRoot) { + $packagedAppEntries = Get-ChildItem -Path $packagedAppRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { Test-Path (Join-Path $_.FullName "manifest.json") } + foreach ($entry in $packagedAppEntries) { + $size = (Get-ChildItem -Path $entry.FullName -Recurse -File -Force -ErrorAction SilentlyContinue | + Measure-Object -Property Length -Sum).Sum + Remove-Item -Recurse -Force -LiteralPath $entry.FullName + $discardedBytes += [int64]($size ?? 0) + $discardedCount += 1 + } + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $packagedAppRoot + } + + $priorityByNode = @{ + "win.electron-builder-dir" = 0 + "win.workspace-build" = 1 + "win.resource-tree" = 2 + "win.workspace-tarballs" = 3 + } + + $entries = Get-ChildItem -Path $entryRoot -Directory -Recurse | + Where-Object { Test-Path (Join-Path $_.FullName "manifest.json") } | + ForEach-Object { + $size = (Get-ChildItem -Path $_.FullName -Recurse -File -Force -ErrorAction SilentlyContinue | + Measure-Object -Property Length -Sum).Sum + $node = Split-Path (Split-Path $_.FullName -Parent) -Leaf + [pscustomobject]@{ + Path = $_.FullName + Node = $node + Priority = [int]($priorityByNode[$node] ?? 100) + Size = [int64]($size ?? 0) + LastWriteTimeUtc = $_.LastWriteTimeUtc + } + } | + Sort-Object Priority, @{ Expression = "LastWriteTimeUtc"; Descending = $true } + + $keptBytes = 0L + $removedBytes = 0L + $removedCount = 0 + foreach ($entry in $entries) { + if (($keptBytes + $entry.Size) -le $maxBytes) { + $keptBytes += $entry.Size + continue + } + Remove-Item -Recurse -Force -LiteralPath $entry.Path + $removedBytes += $entry.Size + $removedCount += 1 + } + + "keptBytes=$keptBytes removedBytes=$removedBytes removedCount=$removedCount discardedBytes=$discardedBytes discardedCount=$discardedCount maxBytes=$maxBytes" + + - name: Summarize Windows tools-pack build + if: ${{ !cancelled() }} + shell: pwsh + continue-on-error: true + run: | + $summaryPath = $env:GITHUB_STEP_SUMMARY + $buildJsonPath = Join-Path $env:RUNNER_TEMP "windows-tools-pack-build.json" + if (!(Test-Path $buildJsonPath)) { + "### Windows tools-pack build" | Add-Content -Path $summaryPath + "" | Add-Content -Path $summaryPath + "Build JSON was not found at `$buildJsonPath`." | Add-Content -Path $summaryPath + exit 0 + } + + $build = Get-Content -Raw -Path $buildJsonPath | ConvertFrom-Json + "### Windows tools-pack build" | Add-Content -Path $summaryPath + "" | Add-Content -Path $summaryPath + "| Phase | Duration |" | Add-Content -Path $summaryPath + "| --- | ---: |" | Add-Content -Path $summaryPath + foreach ($timing in $build.timings) { + $seconds = [math]::Round(([double]$timing.durationMs) / 1000, 1) + "| `$($timing.phase)` | ${seconds}s |" | Add-Content -Path $summaryPath + } + + "" | Add-Content -Path $summaryPath + "| Cache node | Status | Reason | Duration |" | Add-Content -Path $summaryPath + "| --- | --- | --- | ---: |" | Add-Content -Path $summaryPath + foreach ($entry in $build.cacheReport.entries) { + $seconds = [math]::Round(([double]$entry.durationMs) / 1000, 1) + $reason = if ($null -eq $entry.reason) { "" } else { [string]$entry.reason } + "| `$($entry.nodeId)` | `$($entry.status)` | $reason | ${seconds}s |" | Add-Content -Path $summaryPath + } + + $cacheRoot = Join-Path $env:RUNNER_TEMP "tools-pack-cache" + $entryRoot = Join-Path $cacheRoot "entries" + if (Test-Path $entryRoot) { + $entries = Get-ChildItem -Path $entryRoot -Directory -Recurse | + Where-Object { Test-Path (Join-Path $_.FullName "manifest.json") } | + ForEach-Object { + $size = (Get-ChildItem -Path $_.FullName -Recurse -File -Force -ErrorAction SilentlyContinue | + Measure-Object -Property Length -Sum).Sum + [pscustomobject]@{ + Node = Split-Path (Split-Path $_.FullName -Parent) -Leaf + Size = [int64]($size ?? 0) + } + } | + Group-Object Node | + ForEach-Object { + [pscustomobject]@{ + Node = $_.Name + Count = $_.Count + Size = [int64](($_.Group | Measure-Object -Property Size -Sum).Sum ?? 0) + } + } | + Sort-Object Size -Descending + + "" | Add-Content -Path $summaryPath + "| Saved cache node | Entries | Size |" | Add-Content -Path $summaryPath + "| --- | ---: | ---: |" | Add-Content -Path $summaryPath + foreach ($entry in $entries) { + $mb = [math]::Round(([double]$entry.Size) / 1MB, 1) + "| `$($entry.Node)` | $($entry.Count) | ${mb} MB |" | Add-Content -Path $summaryPath + } + } + + - name: Save Windows tools-pack cache + if: ${{ success() && steps.win_tools_pack_cache_restore.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v5 + continue-on-error: true + with: + path: ${{ runner.temp }}/tools-pack-cache + key: tools-pack-win-v6-${{ runner.os }}-${{ steps.win_tools_pack_cache_key.outputs.epoch }}-${{ github.sha }} diff --git a/apps/daemon/src/agents.ts b/apps/daemon/src/agents.ts index 7360964fc..19c3b7c02 100644 --- a/apps/daemon/src/agents.ts +++ b/apps/daemon/src/agents.ts @@ -1,7 +1,7 @@ // @ts-nocheck import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { existsSync } from 'node:fs'; +import { accessSync, constants, existsSync, statSync } from 'node:fs'; import { delimiter } from 'node:path'; import path from 'node:path'; import { homedir } from 'node:os'; @@ -89,7 +89,24 @@ const agentCapabilities = new Map(); // documented, non-secret runtime knobs that belong to the adapter contract. const DEFAULT_MODEL_OPTION = { id: 'default', label: 'Default (CLI config)' }; -const AGENT_BIN_ENV_KEYS = new Map([['codex', 'CODEX_BIN']]); +const AGENT_BIN_ENV_KEYS = new Map([ + ['claude', 'CLAUDE_BIN'], + ['codex', 'CODEX_BIN'], + ['copilot', 'COPILOT_BIN'], + ['cursor-agent', 'CURSOR_AGENT_BIN'], + ['deepseek', 'DEEPSEEK_BIN'], + ['devin', 'DEVIN_BIN'], + ['gemini', 'GEMINI_BIN'], + ['hermes', 'HERMES_BIN'], + ['kimi', 'KIMI_BIN'], + ['kiro', 'KIRO_BIN'], + ['kilo', 'KILO_BIN'], + ['opencode', 'OPENCODE_BIN'], + ['pi', 'PI_BIN'], + ['qoder', 'QODER_BIN'], + ['qwen', 'QWEN_BIN'], + ['vibe', 'VIBE_BIN'], +]); // Map a user-picked reasoning effort to one the chosen model will accept. // Codex's CLI accepts `none | minimal | low | medium | high | xhigh`, but @@ -907,6 +924,16 @@ export function resolveOnPath(bin) { return null; } +function looksExecutableOnWindows(filePath) { + const ext = path.extname(filePath).trim().toUpperCase(); + if (!ext) return false; + const executableExts = (process.env.PATHEXT || '.EXE;.CMD;.BAT') + .split(';') + .map((value) => value.trim().toUpperCase()) + .filter(Boolean); + return executableExts.includes(ext); +} + // Resolve the first available binary for an agent definition. Tries // `def.bin` first, then walks `def.fallbackBins` in order. Used for // agents whose forks ship under a different binary name but speak the @@ -919,7 +946,17 @@ function configuredExecutableOverride(def, configuredEnv = {}) { if (typeof raw !== 'string' || raw.trim().length === 0) return null; const expanded = expandHomePath(raw.trim()); if (!path.isAbsolute(expanded)) return null; - return existsSync(expanded) ? expanded : null; + try { + if (!statSync(expanded).isFile()) return null; + if (process.platform === 'win32') { + if (!looksExecutableOnWindows(expanded)) return null; + } else { + accessSync(expanded, constants.X_OK); + } + return expanded; + } catch { + return null; + } } export function resolveAgentExecutable(def, configuredEnv = {}) { diff --git a/apps/daemon/src/app-config.ts b/apps/daemon/src/app-config.ts index aff91fa5a..0d7f53580 100644 --- a/apps/daemon/src/app-config.ts +++ b/apps/daemon/src/app-config.ts @@ -55,8 +55,22 @@ function configFile(dataDir: string): string { const AGENT_MODEL_KEYS: ReadonlySet = new Set(['model', 'reasoning']); const AGENT_CLI_ENV_KEYS: ReadonlyMap> = new Map([ - ['claude', new Set(['CLAUDE_CONFIG_DIR'])], + ['claude', new Set(['CLAUDE_CONFIG_DIR', 'CLAUDE_BIN'])], ['codex', new Set(['CODEX_HOME', 'CODEX_BIN'])], + ['copilot', new Set(['COPILOT_BIN'])], + ['cursor-agent', new Set(['CURSOR_AGENT_BIN'])], + ['deepseek', new Set(['DEEPSEEK_BIN'])], + ['devin', new Set(['DEVIN_BIN'])], + ['gemini', new Set(['GEMINI_BIN'])], + ['hermes', new Set(['HERMES_BIN'])], + ['kimi', new Set(['KIMI_BIN'])], + ['kiro', new Set(['KIRO_BIN'])], + ['kilo', new Set(['KILO_BIN'])], + ['opencode', new Set(['OPENCODE_BIN'])], + ['pi', new Set(['PI_BIN'])], + ['qoder', new Set(['QODER_BIN'])], + ['qwen', new Set(['QWEN_BIN'])], + ['vibe', new Set(['VIBE_BIN'])], ]); function isValidAgentModelEntry(v: unknown): v is AgentModelPrefs { diff --git a/apps/daemon/tests/agents.test.ts b/apps/daemon/tests/agents.test.ts index b1f98123d..6578f022b 100644 --- a/apps/daemon/tests/agents.test.ts +++ b/apps/daemon/tests/agents.test.ts @@ -44,8 +44,10 @@ const originalAgentHome = process.env.OD_AGENT_HOME; const originalDaemonUrl = process.env.OD_DAEMON_URL; const originalToolToken = process.env.OD_TOOL_TOKEN; const originalNpmConfigPrefix = process.env.NPM_CONFIG_PREFIX; +const originalPathExt = process.env.PATHEXT; const originalVpHome = process.env.VP_HOME; const originalFetch = globalThis.fetch; +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); afterEach(() => { if (originalDisablePlugins == null) { @@ -79,14 +81,30 @@ afterEach(() => { } else { process.env.NPM_CONFIG_PREFIX = originalNpmConfigPrefix; } + if (originalPathExt == null) { + delete process.env.PATHEXT; + } else { + process.env.PATHEXT = originalPathExt; + } if (originalVpHome == null) { delete process.env.VP_HOME; } else { process.env.VP_HOME = originalVpHome; } globalThis.fetch = originalFetch; + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor); + } }); +function withPlatform(platform, run) { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform, + }); + return run(); +} + test('AGENT_DEFS ids are unique', () => { const ids = AGENT_DEFS.map((a) => a.id); const dupes = ids.filter((id, i) => ids.indexOf(id) !== i); @@ -1781,6 +1799,39 @@ test('resolveAgentExecutable prefers a configured CODEX_BIN override over PATH r } }); +test('resolveAgentExecutable supports configured binary overrides for non-Codex adapters', () => { + const cases = [ + ['claude', 'claude', 'CLAUDE_BIN'], + ['gemini', 'gemini', 'GEMINI_BIN'], + ['opencode', 'opencode', 'OPENCODE_BIN'], + ['cursor-agent', 'cursor-agent', 'CURSOR_AGENT_BIN'], + ['qwen', 'qwen', 'QWEN_BIN'], + ['qoder', 'qodercli', 'QODER_BIN'], + ['copilot', 'copilot', 'COPILOT_BIN'], + ['deepseek', 'deepseek', 'DEEPSEEK_BIN'], + ]; + const dir = mkdtempSync(join(tmpdir(), 'od-agent-bin-overrides-')); + try { + process.env.PATH = ''; + process.env.OD_AGENT_HOME = dir; + + for (const [id, binName, envKey] of cases) { + const configured = join(dir, `${binName}-custom`); + writeFileSync(configured, '#!/bin/sh\nexit 0\n'); + chmodSync(configured, 0o755); + + const resolved = resolveAgentExecutable( + { id, bin: binName }, + { [envKey]: configured }, + ); + + assert.equal(resolved, configured, `expected ${id} to use ${envKey}`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('resolveAgentExecutable ignores relative CODEX_BIN overrides', () => { const dir = mkdtempSync(join(tmpdir(), 'od-codex-bin-rel-')); const oldCwd = process.cwd(); @@ -1804,6 +1855,78 @@ test('resolveAgentExecutable ignores relative CODEX_BIN overrides', () => { } }); +test('resolveAgentExecutable ignores configured binary overrides that are not executable files', () => { + const dir = mkdtempSync(join(tmpdir(), 'od-agent-bin-invalid-')); + try { + const directoryOverride = join(dir, 'as-directory'); + mkdirSync(directoryOverride); + const fileOverride = join(dir, 'not-executable'); + writeFileSync(fileOverride, '#!/bin/sh\nexit 0\n'); + if (process.platform !== 'win32') chmodSync(fileOverride, 0o644); + process.env.PATH = ''; + process.env.OD_AGENT_HOME = dir; + + assert.equal( + resolveAgentExecutable({ id: 'codex', bin: 'codex' }, { CODEX_BIN: directoryOverride }), + null, + ); + if (process.platform !== 'win32') { + assert.equal( + resolveAgentExecutable({ id: 'codex', bin: 'codex' }, { CODEX_BIN: fileOverride }), + null, + ); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('resolveAgentExecutable ignores Windows CODEX_BIN overrides without executable PATHEXT extension', () => { + const dir = mkdtempSync(join(tmpdir(), 'od-agent-bin-win-invalid-')); + try { + const invalidOverride = join(dir, 'codex-custom.txt'); + const fallback = join(dir, 'codex.CMD'); + writeFileSync(invalidOverride, '@echo off\r\nexit /b 0\r\n'); + writeFileSync(fallback, '@echo off\r\nexit /b 0\r\n'); + process.env.PATH = dir; + process.env.PATHEXT = '.EXE;.CMD;.BAT'; + process.env.OD_AGENT_HOME = dir; + + const resolved = withPlatform('win32', () => + resolveAgentExecutable( + { id: 'codex', bin: 'codex' }, + { CODEX_BIN: invalidOverride }, + ), + ); + + assert.equal(resolved, fallback); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('resolveAgentExecutable accepts Windows CODEX_BIN overrides with executable PATHEXT extension', () => { + const dir = mkdtempSync(join(tmpdir(), 'od-agent-bin-win-valid-')); + try { + const configured = join(dir, 'codex-custom.CMD'); + writeFileSync(configured, '@echo off\r\nexit /b 0\r\n'); + process.env.PATH = ''; + process.env.PATHEXT = '.EXE;.CMD;.BAT'; + process.env.OD_AGENT_HOME = dir; + + const resolved = withPlatform('win32', () => + resolveAgentExecutable( + { id: 'codex', bin: 'codex' }, + { CODEX_BIN: configured }, + ), + ); + + assert.equal(resolved, configured); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('detectAgents applies configured env while probing the CLI', async () => { const dir = mkdtempSync(join(tmpdir(), 'od-agent-env-')); try { diff --git a/e2e/lib/playwright/fake-agents.ts b/e2e/lib/playwright/fake-agents.ts new file mode 100644 index 000000000..6dc2fd605 --- /dev/null +++ b/e2e/lib/playwright/fake-agents.ts @@ -0,0 +1,213 @@ +import { chmod, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +export type FakeAgentId = + | 'claude' + | 'codex' + | 'copilot' + | 'cursor-agent' + | 'deepseek' + | 'gemini' + | 'opencode' + | 'qoder' + | 'qwen'; + +type FakeAgentRuntime = { + agentId: FakeAgentId; + bin: string; + envKey: string; + env: Record; +}; + +const AGENT_BIN_NAMES: Record = { + claude: 'claude-e2e.js', + codex: 'codex-e2e.js', + copilot: 'copilot-e2e.js', + 'cursor-agent': 'cursor-agent-e2e.js', + deepseek: 'deepseek-e2e.js', + gemini: 'gemini-e2e.js', + opencode: 'opencode-e2e.js', + qoder: 'qodercli-e2e.js', + qwen: 'qwen-e2e.js', +}; + +const AGENT_BIN_ENV_KEYS: Record = { + claude: 'CLAUDE_BIN', + codex: 'CODEX_BIN', + copilot: 'COPILOT_BIN', + 'cursor-agent': 'CURSOR_AGENT_BIN', + deepseek: 'DEEPSEEK_BIN', + gemini: 'GEMINI_BIN', + opencode: 'OPENCODE_BIN', + qoder: 'QODER_BIN', + qwen: 'QWEN_BIN', +}; + +export const FAKE_AGENT_RUNTIME_IDS: FakeAgentId[] = [ + 'claude', + 'gemini', + 'opencode', + 'cursor-agent', + 'qwen', + 'qoder', + 'copilot', +]; + +export async function createFakeAgentRuntimes( + runtimeIds: FakeAgentId[] = ['codex', ...FAKE_AGENT_RUNTIME_IDS], +): Promise> { + const root = path.join(tmpdir(), `open-design-playwright-fake-agents-${process.pid}`); + await mkdir(root, { recursive: true }); + + const runtimes = {} as Record; + for (const agentId of runtimeIds) { + const script = path.join(root, AGENT_BIN_NAMES[agentId]); + const bin = process.platform === 'win32' + ? script.replace(/\.js$/i, '.cmd') + : script; + await writeFile(script, renderFakeAgentScript(agentId), 'utf8'); + if (process.platform === 'win32') { + await writeFile(bin, '@echo off\r\nnode "%~dp0%~n0.js" %*\r\n', 'utf8'); + } else { + await chmod(bin, 0o755); + } + const envKey = AGENT_BIN_ENV_KEYS[agentId]; + runtimes[agentId] = { agentId, bin, envKey, env: { [envKey]: bin } }; + } + return runtimes; +} + +function renderFakeAgentScript(agentId: FakeAgentId): string { + return `#!/usr/bin/env node +const agentId = ${JSON.stringify(agentId)}; +const args = process.argv.slice(2); + +if (args.includes('--version')) { + process.stdout.write(agentId + '-e2e 0.0.0\\n'); + process.exitCode = 0; +} else if (agentId === 'claude' && args[0] === '-p' && args.includes('--help')) { + process.stdout.write('--add-dir --include-partial-messages\\n'); + process.exitCode = 0; +} else if ((agentId === 'opencode' || agentId === 'cursor-agent') && args[0] === 'models') { + process.stdout.write('fake/default\\n'); + process.exitCode = 0; +} else { + +let prompt = ''; +let emitted = false; +process.stdin.setEncoding('utf8'); +process.stdin.resume(); +process.stdin.on('data', (chunk) => { prompt += chunk; }); +process.stdin.on('end', () => { + emitRun(prompt); +}); +if (process.stdin.isTTY || agentId === 'deepseek') { + prompt = args.join(' '); + emitRun(prompt); +} + +function emitRun(promptText) { + if (emitted) return; + emitted = true; + if (promptText.includes('Return an intentional daemon smoke failure')) { + emitFailure(); + return; + } + const isChunked = promptText.includes('Create a chunked deterministic smoke artifact'); + const isFollowUp = promptText.includes('Create a follow-up deterministic smoke artifact'); + const isDefaultSmoke = promptText.includes('Create a deterministic smoke artifact'); + const isRuntime = promptText.match(/Fake runtime smoke for ([a-z0-9-]+)/i); + const runtimeId = isRuntime ? isRuntime[1] : agentId; + const heading = isChunked ? 'Chunked Daemon Smoke' : isFollowUp ? 'Follow-up Daemon Smoke' : isDefaultSmoke ? 'Real Daemon Smoke' : 'Fake Agent Runtime ' + runtimeId; + const identifier = isChunked ? 'chunked-daemon-smoke' : isFollowUp ? 'follow-up-daemon-smoke' : isDefaultSmoke ? 'real-daemon-smoke' : 'fake-agent-runtime-' + runtimeId; + const text = isChunked ? 'Chunked through the daemon run path.' : isFollowUp ? 'Generated after an earlier daemon turn.' : isDefaultSmoke ? 'Generated through the daemon run path.' : 'Generated through fake ' + runtimeId + ' runtime.'; + const html = '

' + heading + '

' + text + '

'; + const artifact = '' + html + ''; + emitSuccess(artifact, isChunked); + process.exitCode = 0; +} + +function writeJson(value) { + process.stdout.write(JSON.stringify(value) + '\\n'); +} + +function emitSuccess(artifact, isChunked) { + const first = artifact.slice(0, Math.ceil(artifact.length / 2)); + const second = artifact.slice(Math.ceil(artifact.length / 2)); + switch (agentId) { + case 'codex': + writeJson({ type: 'thread.started' }); + writeJson({ type: 'turn.started' }); + if (isChunked) { + writeJson({ type: 'item.completed', item: { type: 'agent_message', text: first } }); + writeJson({ type: 'item.completed', item: { type: 'agent_message', text: second } }); + } else { + writeJson({ type: 'item.completed', item: { type: 'agent_message', text: artifact } }); + } + writeJson({ type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } }); + return; + case 'claude': + writeJson({ type: 'system', subtype: 'init', model: 'fake-claude', session_id: 'fake-session' }); + writeJson({ type: 'assistant', message: { id: 'msg-1', content: [{ type: 'text', text: artifact }] } }); + writeJson({ type: 'result', usage: { input_tokens: 1, output_tokens: 1 }, total_cost_usd: 0, duration_ms: 1, stop_reason: 'end_turn' }); + return; + case 'gemini': + writeJson({ type: 'init', session_id: 'fake-gemini', model: 'fake-gemini' }); + writeJson({ type: 'message', role: 'assistant', content: artifact, delta: true }); + writeJson({ type: 'result', status: 'success', stats: { input_tokens: 1, output_tokens: 1, cached: 0, duration_ms: 1 } }); + return; + case 'opencode': + writeJson({ type: 'step_start', sessionID: 'fake-opencode', part: { type: 'step-start' } }); + writeJson({ type: 'text', sessionID: 'fake-opencode', part: { type: 'text', text: artifact } }); + writeJson({ type: 'step_finish', sessionID: 'fake-opencode', part: { type: 'step-finish', tokens: { input: 1, output: 1 }, cost: 0 } }); + return; + case 'cursor-agent': + writeJson({ type: 'system', subtype: 'init', model: 'fake-cursor' }); + writeJson({ type: 'assistant', timestamp_ms: 1, message: { role: 'assistant', content: [{ type: 'text', text: artifact }] } }); + writeJson({ type: 'result', duration_ms: 1, usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 } }); + return; + case 'qoder': + writeJson({ type: 'system', subtype: 'init', qodercli_version: '0.0.0', model: 'fake-qoder', session_id: 'fake-qoder' }); + writeJson({ type: 'assistant', message: { content: [{ type: 'text', text: artifact }] }, session_id: 'fake-qoder' }); + writeJson({ type: 'result', subtype: 'success', duration_ms: 1, is_error: false, stop_reason: 'end_turn', total_cost_usd: 0, usage: { input_tokens: 1, output_tokens: 1 } }); + return; + case 'copilot': + writeJson({ type: 'session.tools_updated', data: { model: 'fake-copilot' } }); + writeJson({ type: 'assistant.turn_start', data: {} }); + writeJson({ type: 'assistant.message_delta', data: { deltaContent: artifact } }); + writeJson({ type: 'result', success: true, exitCode: 0, usage: { input_tokens: 1, output_tokens: 1, sessionDurationMs: 1 } }); + return; + case 'qwen': + case 'deepseek': + process.stdout.write(artifact + '\\n'); + return; + default: + process.stdout.write(artifact + '\\n'); + } +} + +function emitFailure() { + switch (agentId) { + case 'codex': + writeJson({ type: 'thread.started' }); + writeJson({ type: 'turn.started' }); + writeJson({ type: 'turn.failed', error: { message: 'intentional fake codex failure' } }); + process.exitCode = 0; + return; + case 'opencode': + writeJson({ type: 'error', error: { data: { message: 'intentional fake opencode failure' } } }); + process.exitCode = 0; + return; + case 'qoder': + writeJson({ type: 'assistant', message: { content: [] }, error: { message: 'intentional fake qoder failure' } }); + process.exitCode = 0; + return; + default: + process.stderr.write('intentional fake ' + agentId + ' failure\\n'); + process.exitCode = 1; + } +} +} +`; +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 371566e72..bc651c7a0 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -3,15 +3,26 @@ import { defineConfig, devices } from '@playwright/test'; const daemonPort = Number(process.env.OD_PORT) || 17_456; const webPort = Number(process.env.OD_WEB_PORT) || 17_573; const baseURL = `http://127.0.0.1:${webPort}`; +const namespace = process.env.OD_E2E_NAMESPACE || `playwright-${process.pid}`; +const dataDir = process.env.OD_E2E_DATA_DIR || `e2e/ui/.od-data/${namespace}`; + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} export default defineConfig({ testDir: './ui', outputDir: './ui/reports/test-results', timeout: 30_000, + retries: process.env.CI ? 1 : 0, expect: { timeout: 10_000, }, - fullyParallel: true, + // The webServer owns one daemon and one OD_DATA_DIR for the entire UI suite. + // Keep backend-mutating UI tests serialized until the harness can boot an + // isolated daemon/data directory per worker. + fullyParallel: false, + workers: 1, reporter: process.env.CI ? [ ['github'], @@ -33,8 +44,8 @@ export default defineConfig({ }, webServer: { command: - `OD_DATA_DIR=e2e/ui/.od-data ` + - `pnpm --dir .. tools-dev run web --daemon-port ${daemonPort} --web-port ${webPort}`, + `OD_DATA_DIR=${shellQuote(dataDir)} ` + + `pnpm --dir .. tools-dev run web --namespace ${shellQuote(namespace)} --daemon-port ${daemonPort} --web-port ${webPort}`, url: baseURL, reuseExistingServer: false, timeout: 120_000, diff --git a/e2e/resources/playwright.ts b/e2e/resources/playwright.ts index 130e1f9a3..933a3c091 100644 --- a/e2e/resources/playwright.ts +++ b/e2e/resources/playwright.ts @@ -343,7 +343,7 @@ export const playwrightUiScenarios: UiScenario[] = [ title: 'Deck preview previous and next controls move in the correct direction', kind: 'deck', flow: 'deck-pagination-next-prev-correctness', - automated: false, + automated: true, description: 'Should verify that deck preview pagination moves to the actual previous and next slide instead of routing both actions to the same page.', create: { @@ -351,13 +351,16 @@ export const playwrightUiScenarios: UiScenario[] = [ tab: 'deck', }, prompt: 'Review pagination behavior in a multi-slide deck preview', + notes: [ + 'Seeds deterministic deck HTML through the project files API and verifies previous/next controls in Playwright.', + ], }, { id: 'deck-pagination-per-file-isolated', title: 'Each HTML deck tab preserves its own pagination state', kind: 'deck', flow: 'deck-pagination-per-file-isolated', - automated: false, + automated: true, description: 'Should verify that switching between multiple deck HTML files does not leak page position across tabs or reset both files to page 1.', create: { @@ -365,13 +368,16 @@ export const playwrightUiScenarios: UiScenario[] = [ tab: 'deck', }, prompt: 'Keep pagination state isolated per generated deck file', + notes: [ + 'Seeds two deterministic deck HTML files and verifies each open tab preserves its own active slide.', + ], }, { id: 'uploaded-image-renders-in-preview', title: 'Uploaded reference images render correctly in generated deck preview', kind: 'workspace', flow: 'uploaded-image-renders-in-preview', - automated: false, + automated: true, description: 'Should verify that uploaded images resolve to loadable src paths inside generated HTML instead of rendering as broken images.', create: { @@ -379,13 +385,16 @@ export const playwrightUiScenarios: UiScenario[] = [ tab: 'prototype', }, prompt: 'Use uploaded brand images inside a generated deck preview', + notes: [ + 'Seeds an image plus relative HTML reference and asserts the preview iframe loads the image.', + ], }, { id: 'python-source-preview', title: 'Python files should open with a readable inline source preview', kind: 'workspace', flow: 'python-source-preview', - automated: false, + automated: true, description: 'Should verify that opening a .py file in the main workspace renders a readable source/code preview instead of an unsupported blank state.', create: { @@ -394,8 +403,7 @@ export const playwrightUiScenarios: UiScenario[] = [ }, prompt: 'Open a generated Python file and inspect its source inline', notes: [ - 'Candidate follow-up to the Python preview gap in the file viewer.', - 'Likely automation shape: seed a .py file through the project files API, open it, and assert the viewer renders code text.', + 'Seeds a deterministic .py file through the project files API, opens it from the file list, and asserts the source viewer renders readable code text.', ], }, ]; diff --git a/e2e/specs/mac.spec.ts b/e2e/specs/mac.spec.ts index e76e8b5ac..85203d692 100644 --- a/e2e/specs/mac.spec.ts +++ b/e2e/specs/mac.spec.ts @@ -127,8 +127,14 @@ macDescribe('packaged mac runtime smoke', () => { expect(start.source).toBe('installed'); expect(start.appPath).toBe(install.installedAppPath); expectPathInside(start.logPath, join(runtimeNamespaceRoot, 'logs', 'desktop')); - expect(start.status).not.toBeNull(); - expect(start.status?.state).toBe('running'); + // `tools-pack mac start` performs a best-effort status probe before + // returning, but GitHub's macOS runners can take longer than that probe + // window to make the packaged desktop IPC-ready. Keep validating a + // non-null immediate status when available, then use the longer health + // polling below as the authoritative startup check. + if (start.status != null) { + expect(start.status.state).toBe('running'); + } const inspect = await waitForHealthyDesktop(); expect(inspect.status?.state).toBe('running'); diff --git a/e2e/tests/packaged-smoke-workflow.test.ts b/e2e/tests/packaged-smoke-workflow.test.ts new file mode 100644 index 000000000..9b2e33d19 --- /dev/null +++ b/e2e/tests/packaged-smoke-workflow.test.ts @@ -0,0 +1,19 @@ +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const e2eRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const workspaceRoot = dirname(e2eRoot); +const ciWorkflowPath = join(workspaceRoot, ".github", "workflows", "ci.yml"); + +describe("packaged smoke workflow", () => { + it("builds the PR mac smoke artifact without portable mode", async () => { + const workflow = await readFile(ciWorkflowPath, "utf8"); + const macBuildStep = workflow.match(/- name: Build PR mac artifacts\n(?:.+\n)+?(?=\n - name: Smoke PR mac packaged runtime)/m); + + expect(macBuildStep?.[0]).toBeDefined(); + expect(macBuildStep?.[0]).not.toContain("--portable"); + }); +}); diff --git a/e2e/ui/app.test.ts b/e2e/ui/app.test.ts index 8b0e14dc3..3934cce70 100644 --- a/e2e/ui/app.test.ts +++ b/e2e/ui/app.test.ts @@ -291,6 +291,22 @@ for (const entry of automatedUiScenarios()) { await runCommentAttachmentFlow(page, entry); return; } + if (entry.flow === 'deck-pagination-next-prev-correctness') { + await runDeckPaginationNextPrevCorrectnessFlow(page); + return; + } + if (entry.flow === 'deck-pagination-per-file-isolated') { + await runDeckPaginationPerFileIsolatedFlow(page); + return; + } + if (entry.flow === 'uploaded-image-renders-in-preview') { + await runUploadedImageRendersInPreviewFlow(page); + return; + } + if (entry.flow === 'python-source-preview') { + await runPythonSourcePreviewFlow(page); + return; + } await sendPrompt(page, entry.prompt); @@ -592,7 +608,7 @@ async function sendPrompt( await expect(input).toHaveValue(prompt, { timeout: 1500 }); await expect(sendButton).toBeEnabled({ timeout: 1500 }); const chatResponse = page.waitForResponse( - (resp: Response) => resp.url().includes('/api/runs') && resp.request().method() === 'POST', + isCreateRunResponse, { timeout: 2000 }, ); await sendButton.evaluate((button: HTMLButtonElement) => button.click()); @@ -607,7 +623,7 @@ async function sendPrompt( await expect(input).toHaveValue(prompt, { timeout: 1500 }); await expect(sendButton).toBeEnabled({ timeout: 1500 }); const chatResponse = page.waitForResponse( - (resp: Response) => resp.url().includes('/api/runs') && resp.request().method() === 'POST', + isCreateRunResponse, { timeout: 2000 }, ); await sendButton.evaluate((button: HTMLButtonElement) => button.click()); @@ -620,6 +636,16 @@ async function sendPrompt( } } +function isCreateRunResponse(resp: Response): boolean { + const url = new URL(resp.url()); + return url.pathname === '/api/runs' && resp.request().method() === 'POST'; +} + +function isCreateRunRequest(request: Request): boolean { + const url = new URL(request.url()); + return url.pathname === '/api/runs' && request.method() === 'POST'; +} + async function runDesignSystemSelectionFlow( page: Page, entry: UiScenario, @@ -807,7 +833,7 @@ async function runCommentAttachmentFlow( await expect(page.getByTestId('staged-comment-attachments')).toContainText('hero-title'); const runRequest = page.waitForRequest( - (request: Request) => request.url().includes('/api/runs') && request.method() === 'POST', + isCreateRunRequest, ); await page.getByTestId('chat-send').click(); const request = await runRequest; @@ -827,6 +853,122 @@ async function runCommentAttachmentFlow( ]); } +async function runDeckPaginationNextPrevCorrectnessFlow(page: Page) { + const { projectId } = await getCurrentProjectContext(page); + await seedDeckArtifact(page, projectId, 'pagination.html', 'Pagination Deck', ['Slide One', 'Slide Two', 'Slide Three']); + await page.reload(); + await openDesignFile(page, 'pagination.html'); + + const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + await expect(frame.getByText('Slide One')).toBeVisible(); + await page.getByLabel('Next slide').click(); + await expect(frame.getByText('Slide Two')).toBeVisible(); + await page.getByLabel('Next slide').click(); + await expect(frame.getByText('Slide Three')).toBeVisible(); + await page.getByLabel('Previous slide').click(); + await expect(frame.getByText('Slide Two')).toBeVisible(); +} + +async function runDeckPaginationPerFileIsolatedFlow(page: Page) { + const { projectId } = await getCurrentProjectContext(page); + await seedDeckArtifact(page, projectId, 'deck-alpha.html', 'Deck Alpha', ['Alpha One', 'Alpha Two']); + await seedDeckArtifact(page, projectId, 'deck-beta.html', 'Deck Beta', ['Beta One', 'Beta Two']); + await page.reload(); + + await openDesignFile(page, 'deck-alpha.html'); + const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + await expect(frame.getByText('Alpha One')).toBeVisible(); + await page.getByLabel('Next slide').click(); + await expect(frame.getByText('Alpha Two')).toBeVisible(); + + await page.getByTestId('design-files-tab').click(); + await openDesignFile(page, 'deck-beta.html'); + await expect(frame.getByText('Beta One')).toBeVisible(); + await page.getByLabel('Next slide').click(); + await expect(frame.getByText('Beta Two')).toBeVisible(); + + await page.getByRole('tab', { name: /deck-alpha\.html/i }).click(); + await expect(frame.getByText('Alpha Two')).toBeVisible(); + await page.getByRole('tab', { name: /deck-beta\.html/i }).click(); + await expect(frame.getByText('Beta Two')).toBeVisible(); +} + +async function runUploadedImageRendersInPreviewFlow(page: Page) { + const { projectId } = await getCurrentProjectContext(page); + const pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6McAAAAASUVORK5CYII='; + await seedProjectFile(page, projectId, 'brand.png', pngBase64, 'base64'); + await seedHtmlArtifact( + page, + projectId, + 'image-preview.html', + '

Image Preview

Brand logo
', + ); + await page.reload(); + await openDesignFile(page, 'image-preview.html'); + + const image = page.frameLocator('[data-testid="artifact-preview-frame"]').getByRole('img', { name: 'Brand logo' }); + await expect(image).toBeVisible(); + await expect + .poll(async () => image.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0)) + .toBe(true); +} + +async function runPythonSourcePreviewFlow(page: Page) { + const { projectId } = await getCurrentProjectContext(page); + await seedProjectFile(page, projectId, 'app.py', 'def greet():\n return "hello from python"\n'); + await page.reload(); + await openDesignFile(page, 'app.py'); + + await expect(page.locator('.code-viewer')).toContainText('def greet'); + await expect(page.locator('.code-viewer')).toContainText('hello from python'); +} + +async function seedDeckArtifact( + page: Page, + projectId: string, + fileName: string, + title: string, + slides: string[], +) { + const slideHtml = slides + .map((slide, index) => `

${slide}

`) + .join('\n'); + await seedProjectFile( + page, + projectId, + fileName, + `${slideHtml}`, + undefined, + { + version: 1, + kind: 'deck', + title, + entry: fileName, + renderer: 'deck-html', + exports: ['html', 'pptx'], + }, + ); +} + +async function seedProjectFile( + page: Page, + projectId: string, + name: string, + content: string, + encoding?: 'base64', + artifactManifest?: Record, +) { + const response = await page.request.post(`/api/projects/${projectId}/files`, { + data: { + name, + content, + ...(encoding ? { encoding } : {}), + ...(artifactManifest ? { artifactManifest } : {}), + }, + }); + expect(response.ok()).toBeTruthy(); +} + async function createProjectNameOnly( page: Page, entry: UiScenario, diff --git a/e2e/ui/real-daemon-run.test.ts b/e2e/ui/real-daemon-run.test.ts new file mode 100644 index 000000000..83846e0eb --- /dev/null +++ b/e2e/ui/real-daemon-run.test.ts @@ -0,0 +1,320 @@ +import { expect, test } from '@playwright/test'; +import type { Page, Response } from '@playwright/test'; +import { + createFakeAgentRuntimes, + FAKE_AGENT_RUNTIME_IDS, +} from '@/playwright/fake-agents'; +import type { FakeAgentId } from '@/playwright/fake-agents'; + +const STORAGE_KEY = 'open-design:config'; +const GENERATED_FILE = 'real-daemon-smoke.html'; +const GENERATED_HEADING = 'Real Daemon Smoke'; +const CHUNKED_FILE = 'chunked-daemon-smoke.html'; +const CHUNKED_HEADING = 'Chunked Daemon Smoke'; +const FOLLOW_UP_FILE = 'follow-up-daemon-smoke.html'; +let fakeRuntimes: Awaited>; + +test.describe.configure({ mode: 'serial' }); + +test.beforeAll(async () => { + fakeRuntimes = await createFakeAgentRuntimes(); +}); + +test.beforeEach(async ({ page }) => { + test.setTimeout(60_000); + + await resetDaemonAppConfig(page); + + await page.addInitScript(({ key, codexEnv }) => { + if (window.localStorage.getItem(key) != null) return; + window.localStorage.setItem( + key, + JSON.stringify({ + mode: 'daemon', + apiKey: '', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-5', + agentId: 'codex', + skillId: null, + designSystemId: null, + onboardingCompleted: true, + agentModels: { codex: { model: 'default', reasoning: 'default' } }, + agentCliEnv: { codex: codexEnv }, + }), + ); + }, { key: STORAGE_KEY, codexEnv: fakeRuntimes.codex.env }); + + await configureFakeAgent(page, 'codex'); +}); + +test.afterEach(async ({ page }) => { + await resetDaemonAppConfig(page); +}); + +test('real daemon run streams, persists, and previews an artifact', async ({ page }) => { + await page.goto('/'); + await createProject(page, 'Real daemon run smoke'); + await expectWorkspaceReady(page); + + await sendPrompt(page, 'Create a deterministic smoke artifact'); + + await expect(page.getByText(GENERATED_FILE, { exact: true })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('artifact-preview-frame')).toBeVisible(); + const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + await expect(frame.getByRole('heading', { name: GENERATED_HEADING })).toBeVisible(); + + const { projectId } = currentProject(page); + await expectProjectFileToContain(page, projectId, GENERATED_FILE, GENERATED_HEADING); +}); + +test('real daemon run persists an artifact streamed across multiple chunks', async ({ page }) => { + await page.goto('/'); + await createProject(page, 'Chunked daemon run smoke'); + await expectWorkspaceReady(page); + + await sendPrompt(page, 'Create a chunked deterministic smoke artifact'); + + await expect(page.getByText(CHUNKED_FILE, { exact: true })).toBeVisible({ timeout: 15_000 }); + const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + await expect(frame.getByRole('heading', { name: CHUNKED_HEADING })).toBeVisible(); + + const { projectId } = currentProject(page); + await expectProjectFileToContain(page, projectId, CHUNKED_FILE, CHUNKED_HEADING); +}); + +test('real daemon run surfaces process/parser errors in chat', async ({ page }) => { + await page.goto('/'); + await createProject(page, 'Daemon error smoke'); + await expectWorkspaceReady(page); + + await sendPrompt(page, 'Return an intentional daemon smoke failure'); + + await expect(page.locator('.msg.error')).toContainText('intentional fake codex failure', { timeout: 15_000 }); + await expect(page.locator('.status-pill', { hasText: 'intentional fake codex failure' })).toBeVisible(); +}); + +test('real daemon run supports a follow-up turn in the same project', async ({ page }) => { + await page.goto('/'); + await createProject(page, 'Daemon follow-up smoke'); + await expectWorkspaceReady(page); + + await sendPrompt(page, 'Create a deterministic smoke artifact'); + await expect(page.getByText(GENERATED_FILE, { exact: true })).toBeVisible({ timeout: 15_000 }); + + await sendPrompt(page, 'Create a follow-up deterministic smoke artifact'); + await expect(page.getByText(FOLLOW_UP_FILE, { exact: true })).toBeVisible({ timeout: 15_000 }); + + const { projectId } = currentProject(page); + const response = await page.request.get(`/api/projects/${projectId}/files`); + expect(response.ok()).toBeTruthy(); + const { files } = (await response.json()) as { files: Array<{ name: string }> }; + expect(files.map((file) => file.name)).toEqual(expect.arrayContaining([GENERATED_FILE, FOLLOW_UP_FILE])); + + await expectProjectFileToContain(page, projectId, FOLLOW_UP_FILE, 'Generated after an earlier daemon turn.'); +}); + +test('real daemon run previews an artifact from a fake OpenCode runtime', async ({ page }) => { + await configureFakeAgent(page, 'opencode'); + await page.goto('/'); + await setBrowserAgentConfig(page, 'opencode'); + await page.reload(); + await createProject(page, 'Fake OpenCode runtime smoke'); + await expectWorkspaceReady(page); + + await sendPrompt(page, 'Fake runtime smoke for opencode'); + + const fileName = 'fake-agent-runtime-opencode.html'; + const heading = 'Fake Agent Runtime opencode'; + await expect(page.getByText(fileName, { exact: true })).toBeVisible({ timeout: 15_000 }); + const frame = page.frameLocator('[data-testid="artifact-preview-frame"]'); + await expect(frame.getByRole('heading', { name: heading })).toBeVisible(); + + const { projectId } = currentProject(page); + await expectProjectFileToContain(page, projectId, fileName, heading); +}); + +test('real daemon run supports fake non-Codex runtime protocols', async ({ page }) => { + test.setTimeout(180_000); + + for (const agentId of FAKE_AGENT_RUNTIME_IDS) { + await test.step(agentId, async () => { + await configureFakeAgent(page, agentId); + const projectId = `fake-runtime-${agentId}-${Date.now()}`.replace(/[^A-Za-z0-9._-]/g, '-'); + const { conversationId } = await createProjectViaApi(page, projectId, `Fake ${agentId} runtime smoke`); + const expectedArtifact = `fake-agent-runtime-${agentId}`; + + expect(conversationId).toBeTruthy(); + await startRunAndWaitForSuccess(page, { + agentId, + projectId, + conversationId, + message: `Fake runtime smoke for ${agentId}`, + expectedOutput: expectedArtifact, + }); + }); + } +}); + +async function createProject(page: Page, name: string) { + await expect(page.getByTestId('new-project-panel')).toBeVisible(); + await page.getByTestId('new-project-tab-prototype').click(); + await page.getByTestId('new-project-name').fill(name); + await page.getByTestId('create-project').click(); +} + +async function createProjectViaApi(page: Page, projectId: string, name: string) { + const response = await page.request.post('/api/projects', { + data: { + id: projectId, + name, + skillId: null, + designSystemId: null, + pendingPrompt: null, + metadata: { kind: 'prototype' }, + }, + }); + expect(response.ok()).toBeTruthy(); + return (await response.json()) as { conversationId: string }; +} + +async function expectWorkspaceReady(page: Page) { + await expect(page).toHaveURL(/\/projects\//); + await expect(page.getByTestId('chat-composer')).toBeVisible(); + await expect(page.getByTestId('file-workspace')).toBeVisible(); + await expect(page.getByText('Start a conversation')).toBeVisible(); +} + +async function sendPrompt(page: Page, prompt: string) { + const input = page.getByTestId('chat-composer-input'); + const sendButton = page.getByTestId('chat-send'); + await input.click(); + await input.fill(prompt); + await expect(input).toHaveValue(prompt); + await expect(sendButton).toBeEnabled(); + const chatResponse = page.waitForResponse(isCreateRunResponse); + await sendButton.click(); + const response = await chatResponse; + expect(response.ok()).toBeTruthy(); +} + +async function configureFakeAgent(page: Page, agentId: FakeAgentId) { + const runtime = fakeRuntimes[agentId]; + const response = await page.request.put('/api/app-config', { + data: { + onboardingCompleted: true, + agentId, + agentModels: { [agentId]: { model: 'default', reasoning: 'default' } }, + agentCliEnv: { [agentId]: runtime.env }, + skillId: null, + designSystemId: null, + }, + }); + expect(response.ok()).toBeTruthy(); +} + +async function setBrowserAgentConfig(page: Page, agentId: FakeAgentId) { + await page.evaluate(({ key, id, env }) => { + window.localStorage.setItem( + key, + JSON.stringify({ + mode: 'daemon', + apiKey: '', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-5', + agentId: id, + skillId: null, + designSystemId: null, + onboardingCompleted: true, + agentModels: { [id]: { model: 'default', reasoning: 'default' } }, + agentCliEnv: { [id]: env }, + }), + ); + }, { key: STORAGE_KEY, id: agentId, env: fakeRuntimes[agentId].env }); +} + +async function resetDaemonAppConfig(page: Page) { + const response = await page.request.put('/api/app-config', { + data: { + onboardingCompleted: true, + agentId: 'mock', + agentModels: {}, + agentCliEnv: {}, + skillId: null, + designSystemId: null, + }, + }); + expect(response.ok()).toBeTruthy(); +} + +async function startRunAndWaitForSuccess( + page: Page, + options: { + agentId: FakeAgentId; + projectId: string; + conversationId: string; + message: string; + expectedOutput?: string; + }, +) { + const requestId = `fake-${options.agentId}-${Date.now()}`; + const response = await page.request.post('/api/runs', { + data: { + agentId: options.agentId, + message: options.message, + projectId: options.projectId, + conversationId: options.conversationId, + assistantMessageId: `assistant-${requestId}`, + clientRequestId: requestId, + skillId: null, + designSystemId: null, + model: 'default', + reasoning: 'default', + }, + }); + expect(response.ok()).toBeTruthy(); + const { runId } = (await response.json()) as { runId: string }; + + await expect + .poll(async () => { + const status = await page.request.get(`/api/runs/${runId}`); + if (!status.ok()) return `http-${status.status()}`; + const body = (await status.json()) as { status: string }; + return body.status; + }, { timeout: 20_000 }) + .toBe('succeeded'); + + if (options.expectedOutput) { + const events = await page.request.get(`/api/runs/${runId}/events`); + expect(events.ok()).toBeTruthy(); + await expect(events.text()).resolves.toContain(options.expectedOutput); + } +} + +async function expectProjectFileToContain( + page: Page, + projectId: string, + fileName: string, + expected: string, +) { + await expect + .poll(async () => { + const response = await page.request.get(`/api/projects/${projectId}/files/${fileName}`); + if (!response.ok()) return ''; + return response.text(); + }, { timeout: 15_000 }) + .toContain(expected); +} + +function isCreateRunResponse(response: Response): boolean { + const url = new URL(response.url()); + return url.pathname === '/api/runs' && response.request().method() === 'POST'; +} + +function currentProject(page: Page): { projectId: string } { + const current = new URL(page.url()); + const [, projects, projectId] = current.pathname.split('/'); + if (projects !== 'projects' || !projectId) { + throw new Error(`unexpected project route: ${current.pathname}`); + } + return { projectId }; +} diff --git a/tools/pack/src/mac/lifecycle.ts b/tools/pack/src/mac/lifecycle.ts index 1ee723e23..63ae2d182 100644 --- a/tools/pack/src/mac/lifecycle.ts +++ b/tools/pack/src/mac/lifecycle.ts @@ -31,6 +31,7 @@ import { desktopIdentityPath, desktopLogPath, macAppExecutablePath, resolveMacPa import type { DesktopRootIdentityFallback, DesktopRootIdentityMarker, MacCleanupResult, MacInspectResult, MacInstallResult, MacStartResult, MacStartSource, MacStopResult, MacUninstallResult } from "./types.js"; const execFileAsync = promisify(execFile); +const PACKAGED_CONFIG_PATH_ENV = "OD_PACKAGED_CONFIG_PATH"; function desktopStamp(config: ToolPackConfig): SidecarStamp { return { @@ -301,8 +302,35 @@ export async function installPackedMacDmg(config: ToolPackConfig): Promise { + if (!config.portable) return null; + + let raw: Record; + try { + raw = JSON.parse( + await readFile(join(appPath, "Contents", "Resources", "open-design-config.json"), "utf8"), + ) as Record; + } catch (error) { + const code = typeof error === "object" && error != null && "code" in error + ? String((error as { code?: unknown }).code) + : null; + if (code === "ENOENT") return null; + throw error; + } + + const launchConfigPath = join(config.roots.runtime.namespaceRoot, "open-design-config.json"); + await mkdir(config.roots.runtime.namespaceRoot, { recursive: true }); + await writeFile( + launchConfigPath, + `${JSON.stringify({ ...raw, namespaceBaseRoot: config.roots.runtime.namespaceBaseRoot }, null, 2)}\n`, + "utf8", + ); + return launchConfigPath; +} + export async function startPackedMacApp(config: ToolPackConfig): Promise { const target = await resolvePackedMacStartTarget(config); + const launchConfigPath = await prepareMacLaunchConfig(config, target.appPath); const stamp = desktopStamp(config); const logPath = desktopLogPath(config); await mkdir(dirname(logPath), { recursive: true }); @@ -318,6 +346,7 @@ export async function startPackedMacApp(config: ToolPackConfig): Promise ({ state: "running" })); +const resolveAppIpcPath = vi.fn(() => "/tmp/open-design/ipc/test/desktop.sock"); +const createSidecarLaunchEnv = vi.fn(({ extraEnv }: { extraEnv: NodeJS.ProcessEnv }) => extraEnv); +const spawnBackgroundProcess = vi.fn(async ({ env }: { env: NodeJS.ProcessEnv }) => ({ env, pid: 1234 })); + +vi.mock("@open-design/sidecar", () => ({ + createSidecarLaunchEnv, + requestJsonIpc, + resolveAppIpcPath, +})); + +vi.mock("@open-design/platform", () => ({ + collectProcessTreePids: vi.fn(), + createProcessStampArgs: vi.fn(() => []), + listProcessSnapshots: vi.fn(async () => []), + matchesStampedProcess: vi.fn(() => false), + readLogTail: vi.fn(async () => []), + spawnBackgroundProcess, + stopProcesses: vi.fn(async () => []), +})); + +const { startPackedMacApp } = await import("../src/mac/lifecycle.js"); + +function makeConfig(root: string, overrides: Partial = {}): ToolPackConfig { + return { + containerized: false, + electronBuilderCliPath: "/x/electron-builder/cli.js", + electronDistPath: "/x/electron/dist", + electronVersion: "41.3.0", + macCompression: "normal", + namespace: "local-test", + platform: "mac", + portable: true, + removeData: false, + removeLogs: false, + removeProductUserData: false, + removeSidecars: false, + roots: { + output: { + appBuilderRoot: join(root, ".tmp", "tools-pack", "out", "mac", "namespaces", "local-test", "builder"), + namespaceRoot: join(root, ".tmp", "tools-pack", "out", "mac", "namespaces", "local-test"), + platformRoot: join(root, ".tmp", "tools-pack", "out", "mac"), + root: join(root, ".tmp", "tools-pack", "out"), + }, + runtime: { + namespaceBaseRoot: join(root, ".tmp", "tools-pack", "runtime", "mac", "namespaces"), + namespaceRoot: join(root, ".tmp", "tools-pack", "runtime", "mac", "namespaces", "local-test"), + }, + cacheRoot: join(root, ".tmp", "tools-pack", "cache"), + toolPackRoot: join(root, ".tmp", "tools-pack"), + }, + silent: true, + signed: false, + to: "app", + webOutputMode: "standalone", + workspaceRoot: root, + ...overrides, + }; +} + +afterEach(() => { + vi.clearAllMocks(); + requestJsonIpc.mockResolvedValue({ state: "running" }); +}); + +describe("startPackedMacApp", () => { + it("skips the launch override when the bundled config is missing", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-tools-pack-mac-lifecycle-")); + try { + const config = makeConfig(root); + const paths = resolveMacPaths(config); + const executablePath = join(paths.installedAppPath, "Contents", "MacOS", "Open Design"); + + await mkdir(join(paths.installedAppPath, "Contents", "MacOS"), { recursive: true }); + await writeFile(executablePath, "#!/bin/sh\nexit 0\n", "utf8"); + await chmod(executablePath, 0o755); + + const result = await startPackedMacApp(config); + const launchConfigPath = join(config.roots.runtime.namespaceRoot, "open-design-config.json"); + const launchEnv = spawnBackgroundProcess.mock.calls[0]?.[0]?.env as NodeJS.ProcessEnv | undefined; + + expect(result.source).toBe("installed"); + expect(result.status?.state).toBe("running"); + expect(launchEnv?.OD_PACKAGED_CONFIG_PATH).toBeUndefined(); + await expect(readFile(launchConfigPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it("passes a launch override config path for portable mac starts", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-tools-pack-mac-lifecycle-")); + try { + const config = makeConfig(root); + const paths = resolveMacPaths(config); + const executablePath = join(paths.installedAppPath, "Contents", "MacOS", "Open Design"); + const bundledConfigPath = join(paths.installedAppPath, "Contents", "Resources", "open-design-config.json"); + + await mkdir(join(paths.installedAppPath, "Contents", "MacOS"), { recursive: true }); + await mkdir(join(paths.installedAppPath, "Contents", "Resources"), { recursive: true }); + await writeFile(executablePath, "#!/bin/sh\nexit 0\n", "utf8"); + await chmod(executablePath, 0o755); + await writeFile( + bundledConfigPath, + `${JSON.stringify({ + appVersion: "1.2.3", + daemonCliEntryRelative: "open-design/bin/od", + namespace: config.namespace, + nodeCommandRelative: "open-design/bin/node", + }, null, 2)}\n`, + "utf8", + ); + + const result = await startPackedMacApp(config); + const launchConfigPath = join(config.roots.runtime.namespaceRoot, "open-design-config.json"); + const launchEnv = spawnBackgroundProcess.mock.calls[0]?.[0]?.env as NodeJS.ProcessEnv | undefined; + + expect(result.source).toBe("installed"); + expect(result.status?.state).toBe("running"); + expect(launchEnv?.OD_PACKAGED_CONFIG_PATH).toBe(launchConfigPath); + await expect(readFile(launchConfigPath, "utf8")).resolves.toContain( + `"namespaceBaseRoot": ${JSON.stringify(config.roots.runtime.namespaceBaseRoot)}`, + ); + await expect(readFile(launchConfigPath, "utf8")).resolves.toContain('"appVersion": "1.2.3"'); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); diff --git a/tools/pack/tests/mac.test.ts b/tools/pack/tests/mac.test.ts index 7e6d8ffbe..991d7bc89 100644 --- a/tools/pack/tests/mac.test.ts +++ b/tools/pack/tests/mac.test.ts @@ -5,7 +5,9 @@ import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { ToolPackConfig } from "../src/config.js"; +import { resolveMacPaths } from "../src/mac/paths.js"; import { resolveSeededAppConfigPaths, seedPackagedAppConfig } from "../src/mac/index.js"; +import * as macLifecycle from "../src/mac/lifecycle.js"; function makeConfig(root: string, overrides: Partial = {}): ToolPackConfig { return { @@ -132,3 +134,37 @@ describe("seedPackagedAppConfig", () => { } }); }); + +describe("prepareMacLaunchConfig", () => { + it("injects the runtime namespace base root for portable mac starts", async () => { + const root = await mkdtemp(join(tmpdir(), "open-design-tools-pack-mac-")); + try { + const config = makeConfig(root, { portable: true }); + const paths = resolveMacPaths(config); + await mkdir(join(paths.installedAppPath, "Contents", "Resources"), { recursive: true }); + await mkdir(config.roots.runtime.namespaceRoot, { recursive: true }); + await writeFile( + join(paths.installedAppPath, "Contents", "Resources", "open-design-config.json"), + `${JSON.stringify({ + appVersion: "1.2.3", + daemonCliEntryRelative: "open-design/bin/od", + namespace: config.namespace, + nodeCommandRelative: "open-design/bin/node", + }, null, 2)}\n`, + "utf8", + ); + + const launchConfigPath = await (macLifecycle as { + prepareMacLaunchConfig?: (input: ToolPackConfig, appPath: string) => Promise; + }).prepareMacLaunchConfig?.(config, paths.installedAppPath); + + expect(launchConfigPath).toBe(join(config.roots.runtime.namespaceRoot, "open-design-config.json")); + await expect(readFile(String(launchConfigPath), "utf8")).resolves.toContain( + `"namespaceBaseRoot": ${JSON.stringify(config.roots.runtime.namespaceBaseRoot)}`, + ); + await expect(readFile(String(launchConfigPath), "utf8")).resolves.toContain('"appVersion": "1.2.3"'); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +});