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
29 changes: 28 additions & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,22 @@ tasks:
desc: run e2e tests using act with focus
cmd: act workflow_dispatch -W .github/workflows/act.yml -j e2e-test --input test_focus={{ .CLI_ARGS }}

desktop:deps:
desc: install Linux desktop runtime dependencies for Electron and Playwright
cmds:
- cmd: sudo apt-get update
- cmd: >-
sudo apt-get install -y
libglib2.0-0 libnspr4 libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0
libcairo2 libxshmfence1 libgtk-3-0 libxss1 libatspi2.0-0 libnotify4 libxtst6 xdg-utils
libuuid1 xvfb

desktop:setup:
desc: setup Electron desktop application
dir: desktop
deps:
- desktop:deps
cmds:
- task: cli:build:dev
- cmd: mkdir -p resources/bin
Expand Down Expand Up @@ -183,8 +196,22 @@ tasks:

desktop:test:e2e:
desc: run desktop e2e tests
deps:
- desktop:deps
dir: desktop
cmd: npm run test:e2e
cmd: |
export DISPLAY=:99
if [ ! -f /tmp/.X99-lock ]; then
Xvfb :99 -screen 0 1280x720x24 >/tmp/devsy-xvfb.log 2>&1 &
# Wait for Xvfb to be ready (max 10 seconds)
for i in $(seq 1 50); do
if [ -f /tmp/.X99-lock ]; then
break
fi
sleep 0.2
done
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
npm run test:e2e

desktop:act:build:ui:
desc: build desktop ui using act
Expand Down
57 changes: 43 additions & 14 deletions desktop/e2e/fixtures/mock-devsy.cjs
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -204,19 +204,35 @@ function handleSsh() {
process.exit(0)
}

