mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
8fee22d358
commit
b06f26a5fd
14 changed files with 1452 additions and 19 deletions
339
.github/workflows/ci.yml
vendored
339
.github/workflows/ci.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 = {}) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
213
e2e/lib/playwright/fake-agents.ts
Normal file
213
e2e/lib/playwright/fake-agents.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
19
e2e/tests/packaged-smoke-workflow.test.ts
Normal file
19
e2e/tests/packaged-smoke-workflow.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
320
e2e/ui/real-daemon-run.test.ts
Normal file
320
e2e/ui/real-daemon-run.test.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
138
tools/pack/tests/mac-lifecycle.test.ts
Normal file
138
tools/pack/tests/mac-lifecycle.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue