test: strengthen e2e PR coverage (#796)

* test: strengthen e2e PR coverage

* fix: address e2e PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* ci: cache Windows packaged smoke builds

* test: fake additional agent runtimes

* fix: address e2e PR feedback

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Route tools-pack mac starts through a launch-time packaged config override so portable packaged smoke runs keep using the namespace runtime root that inspect and logs expect.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Fall back to the packaged app's embedded config when the build output config is missing so installed mac starts still work.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: align packaged mac PR smoke with tools-pack runtime mode

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Keep blake3-wasm out of the packaged mac daemon prebundle so the standalone runtime loads the Cloudflare asset hasher from node_modules instead of crashing in ESM.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address e2e PR feedback

Skip the portable mac launch override when the bundled packaged config is missing so installed fallback app targets can still boot with packaged defaults.

Add a regression test covering the missing-config start path.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(pack): remove duplicate mac prebundle dependency key
This commit is contained in:
Marc Chan 2026-05-08 16:48:10 +08:00 committed by GitHub
parent 8fee22d358
commit b06f26a5fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1452 additions and 19 deletions

View file

@ -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 }}

View file

@ -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 = {}) {

View file

@ -55,8 +55,22 @@ function configFile(dataDir: string): string {
const AGENT_MODEL_KEYS: ReadonlySet<string> = new Set(['model', 'reasoning']);
const AGENT_CLI_ENV_KEYS: ReadonlyMap<string, ReadonlySet<string>> = 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 {

View file

@ -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 {

View file

@ -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<string, string>;
};
const AGENT_BIN_NAMES: Record<FakeAgentId, string> = {
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<FakeAgentId, string> = {
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<Record<FakeAgentId, FakeAgentRuntime>> {
const root = path.join(tmpdir(), `open-design-playwright-fake-agents-${process.pid}`);
await mkdir(root, { recursive: true });
const runtimes = {} as Record<FakeAgentId, FakeAgentRuntime>;
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 = '<!doctype html><html><body><main><h1>' + heading + '</h1><p>' + text + '</p></main></body></html>';
const artifact = '<artifact identifier="' + identifier + '" type="text/html" title="' + heading + '">' + html + '</artifact>';
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;
}
}
}
`;
}

View file

@ -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,

View file

@ -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.',
],
},
];

View file

@ -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');

View file

@ -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");
});
});

View file

@ -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',
'<!doctype html><html><body><main><h1>Image Preview</h1><img alt="Brand logo" src="brand.png"></main></body></html>',
);
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) => `<section class="slide" data-od-id="slide-${index + 1}"${index === 0 ? '' : ' hidden'}><h1>${slide}</h1></section>`)
.join('\n');
await seedProjectFile(
page,
projectId,
fileName,
`<!doctype html><html><body>${slideHtml}</body></html>`,
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<string, unknown>,
) {
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,

View file

@ -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<ReturnType<typeof createFakeAgentRuntimes>>;
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 };
}

View file

@ -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<MacIn
};
}
export async function prepareMacLaunchConfig(config: ToolPackConfig, appPath: string): Promise<string | null> {
if (!config.portable) return null;
let raw: Record<string, unknown>;
try {
raw = JSON.parse(
await readFile(join(appPath, "Contents", "Resources", "open-design-config.json"), "utf8"),
) as Record<string, unknown>;
} 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<MacStartResult> {
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<MacStar
extraEnv: {
...process.env,
[DESKTOP_LOG_ECHO_ENV]: "0",
...(launchConfigPath == null ? {} : { [PACKAGED_CONFIG_PATH_ENV]: launchConfigPath }),
},
stamp,
}),

View file

@ -0,0 +1,138 @@
import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ToolPackConfig } from "../src/config.js";
import { resolveMacPaths } from "../src/mac/paths.js";
const requestJsonIpc = vi.fn(async () => ({ 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> = {}): 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 });
}
});
});

View file

@ -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> = {}): 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<string | null>;
}).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 });
}
});
});