// Adds a completed workspace to state.
function materializeWorkspace(wsId, source, providerFlag, ideFlag) {
state.workspaces.push({
id: wsId,
uid: `ws-${Date.now()}`,
source: { gitRepository: source },
provider: { name: providerFlag || "docker" },
ide: { name: ideFlag || "none" },
status: "Running",
lastUsed: new Date().toISOString(),
created: new Date().toISOString(),
context: "default",
})
// Adds or updates a workspace in state, preserving the existing workspace
// shape when the same id already exists so lifecycle transitions are stable.
function materializeWorkspace(wsId, source, providerFlag, ideFlag, status = "Running") {
const existingIndex = state.workspaces.findIndex((w) => w.id === wsId)
const now = new Date().toISOString()

if (existingIndex >= 0) {
// For existing workspaces, merge only lifecycle fields; preserve metadata
const existing = state.workspaces[existingIndex]
state.workspaces[existingIndex] = {
...existing,
status,
lastUsed: now,
}
} else {
// For new workspaces, build all fields from arguments
const entry = {
id: wsId,
uid: `ws-${Date.now()}`,
source: { gitRepository: source },
provider: { name: providerFlag || "docker" },
ide: { name: ideFlag || "none" },
status,
lastUsed: now,
created: now,
context: "default",
}
state.workspaces.push(entry)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
saveState(state)
}

Expand Down Expand Up @@ -256,7 +272,7 @@ function handleUp(args) {
out("Pulling image")
out("Starting workspace")
out("Workspace ready")
materializeWorkspace(wsId, source, providerFlag, ideFlag)
materializeWorkspace(wsId, source, providerFlag, ideFlag, "Running")
process.exit(0)
}

Expand Down Expand Up @@ -404,6 +420,18 @@ function handleStop(args) {
process.exit(0)
}

function handleStart(args) {
const { positional, idFlag, providerFlag, ideFlag } = parseArgs(args)
const source = positional[0]
const wsId = idFlag || source || "workspace"
out("Resolving source")
out("Pulling image")
out("Starting workspace")
out("Workspace ready")
materializeWorkspace(wsId, source, providerFlag, ideFlag, "Running")
process.exit(0)
}

function handleDelete(args) {
const { positional } = parseArgs(args)
const wsId = positional[0]
Expand Down Expand Up @@ -448,6 +476,7 @@ const workspaceHandlers = {
ssh: handleSsh,
up: handleUp,
task: handleTask,
start: handleStart,
stop: handleStop,
delete: handleDelete,
rename: handleRename,
Expand Down
119 changes: 116 additions & 3 deletions desktop/e2e/workspaces.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,124 @@ test.describe("Workspace lifecycle badges", () => {
})
})

test.describe("Workspace detail flow", () => {
test.beforeEach(async () => {
await page.click('[data-sidebar="sidebar"] a[href="#/workspaces"]')
await page.locator("table").waitFor({ timeout: 10000 })
})

const api = async (channel: string, args: Record<string, unknown>) =>
page.evaluate(
([c, a]) =>
(
window as unknown as {
electronAPI: {
invoke: (
c: string,
a?: Record<string, unknown>,
) => Promise<unknown>
}
}
).electronAPI.invoke(c as string, a as Record<string, unknown>),
[channel, args] as const,
)

test("supports a start/stop playthrough from the workspaces list", async () => {
const workspaceName = "flow-playthrough"
await api("workspace_up", {
source: "https://example.com/flow-playthrough.git",
workspaceId: workspaceName,
})

await expect(page.locator("table")).toContainText(workspaceName, {
timeout: 10000,
})

await page
.locator("tbody tr")
.filter({ hasText: workspaceName })
.first()
.click()

await expect(
page.getByRole("heading", { name: workspaceName }),
).toBeVisible({ timeout: 10000 })

const main = page.locator('[data-slot="sidebar-inset"] main')
await expect(main).toContainText("Running")

await page.getByRole("button", { name: /^stop$/i }).click()
await expect(main).toContainText("Stopping", { timeout: 3000 })
await expect(main).toContainText("Stopped", { timeout: 10000 })

await page.getByRole("button", { name: /^start$/i }).click()
await expect(main).toContainText("Starting", { timeout: 3000 })
await expect(main).toContainText("Running", { timeout: 10000 })
})

test("shows rebuild and delete confirmation flows from the detail page", async () => {
const workspaceName = "flow-delete-rebuild"
await api("workspace_up", {
source: "https://example.com/flow-delete-rebuild.git",
workspaceId: workspaceName,
})

await page.locator("table").waitFor({ timeout: 10000 })
await expect(
page.locator("tbody tr").filter({ hasText: workspaceName }).first(),
).toBeVisible({ timeout: 10000 })

await page
.locator("tbody tr")
.filter({ hasText: workspaceName })
.first()
.click()

await expect(
page.getByRole("heading", { name: workspaceName }),
).toBeVisible({ timeout: 10000 })

await page.getByRole("button", { name: /more actions/i }).click()
await page.getByRole("menuitem", { name: /rebuild/i }).click()

const rebuildDialog = page.locator('[role="dialog"]').filter({ hasText: /rebuild workspace/i }).first()
await expect(rebuildDialog).toBeVisible({ timeout: 5000 })
await expect(rebuildDialog).toContainText("Rebuild workspace")
await rebuildDialog.getByRole("button", { name: /^cancel$/i }).click()

await page.getByRole("button", { name: /more actions/i }).click()
await page.getByRole("menuitem", { name: /delete/i }).click()

const deleteDialog = page.locator('[role="dialog"]').filter({ hasText: /delete workspace/i }).first()
await expect(deleteDialog).toBeVisible({ timeout: 5000 })
await expect(deleteDialog).toContainText("Delete workspace")
await deleteDialog.getByRole("button", { name: /^cancel$/i }).click()
})
})

test.describe.serial("Create Workspace Wizard", () => {
test("should open the wizard and show step 1 (provider)", async () => {
await page.getByRole("button", { name: /create workspace/i }).click()
async function openCreateWorkspaceWizard(page: Page) {
await page.click('[data-sidebar="sidebar"] a[href="#/workspaces"]')
await page.locator('[data-slot="sidebar-inset"] main').first().waitFor({
timeout: 30_000,
})

// Support both old/new CTA labels.
const createWorkspaceButton = page
.getByRole("button", { name: /create workspace|new workspace/i })
.first()

await expect(createWorkspaceButton).toBeVisible({ timeout: 30_000 })
await expect(createWorkspaceButton).toBeEnabled({ timeout: 30_000 })
await createWorkspaceButton.click()

const dialog = page.locator('[role="dialog"]').first()
await expect(dialog).toBeVisible({ timeout: 5000 })
await expect(dialog).toBeVisible({ timeout: 10_000 })
return dialog
}

test("should open the wizard and show step 1 (provider)", async () => {
const dialog = await openCreateWorkspaceWizard(page)

// Step indicator labels — all 5 steps present
for (const label of ["Provider", "Source", "IDE", "Review", "Launch"]) {
Expand Down
11 changes: 11 additions & 0 deletions desktop/src/main/__tests__/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ describe("DaemonState", () => {
expect(state.workspaceList()).toHaveLength(1)
})

it("preserves a cached workspace status when later updates omit it", () => {
const state = new DaemonState()
expect(
state.updateWorkspaces([{ id: "ws1", lastUsed: "2024-01-01", status: "Running" }]),
).toBe(true)
expect(
state.updateWorkspaces([{ id: "ws1", lastUsed: "2024-01-02" }]),
).toBe(true)
expect(state.workspaceList()[0].status).toBe("Running")
})

it("detects provider changes", () => {
const state = new DaemonState()
const providers = [makeProvider("docker")]
Expand Down
74 changes: 72 additions & 2 deletions desktop/src/main/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,17 +272,24 @@ export class CliRunner {
}

let lastCliError: CLIError | undefined
let suppressCallbacks = false

if (child.stdout) {
const applyBackpressure = backpressureController(child.stdout)
const rl = createInterface({ input: child.stdout })
rl.on("line", (line) => applyBackpressure(onLine(line, "stdout")))
rl.on("line", (line) => {
if (suppressCallbacks) return
applyBackpressure(onLine(line, "stdout"))
})
// Store readline interface for cleanup
;(child as unknown as { _rlStdout?: typeof rl })._rlStdout = rl
}

if (child.stderr) {
const applyBackpressure = backpressureController(child.stderr)
const rl = createInterface({ input: child.stderr })
rl.on("line", (line) => {
if (suppressCallbacks) return
const parsed = parseStderrLine(line)
if (parsed?.cliError) {
lastCliError = parsed.cliError
Expand All @@ -295,6 +302,13 @@ export class CliRunner {
}
applyBackpressure(onLine(line, "stderr", meta))
})
// Store readline interface for cleanup
;(child as unknown as { _rlStderr?: typeof rl })._rlStderr = rl
}

// Expose a method to suppress callbacks (used by cancelFor timeout)
;(child as unknown as { _suppressCallbacks?: () => void })._suppressCallbacks = () => {
suppressCallbacks = true
}

let settled = false
Expand All @@ -313,6 +327,9 @@ export class CliRunner {
onExit(code, cliError)
}

// Expose finish for cancelFor timeout handling
;(child as unknown as { _finish?: typeof finish })._finish = finish

// A spawn failure (missing binary, EACCES) emits "error" and never
// "close". Without this, onExit never fires: callers that wrap this in a
// promise hang forever, and the concurrency slot is never released.
Expand Down Expand Up @@ -345,13 +362,66 @@ export class CliRunner {

const waits: Promise<void>[] = []
for (const child of bucket) {
let timedOut = false
waits.push(
new Promise<void>((resolve) => {
let settled = false
let timer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
timer = null
settled = true
timedOut = true
resolve()
}, 2000)

if (child.exitCode !== null || child.signalCode !== null) {
if (timer) clearTimeout(timer)
settled = true
resolve()
return
}
child.once("close", () => resolve())
child.once("close", () => {
if (timer) {
clearTimeout(timer)
timer = null
}
settled = true
resolve()
})
}).then(() => {
// If process did not close in time, forcefully kill and suppress late callbacks
if (child.exitCode === null && child.signalCode === null) {
// Run lifecycle cleanup BEFORE suppressing callbacks/removing listeners
// so finish(...) can properly clean up sessions and call onExit
if (timedOut) {
const finishFn = (child as unknown as { _finish?: (code: number, cliError?: CLIError) => void })._finish
if (finishFn) {
finishFn(-1, { code: "timeout", message: "Process did not exit in time" })
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Now suppress callbacks at the source
const suppressFn = (child as unknown as { _suppressCallbacks?: () => void })._suppressCallbacks
if (suppressFn) suppressFn()

// Close readline interfaces
const rlStdout = (child as unknown as { _rlStdout?: { close: () => void } })._rlStdout
const rlStderr = (child as unknown as { _rlStderr?: { close: () => void } })._rlStderr
if (rlStdout) rlStdout.close()
if (rlStderr) rlStderr.close()

// Destroy streams to stop emitting data events
if (child.stdout) {
child.stdout.removeAllListeners()
child.stdout.destroy()
}
if (child.stderr) {
child.stderr.removeAllListeners()
child.stderr.destroy()
}

child.removeAllListeners()
child.kill("SIGKILL")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}),
)
child.kill("SIGTERM")
Expand Down
Loading
Loading