Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
different-ai avatar

Daytona Electron Test

  • 22 installs
  • 20.9k repo stars
  • Updated August 5, 2026
  • different-ai/openwork

Drives the real OpenWork Electron app inside a Daytona sandbox via CDP to create workspaces, run sessions, verify settings, and reproduce bugs.

About

Creates a Daytona VNC sandbox, boots Vite and Electron, connects browser tools over CDP, and drives the app end-to-end. A developer uses it to test the Electron app or reproduce bugs remotely without local Electron.

  • Helper script boots XFCE, Vite, Electron and prints CDP/noVNC URLs
  • Selector map and React-fiber tricks to bypass native file pickers

Daytona Electron Test by the numbers

  • 22 all-time installs (skills.sh)
  • Ranked #1,413 of 2,153 Testing & QA skills by installs in the Skillselion catalog
  • Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/different-ai/openwork --skill daytona-electron-test

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs22
repo stars20.9k
Last updatedAugust 5, 2026
Repositorydifferent-ai/openwork

What it does

Drives the real OpenWork Electron app inside a Daytona sandbox via CDP to create workspaces, run sessions, verify settings, and reproduce bugs.

Files

SKILL.mdMarkdownGitHub ↗

Skill: Daytona Electron Test

Drive the real OpenWork Electron app inside a Daytona sandbox via CDP browser tools. Covers workspace creation, session interaction, settings verification, and bug reproduction.

When to use

  • User says "test on Daytona", "run the app on Daytona", "Daytona dry run"
  • User wants to reproduce a bug in the real Electron app remotely
  • User wants to verify a UI flow end-to-end without local Electron

Fastest path: the script

Run the helper script from the repo root. It creates a Daytona VNC-capable sandbox, checks out the ref, starts XFCE/noVNC, Vite, Electron, and waits for CDP:

bash .devcontainer/test-on-daytona.sh [branch-or-commit]

It prints the CDP and noVNC URLs at the end. Then use browser_list to connect.

Manual path (step by step)

1. Create the sandbox

SANDBOX="openwork-test-$(date +%Y%m%d-%H%M%S)"

daytona create \
  --name "$SANDBOX" \
  --dockerfile .devcontainer/Dockerfile.daytona-vnc \
  --context .devcontainer/Dockerfile.daytona-vnc \
  --context .devcontainer/start-daytona-vnc.sh \
  --class large \
  --memory 8 \
  --disk 10 \
  --auto-stop 60 \
  --public \
  --target us

CRITICAL: Always --memory 8. The default 1 GB will OOM-kill pnpm install and Vite's esbuild. Electron + Vite + opencode needs ~6 GB.

CRITICAL: Always --disk 10 with the Daytona VNC image. The default 3 GB can fill up during dependency/sidecar work.

WHY THIS IMAGE: Use .devcontainer/Dockerfile.daytona-vnc, which is based on daytonaio/sandbox:0.6.0. It includes Daytona's expected desktop stack: Xvfb, XFCE, x11vnc, noVNC, websockify, and dbus-x11. Do not use the generic node:20-bookworm + fluxbox path for Electron/noVNC tests unless debugging the old setup.

2. Checkout the branch under test

The Dockerfile clones dev at build time. Fetch and checkout the target:

daytona exec "$SANDBOX" -- "bash -lc 'cd /workspace && git fetch origin <ref> && git checkout <ref> && pnpm install --frozen-lockfile || pnpm install'"

