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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,11 @@ jobs:
OPENCODE_CHANNEL: ${{ inputs.channel || 'dev' }}
PAWWORK_FEEDBACK_FORM_URL: ${{ vars.PAWWORK_FEEDBACK_FORM_URL || '' }}

- name: Check desktop runtime imports
if: ${{ inputs.phase != 'finalize' }}
run: bun ./scripts/runtime-import-guard.ts
working-directory: packages/desktop-electron

- name: Setup Apple API Key
if: runner.os == 'macOS'
run: printenv APPLE_API_KEY_CONTENT > $RUNNER_TEMP/apple-api-key.p8
Expand Down Expand Up @@ -364,6 +369,32 @@ jobs:
env:
OPENCODE_CHANNEL: ${{ inputs.channel || 'dev' }}

- name: Smoke signed macOS app
if: ${{ runner.os == 'macOS' && (inputs.phase == 'submit' || inputs.phase == 'full') }}
run: |
set -euo pipefail

case "$OPENCODE_CHANNEL" in
dev) APP_NAME="PawWork Dev" ;;
beta) APP_NAME="PawWork Beta" ;;
prod) APP_NAME="PawWork" ;;
*) echo "Unsupported channel: $OPENCODE_CHANNEL"; exit 1 ;;
esac

case "${{ matrix.arch_label }}" in
arm64) APP_OUT_DIR="dist/mac-arm64" ;;
x64) APP_OUT_DIR="dist/mac" ;;
*) echo "Unsupported arch: ${{ matrix.arch_label }}"; exit 1 ;;
esac

APP_PATH="$APP_OUT_DIR/$APP_NAME.app"
EXECUTABLE_PATH="$APP_PATH/Contents/MacOS/$APP_NAME"
bun ./scripts/ci-smoke.ts packaged "$OPENCODE_CHANNEL" "$EXECUTABLE_PATH"
working-directory: packages/desktop-electron
timeout-minutes: 2
env:
OPENCODE_CHANNEL: ${{ inputs.channel || 'dev' }}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Prepare signed app artifact
if: ${{ runner.os == 'macOS' && inputs.phase == 'submit' }}
run: |
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/desktop-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ jobs:
env:
OPENCODE_CHANNEL: dev

- name: Check desktop runtime imports
run: bun ./scripts/runtime-import-guard.ts
working-directory: packages/desktop-electron

- name: Launch desktop smoke app
run: bun run smoke:ci
working-directory: packages/desktop-electron
Expand Down Expand Up @@ -190,6 +194,14 @@ jobs:
grep -q "Signature=adhoc" /tmp/pawwork-codesign.txt
working-directory: packages/desktop-electron

- name: Launch packaged desktop smoke app
run: |
set -euo pipefail

EXECUTABLE_PATH="dist/mac-arm64/PawWork Dev.app/Contents/MacOS/PawWork Dev"
bun ./scripts/ci-smoke.ts packaged dev "$EXECUTABLE_PATH"
working-directory: packages/desktop-electron

# Aggregator for the `dev` branch ruleset. The required check on GitHub
# is `desktop-smoke / check`; every new job added above MUST be listed
# in `needs:` below, otherwise its failure will not block merge.
Expand Down
112 changes: 108 additions & 4 deletions packages/desktop-electron/scripts/ci-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { describe, expect, test } from "bun:test"
import { spawnSync } from "node:child_process"
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import { desktopShellMainSelector, titlebarShellSelector } from "../src/renderer/ci-smoke-selectors"
import { buildSmokeEnv, requiredSelectors, resolveCiSmokeReadyFile, resolveMainEntry } from "./ci-smoke"
import {
appIdForSmoke,
buildSmokeEnv,
parseSmokeArgs,
requiredSelectors,
resolveCiSmokeReadyFile,
resolveLaunchCommand,
resolveMainEntry,
} from "./ci-smoke"

describe("ci smoke helpers", () => {
test("resolveMainEntry points at the built Electron main process bundle", () => {
expect(resolveMainEntry().endsWith(path.join("packages", "desktop-electron", "out", "main", "index.js"))).toBe(
true,
)
expect(resolveMainEntry().endsWith(path.join("packages", "desktop-electron", "out", "main", "index.js"))).toBe(true)
})

test("buildSmokeEnv isolates the app state in a temporary home", () => {
Expand All @@ -33,4 +42,99 @@ describe("ci smoke helpers", () => {
path.join("/tmp/pawwork-ci-smoke", "ai.pawwork.desktop.dev", "ci-smoke-ready.json"),
)
})

test("appIdForSmoke uses dev app data for raw runs and channel app IDs for packaged runs", () => {
expect(appIdForSmoke("dev", "raw")).toBe("ai.pawwork.desktop.dev")
expect(appIdForSmoke("prod", "raw")).toBe("ai.pawwork.desktop.dev")
expect(appIdForSmoke("dev", "packaged")).toBe("ai.pawwork.desktop.dev")
expect(appIdForSmoke("beta", "packaged")).toBe("ai.pawwork.desktop.beta")
expect(appIdForSmoke("prod", "packaged")).toBe("ai.pawwork.desktop")
})

test("resolveCiSmokeReadyFile follows packaged channel app IDs", () => {
expect(resolveCiSmokeReadyFile("/tmp/pawwork-ci-smoke", { channel: "prod", mode: "packaged" })).toBe(
path.join("/tmp/pawwork-ci-smoke", "ai.pawwork.desktop", "ci-smoke-ready.json"),
)
expect(resolveCiSmokeReadyFile("/tmp/pawwork-ci-smoke", { channel: "beta", mode: "packaged" })).toBe(
path.join("/tmp/pawwork-ci-smoke", "ai.pawwork.desktop.beta", "ci-smoke-ready.json"),
)
})

test("buildSmokeEnv carries the requested channel into the child process", () => {
const env = buildSmokeEnv("/tmp/pawwork-ci-smoke", "prod")

expect(env.OPENCODE_CHANNEL).toBe("prod")
expect(env.PAWWORK_CI_SMOKE).toBe("true")
expect(env.PAWWORK_CI_SMOKE_HOME).toBe("/tmp/pawwork-ci-smoke")
})

test("parseSmokeArgs defaults to raw dev mode", () => {
expect(parseSmokeArgs([])).toEqual({ mode: "raw", channel: "dev" })
})

test("parseSmokeArgs accepts a packaged executable path", () => {
const dir = mkdtempSync(path.join(tmpdir(), "pawwork-ci-smoke-"))
try {
const executablePath = path.join(dir, "PawWork")
writeFileSync(executablePath, "")

expect(parseSmokeArgs(["packaged", "prod", executablePath])).toEqual({
mode: "packaged",
channel: "prod",
executablePath,
})
} finally {
rmSync(dir, { recursive: true, force: true })
}
})

test("parseSmokeArgs rejects packaged mode without an executable path", () => {
expect(() => parseSmokeArgs(["packaged", "dev"])).toThrow("Packaged smoke requires an executable path")
})

test("parseSmokeArgs rejects packaged mode when the executable path is missing", () => {
expect(() => parseSmokeArgs(["packaged", "dev", "/tmp/pawwork-missing-executable"])).toThrow(
"Packaged smoke executable not found: /tmp/pawwork-missing-executable",
)
})

test("resolveLaunchCommand uses Electron for raw runs and the app executable for packaged runs", () => {
const raw = resolveLaunchCommand({ mode: "raw", channel: "dev" })
expect(raw.args).toEqual([resolveMainEntry()])
expect(raw.command).toContain("electron")

const packaged = resolveLaunchCommand({
mode: "packaged",
channel: "dev",
executablePath: "/tmp/PawWork Dev.app/Contents/MacOS/PawWork Dev",
})
expect(packaged).toEqual({
command: "/tmp/PawWork Dev.app/Contents/MacOS/PawWork Dev",
args: [],
})
})

test("packaged smoke reports spawn failures with launch context", () => {
const dir = mkdtempSync(path.join(tmpdir(), "pawwork-ci-smoke-"))
try {
const executablePath = path.join(dir, "PawWork")
writeFileSync(executablePath, "")
chmodSync(executablePath, 0o755)

const result = spawnSync(
process.execPath,
[path.join(import.meta.dir, "ci-smoke.ts"), "packaged", "dev", executablePath],
{
encoding: "utf8",
timeout: 5_000,
},
)

expect(result.status).not.toBe(0)
expect(`${result.stdout}${result.stderr}`).toContain("Failed to launch desktop app:")
expect(`${result.stdout}${result.stderr}`).toContain(executablePath)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
96 changes: 83 additions & 13 deletions packages/desktop-electron/scripts/ci-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,53 @@ import { desktopShellMainSelector, titlebarShellSelector } from "../src/renderer
export const requiredSelectors = [titlebarShellSelector, desktopShellMainSelector]
const require = createRequire(import.meta.url)

export type SmokeChannel = "dev" | "beta" | "prod"
export type SmokeMode = "raw" | "packaged"

export type SmokeTarget =
| { mode: "raw"; channel: SmokeChannel }
| { mode: "packaged"; channel: SmokeChannel; executablePath: string }

type LaunchedApp = {
child: ChildProcessWithoutNullStreams
spawnError: { current: Error | undefined }
}

const APP_ID_BY_CHANNEL: Record<SmokeChannel, string> = {
dev: "ai.pawwork.desktop.dev",
beta: "ai.pawwork.desktop.beta",
prod: "ai.pawwork.desktop",
}

function parseChannel(raw: string | undefined): SmokeChannel {
if (raw === undefined || raw === "") return "dev"
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
throw new Error(`Unsupported smoke channel: ${raw}`)
}

export function appIdForSmoke(channel: SmokeChannel, mode: SmokeMode) {
if (mode === "raw") return APP_ID_BY_CHANNEL.dev
return APP_ID_BY_CHANNEL[channel]
}

export function parseSmokeArgs(argv: string[]): SmokeTarget {
const mode = argv[0] as SmokeMode | undefined
if (mode === undefined || mode === "raw") {
return { mode: "raw", channel: parseChannel(argv[1]) }
}
if (mode !== "packaged") throw new Error(`Unsupported smoke mode: ${mode}`)

const executablePath = argv[2]
if (!executablePath) throw new Error("Packaged smoke requires an executable path")
if (!existsSync(executablePath)) throw new Error(`Packaged smoke executable not found: ${executablePath}`)
return { mode, channel: parseChannel(argv[1]), executablePath }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export function resolveMainEntry() {
return resolve(import.meta.dir, "../out/main/index.js")
}

export function buildSmokeEnv(homeDir: string) {
export function buildSmokeEnv(homeDir: string, channel: SmokeChannel = "dev") {
return {
...process.env,
CI: "true",
Expand All @@ -26,18 +68,27 @@ export function buildSmokeEnv(homeDir: string) {
XDG_CACHE_HOME: homeDir,
XDG_CONFIG_HOME: homeDir,
XDG_STATE_HOME: homeDir,
OPENCODE_CHANNEL: "dev",
OPENCODE_CHANNEL: channel,
}
}

export function resolveCiSmokeReadyFile(homeDir: string) {
return join(homeDir, "ai.pawwork.desktop.dev", "ci-smoke-ready.json")
export function resolveCiSmokeReadyFile(homeDir: string, options: { channel?: SmokeChannel; mode?: SmokeMode } = {}) {
const channel = options.channel ?? "dev"
const mode = options.mode ?? "raw"
return join(homeDir, appIdForSmoke(channel, mode), "ci-smoke-ready.json")
}

function resolveElectronBinary() {
return require("electron/index.js") as string
}

export function resolveLaunchCommand(target: SmokeTarget) {
if (target.mode === "packaged") {
return { command: target.executablePath, args: [] as string[] }
}
return { command: resolveElectronBinary(), args: [resolveMainEntry()] }
}

function watchChildLogs(child: ChildProcessWithoutNullStreams) {
const stdout = readline.createInterface({ input: child.stdout })
const stderr = readline.createInterface({ input: child.stderr })
Expand All @@ -60,11 +111,18 @@ function watchChildLogs(child: ChildProcessWithoutNullStreams) {
}
}

async function waitForCiSmokeReady(homeDir: string, child: ChildProcessWithoutNullStreams, recent: string[]) {
const readyFile = resolveCiSmokeReadyFile(homeDir)
async function waitForCiSmokeReady(
homeDir: string,
target: SmokeTarget,
child: ChildProcessWithoutNullStreams,
spawnError: { current: Error | undefined },
recent: string[],
) {
const readyFile = resolveCiSmokeReadyFile(homeDir, { channel: target.channel, mode: target.mode })
const timeoutAt = Date.now() + 60_000

while (Date.now() < timeoutAt) {
if (spawnError.current) throw new Error(`Failed to launch desktop app: ${spawnError.current.message}`)
if (existsSync(readyFile)) return

if (child.exitCode !== null || child.signalCode !== null) {
Expand All @@ -79,11 +137,22 @@ async function waitForCiSmokeReady(homeDir: string, child: ChildProcessWithoutNu
throw new Error(`Timed out waiting for the desktop app to report CI smoke readiness${tail}`)
}

function launchApp(homeDir: string) {
return spawn(resolveElectronBinary(), [resolveMainEntry()], {
env: buildSmokeEnv(homeDir),
stdio: ["ignore", "pipe", "pipe"],
})
function launchApp(homeDir: string, target: SmokeTarget): LaunchedApp {
const launch = resolveLaunchCommand(target)
const spawnError = { current: undefined as Error | undefined }
try {
const child = spawn(launch.command, launch.args, {
env: buildSmokeEnv(homeDir, target.channel),
stdio: ["ignore", "pipe", "pipe"],
})
child.on("error", (error) => {
spawnError.current = error
})
return { child, spawnError }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`Failed to launch desktop app: ${message}`)
}
}

async function stopChild(child: ChildProcessWithoutNullStreams) {
Expand All @@ -99,12 +168,13 @@ async function stopChild(child: ChildProcessWithoutNullStreams) {
}

async function main() {
const target = parseSmokeArgs(Bun.argv.slice(2))
const homeDir = mkdtempSync(join(tmpdir(), "pawwork-ci-smoke-"))
const child = launchApp(homeDir)
const { child, spawnError } = launchApp(homeDir, target)
const logs = watchChildLogs(child)

try {
await waitForCiSmokeReady(homeDir, child, logs.recent)
await waitForCiSmokeReady(homeDir, target, child, spawnError, logs.recent)
} finally {
logs.close()
await stopChild(child)
Expand Down
Loading
Loading