3. Start services (background, don't block)

daytona exec "$SANDBOX" -- "bash -lc 'cd /workspace && nohup bash .devcontainer/start-daytona-vnc.sh > /tmp/start-vnc.log 2>&1 &'"

daytona exec "$SANDBOX" -- "bash -lc 'cd /workspace/apps/app && nohup env OPENWORK_DEV_MODE=1 pnpm exec vite --host 0.0.0.0 --port 5173 > /tmp/vite.log 2>&1 &'"

daytona exec "$SANDBOX" -- "bash -lc 'cd /workspace && nohup env DISPLAY=:99 ELECTRON_DISABLE_SANDBOX=1 OPENWORK_REACT_DEVTOOLS=0 OPENWORK_DEV_MODE=1 OPENWORK_ELECTRON_REMOTE_DEBUG_PORT=9825 pnpm --filter @openwork/desktop dev:electron > /tmp/electron.log 2>&1 &'"

IMPORTANT: Keep these as separate daytona exec calls. start-daytona-vnc.sh starts long-lived desktop services, Vite is a long-lived dev server, and Electron is long-lived. Running them in the foreground blocks daytona exec.

Wait ~35-60s for XFCE + Vite + Electron + opencode sidecar to boot.

4. Get URLs

# Electron CDP (automation) -- THIS IS WHAT browser_list CONNECTS TO
daytona preview-url "$SANDBOX" -p 9825

# noVNC (visual access in your browser)
daytona preview-url "$SANDBOX" -p 6080

5. Connect browser tools

browser_list({ browser_url: "<CDP_URL>" })

Should show: [target_id] OpenWork http://localhost:5173/#/welcome

6. Verify it's real Electron (not plain Chromium)

browser_eval({ expression: "navigator.userAgent" })

Must contain Electron/.

Creating a workspace through the UI

Prepare the directory first

daytona exec "$SANDBOX" -- "bash -lc 'mkdir -p /workspace/hello'"

Drive the modal

1. Click "Get started":

(function() { var btns = document.querySelectorAll('button'); for (var i = 0; i < btns.length; i++) { if (btns[i].textContent.indexOf('Get started') !== -1) { btns[i].click(); return 'clicked'; } } return 'not found'; })()

2. Click "Local workspace":

(function() { var btns = document.querySelectorAll('button'); for (var i = 0; i < btns.length; i++) { if (btns[i].textContent.indexOf('Local workspace') !== -1) { btns[i].click(); return 'clicked'; } } return 'not found'; })()

3. Inject folder path (bypasses the native file picker that can't work headless):

JSON.stringify((function() {
  function findFiber(el) {
    var key = Object.keys(el).find(function(k) { return k.startsWith('__reactFiber$'); });
    return key ? el[key] : null;
  }
  var all = document.querySelectorAll('span,div,p');
  var p = null;
  for (var i = 0; i < all.length; i++) {
    if (all[i].textContent.indexOf('No folder') !== -1) { p = all[i]; break; }
  }
  if (!p) return {err: 'no placeholder'};
  var fiber = findFiber(p);
  while (fiber) {
    var name = (fiber.elementType && fiber.elementType.name) || (fiber.type && fiber.type.name) || '';
    if (name === 'CreateWorkspaceModal') break;
    fiber = fiber.return;
  }
  if (!fiber) return {err: 'no fiber'};
  var hook = fiber.memoizedState;
  while (hook) {
    if (hook.queue && hook.queue.dispatch) {
      hook.queue.dispatch({ key: 'selectedFolder', value: '/workspace/hello' });
      hook.queue.dispatch({ key: 'pickingFolder', value: false });
      return {ok: true};
    }
    hook = hook.next;
  }
  return {err: 'no dispatch'};
})())

The reducer uses { key, value } actions. NOT direct state replacement.

4. Click "Create Workspace":

(function() { var btns = document.querySelectorAll('button'); for (var i = 0; i < btns.length; i++) { if (btns[i].textContent.trim() === 'Create Workspace' && !btns[i].disabled) { btns[i].click(); return 'clicked'; } } return 'not found'; })()

5. Wait 10-12s. Verify:

  • URL contains #/workspace/ws_
  • Status bar shows "OpenWork Ready"
  • opencode process running: daytona exec "$SANDBOX" -- "bash -lc 'ps aux | grep opencode | grep -v grep'"

UI automation selector map

Before guessing selectors, check the owning component. Prefer ARIA labels, button text, and input placeholders over brittle CSS classes. Use React fiber only when bypassing native file pickers.

ControlStable selector/searchSource file
Settings buttonbutton[aria-label="Settings"]apps/app/src/react-app/domains/session/chat/status-bar.tsx
Back to appbutton text Back to appapps/app/src/react-app/domains/settings/shell/settings-shell.tsx
New taskbutton[aria-label="New task"]apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx
Run taskbutton text Run taskapps/app/src/react-app/domains/session/surface/composer/composer.tsx
Model selectorbutton[aria-label="Change model"]apps/app/src/react-app/domains/session/surface/composer/composer.tsx
Composer editor[contenteditable="true"][data-lexical-editor="true"]apps/app/src/react-app/domains/session/surface/composer/editor.tsx
AI Providers tabbutton text AI Providersapps/app/src/react-app/domains/settings/shell/settings-page.tsx
Connect providerbutton text Connect providerapps/app/src/react-app/domains/settings/pages/ai-view.tsx
Provider searchinput[placeholder="Filter providers by name or ID"]apps/app/src/react-app/domains/connections/provider-auth/provider-auth-modal.tsx
Manual key optionbutton containing Manually enter API Keyprovider-auth-modal.tsx
API key inputinput[type="password"][placeholder="sk-..."]provider-auth-modal.tsx
Save keybutton text Save keyprovider-auth-modal.tsx

Reusable click helpers:

// Click exact button text.
(function(text) { var b = Array.from(document.querySelectorAll('button')).find(function(el) { return el.textContent.trim() === text && !el.disabled; }); if (!b) return 'not found: ' + text; b.click(); return 'clicked: ' + text; })('AI Providers')
// Click an ARIA-labeled button/link.
(function(label) { var el = Array.from(document.querySelectorAll('button,a')).find(function(node) { return node.getAttribute('aria-label') === label && !node.disabled; }); if (!el) return 'not found: ' + label; el.click(); return 'clicked: ' + label; })('Settings')
// Set a React-controlled input.
(function(selector, value) { var input = document.querySelector(selector); if (!input) return 'not found: ' + selector; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, value); input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value })); return 'set: ' + selector; })('input[placeholder="Filter providers by name or ID"]', 'openai')
// Paste text into the Lexical composer. Prefer this over execCommand in Electron/CDP.
(function(text) { var editor = document.querySelector('[contenteditable="true"][data-lexical-editor="true"]'); if (!editor) return 'no editor'; editor.focus(); var data = new DataTransfer(); data.setData('text/plain', text); editor.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data })); return editor.innerText; })('Reply with exactly: Daytona UI key OK')

Connect OpenAI through the UI

Use this when the user provides a temporary key and asks to test real model sessions. Do not write the key into docs or repo files.

1. Open Settings using button[aria-label="Settings"]. 2. Click AI Providers. 3. Click Connect provider. 4. Set input[placeholder="Filter providers by name or ID"] to openai. 5. Click the provider row containing OpenAI and openai. 6. Click Manually enter API Key. 7. Set input[type="password"][placeholder="sk-..."] to the key. 8. Click Save key. 9. Verify text includes 2 providers connected, OpenAI, and Disconnect. 10. Click Pick a new default?, expand OpenAI, select Default model, and click GPT-5.5gpt-5.5. 11. Return to app, create a session, paste a prompt into the composer, and click Run task.

Expected successful session message metadata: provider openai, model gpt-5.5, variant medium.

Session interaction

Prerequisites: API key for real LLM sessions

To test real sessions (not just UI flow), the opencode sidecar needs an LLM provider key. The easiest is OpenAI:

daytona exec "$SANDBOX" -- "bash -lc 'cd /workspace/hello && node -e \"
  const fs = require(\\\"fs\\\");
  const p = \\\"opencode.jsonc\\\";
  let c = JSON.parse(fs.readFileSync(p, \\\"utf8\\\").replace(/^\\\\/\\\\/.*$/gm, \\\"\\\"));
  c.provider = c.provider || {};
  c.provider.openai = { options: { apiKey: process.env.KEY } };
  fs.writeFileSync(p, JSON.stringify(c, null, 2));
\" '"

Set KEY=sk-proj-... in the command above. After writing the config, you must restart all services (see "Injecting API keys" section below) for opencode to pick up the new provider.

To switch models in the UI, click the model name in the bottom bar (e.g. "Big Pickle") and select the desired model (e.g. GPT-5.5).

Type in the Lexical composer

(function() {
  var editor = document.querySelector('[contenteditable=true]');
  if (!editor) return 'no editor';
  editor.focus();
  document.execCommand('selectAll', false, null);
  document.execCommand('insertText', false, 'YOUR PROMPT HERE');
  return 'typed';
})()

MUST use `document.execCommand('insertText', ...)`. Direct textContent = or innerHTML = does NOT trigger Lexical state updates.

Click Run task

(function() { var btns = document.querySelectorAll('button'); for (var i = 0; i < btns.length; i++) { if (btns[i].textContent.indexOf('Run task') !== -1 && !btns[i].disabled) { btns[i].click(); return 'clicked'; } } return 'not found'; })()

Check response

document.body.innerText.substring(0, 3000)

Settings navigation

Open settings (gear icon):

(function() { var el = Array.from(document.querySelectorAll('button,a')).find(function(node) { return node.getAttribute('aria-label') === 'Settings'; }); if (!el) return 'not found'; el.click(); return 'clicked'; })()

Navigate to a panel (e.g. AI Providers):

(function() { var btn = Array.from(document.querySelectorAll('button')).find(function(el) { return el.textContent.trim() === 'AI Providers'; }); if (!btn) return 'not found'; btn.click(); return 'clicked'; })()

Back to app:

(function() { var btn = Array.from(document.querySelectorAll('button')).find(function(el) { return el.textContent.trim() === 'Back to app'; }); if (!btn) return 'not found'; btn.click(); return 'clicked'; })()

Window management (minimize/restore testing)

Install xdotool first:

daytona exec "$SANDBOX" -- "bash -lc 'apt-get update && apt-get install -y xdotool'"

Then:

# Minimize
daytona exec "$SANDBOX" -- "bash -lc 'DISPLAY=:99 xdotool search --name OpenWork windowminimize'"

# Restore
daytona exec "$SANDBOX" -- "bash -lc 'DISPLAY=:99 xdotool search --name OpenWork windowactivate'"

Injecting API keys

Edit the workspace opencode config:

daytona exec "$SANDBOX" -- "bash -lc 'cd /workspace/hello && node -e \"...update opencode.jsonc...\"'"

Then restart Electron so opencode picks up the new config:

# Step 1: kill Electron/runtime children
daytona exec "$SANDBOX" -- "bash -lc 'pkill -f electron || true; pkill -f electron-dev || true; pkill -f opencode || true'"

# Step 2: wait, then restart Electron (separate exec call)
sleep 3
daytona exec "$SANDBOX" -- "bash -lc 'cd /workspace && nohup env DISPLAY=:99 ELECTRON_DISABLE_SANDBOX=1 OPENWORK_REACT_DEVTOOLS=0 OPENWORK_ELECTRON_REMOTE_DEBUG_PORT=9825 OPENWORK_DEV_MODE=1 pnpm --filter @openwork/desktop dev:electron > /tmp/electron.log 2>&1 &'"

GOTCHA: Do NOT chain pkill and the restart in the same daytona exec call. pkill -f electron sends SIGTERM to the exec session itself (because the command string matches). The restart never runs. Always use two separate daytona exec calls with a sleep between them.

Ports reference

ServicePortDescription
noVNC6080See the Electron app visually
Vite HMR5173React UI hot reload
CDP9825Chrome DevTools Protocol for automation
Den Web3005Admin dashboard (needs MySQL)
Den API8788Control plane (needs MySQL)

Troubleshooting

OOM during pnpm install or Vite esbuild crash (EPIPE): You used --memory 1 (default). Always --memory 8.

Electron exits with "Running as root without --no-sandbox": The devcontainer sets ELECTRON_DISABLE_SANDBOX=1. If running Electron manually, pass --no-sandbox or set the env var.

Generic DBus errors in Electron logs: DBus warnings are expected in Daytona/Linux containers. They are not fatal if you also see DevTools listening on ws://127.0.0.1:9825/... and an OpenWork window in noVNC.

GPU process errors in Electron logs: Exiting GPU process due to errors during initialization is common under Xvfb. It is not fatal if Chromium falls back and the window appears. If CDP never prints DevTools listening, check /tmp/electron.log and restart Electron.

"bun: not found" during dev:electron: The sidecar prep script uses bun. The devcontainer Dockerfile installs it globally. If you built a custom Dockerfile, add RUN npm install -g bun.

"xauth command not found": apt-get install -y xauth (already in the devcontainer Dockerfile).

CDP shows no targets after 60s: Check /tmp/electron.log and /tmp/vite.log:

daytona exec "$SANDBOX" -- "bash -lc 'tail -80 /tmp/electron.log'"
daytona exec "$SANDBOX" -- "bash -lc 'tail -80 /tmp/vite.log'"

The app log line [openwork] Electron CDP exposed at http://127.0.0.1:9825 means OpenWork requested CDP. The real success marker is Chromium's own line: DevTools listening on ws://127.0.0.1:9825/devtools/browser/....

opencode sidecar not restarting after kill: The Electron runtime manager does NOT auto-detect sidecar death. You must restart the entire Electron process.

`daytona exec` with `pkill` kills the exec session: The process pattern match hits the exec wrapper. Always split kill and restart into separate daytona exec calls.

Blank Electron window (empty `<div id="root"></div>`): Vite crashed (check /tmp/vite.log). Usually memory pressure. Verify free -m shows >2 GB available.

noVNC URL says sandbox not found: Preview URLs are not stable. Regenerate the URL:

daytona preview-url "$SANDBOX" -p 6080

Electron starts twice or CDP says address already in use: Kill the old Electron process before restarting:

daytona exec "$SANDBOX" -- "bash -lc 'pkill -f electron || true; pkill -f electron-dev || true'"

Teardown

daytona delete "$SANDBOX"

Related skills

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.