Skip to content

refactor(wework): isolate desktop control extension boundary - #2082

Merged
qdaxb merged 3 commits into
wecode-ai:mainfrom
FicoHu:refactor/wework-desktop-extension-boundary
Jul 21, 2026
Merged

qdaxb merged 3 commits into
wecode-ai:mainfrom
FicoHu:refactor/wework-desktop-extension-boundary

Conversation

@FicoHu

@FicoHu FicoHu commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

Move desktop-specific control commands and workspace actions behind an extension contract so the public automation runner and workspace panels carry no concrete product protocol.

Summary by CodeRabbit

  • New Features
    • Added optional runtime injection of product-specific desktop scenarios for desktop E2E, including verification and diagnostics in scenario state.
    • Updated cloud desktop integration so workspace desktop actions come from the cloud desktop extension.
    • Improved desktop control handling via extension contracts, with clear failure for unsupported actions.
  • Documentation
    • Refined English and Chinese developer guides describing desktop E2E behavior and cloud desktop extension requirements.
  • Tests
    • Added/updated E2E and component tests for scenario loading, desktop control fallback, cloud desktop actions, and process lifecycle behavior.

Move desktop-specific control commands and workspace actions behind an
extension contract so the public automation runner and workspace panels
carry no concrete product protocol.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds optional product desktop scenarios to the E2E runner, delegates unsupported desktop controls through an extension contract, replaces the workspace desktop launcher with a typed cloud desktop action, and extracts a reusable stable-event hook with related workbench tests.

Changes

Desktop extension boundaries

Layer / File(s) Summary
Product-injected desktop E2E scenarios
wework/e2e/desktop/*, wework/src/e2e/desktop-scenario-loader.test.ts, wework/src/e2e/desktop-process-lifecycle.test.ts, docs/.../wework-e2e-automation.md
The runner dynamically loads optional scenarios, delegates server and HTTP handling, runs verification, supports scenario-only execution, records diagnostics, and uses timeout-aware process-group cleanup.
Extension-backed desktop control
wework/src/extensions/desktop-control-*, wework/src/e2e/automation.ts, docs/.../wework-e2e-automation.md
Desktop commands use shared extension types, delegate unhandled actions, return extension values, and fail explicitly when unsupported.
Cloud desktop workspace action
wework/src/extensions/cloud-desktop*, wework/src/components/layout/workspace-panels/WorkspacePanelCards.*, docs/.../wework-embedded-browser.md
Workspace cards use the typed WorkspaceAction extension component, while desktop-specific tool state and launcher logic are removed; extension boundaries are documented in English and Chinese.
Stable workbench event handling
wework/src/features/workbench/useStableEvent.*, wework/src/features/workbench/WorkbenchProvider.*
useStableEvent is extracted into its own module, imported by WorkbenchProvider, and tested with runtime project-selection behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DesktopRunner
  participant ScenarioLoader
  participant DesktopE2EServer
  participant ProductScenario
  DesktopRunner->>ScenarioLoader: Load configured scenario module
  ScenarioLoader-->>DesktopRunner: Return desktop scenario
  DesktopRunner->>DesktopE2EServer: Attach scenario
  DesktopE2EServer->>ProductScenario: Delegate HTTP handling
  DesktopRunner->>ProductScenario: Run scenario verification
  ProductScenario-->>DesktopRunner: Return diagnostics
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main refactor: moving desktop control behind an extension boundary.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5c04980a41

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +2014 to +2016
if (desktopScenario) {
phase = 'desktop-extension-scenario'
await desktopScenario.verify(control)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run the injected scenario in cloud-only mode

When this runner is invoked with the existing --cloud-only mode, it returns from the cloud-project branch before reaching this new scenario invocation, so setting WEWORK_E2E_DESKTOP_SCENARIO_MODULE silently never runs the product scenario (and WEWORK_E2E_DESKTOP_SCENARIO_ONLY=true is likewise ignored after passing the module-presence check). This prevents product desktop scenarios that require the real cloud environment from being exercised by e2e:desktop:cloud; invoke the scenario before that return or explicitly reject the unsupported combination.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
wework/src/components/layout/workspace-panels/WorkspacePanelCards.test.tsx (1)

392-414: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Restore the extension launch rejection coverage The new WorkspacePanelCards.test.tsx case only covers the happy path, and wework/src/extensions/cloud-desktop.test.tsx still only exercises the unavailable stub. Add a regression test for the rejection/retry path so this failure mode stays covered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wework/src/components/layout/workspace-panels/WorkspacePanelCards.test.tsx`
around lines 392 - 414, Extend the cloud desktop workspace coverage around
WorkspacePanelCards and the cloud-desktop extension to exercise when the
extension launch rejects, then verify the retry path succeeds and the close
behavior remains correct. Add the regression test alongside the existing
“renders the project desktop through the cloud desktop workspace extension”
case, and cover the real rejection flow rather than only the unavailable stub.

Source: Path instructions

🧹 Nitpick comments (1)
wework/src/components/layout/workspace-panels/WorkspacePanelCards.test.tsx (1)

20-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for handleExtensionBusyChange busy-state wiring.

The mock's onClick calls onOpened directly, so no test currently exercises the extension reporting busy=true, which is what should disable other workspace-tool cards while the cloud desktop action is in flight. Consider extending the mock to also expose onBusyChange and asserting that other cards become disabled while busy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wework/src/components/layout/workspace-panels/WorkspacePanelCards.test.tsx`
around lines 20 - 29, Extend the WorkspaceAction mock and its tests around
handleExtensionBusyChange to expose and invoke onBusyChange(true), then assert
that the other workspace-tool cards become disabled while the cloud desktop
action is busy. Preserve the existing onOpened behavior and verify the cards
return to their normal enabled state when busy ends.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@wework/e2e/desktop/task-flow.e2e.mjs`:
- Line 1079: Await both optional desktop scenario hooks: update the cleanup call
around desktopScenario.close and the failure diagnostics construction around
desktopScenario.diagnostics to use optional awaiting. Ensure asynchronous
diagnostics are resolved before serialization, and preserve the original test
error when cleanup in main’s finally block also fails by preventing the cleanup
rejection from replacing the already-propagating failure.

---

Outside diff comments:
In `@wework/src/components/layout/workspace-panels/WorkspacePanelCards.test.tsx`:
- Around line 392-414: Extend the cloud desktop workspace coverage around
WorkspacePanelCards and the cloud-desktop extension to exercise when the
extension launch rejects, then verify the retry path succeeds and the close
behavior remains correct. Add the regression test alongside the existing
“renders the project desktop through the cloud desktop workspace extension”
case, and cover the real rejection flow rather than only the unavailable stub.

---

Nitpick comments:
In `@wework/src/components/layout/workspace-panels/WorkspacePanelCards.test.tsx`:
- Around line 20-29: Extend the WorkspaceAction mock and its tests around
handleExtensionBusyChange to expose and invoke onBusyChange(true), then assert
that the other workspace-tool cards become disabled while the cloud desktop
action is busy. Preserve the existing onOpened behavior and verify the cards
return to their normal enabled state when busy ends.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 58ea2995-5ad1-46bd-8542-4f59e30c8f76

📥 Commits

Reviewing files that changed from the base of the PR and between 017ee69 and 5c04980.

📒 Files selected for processing (16)
  • docs/en/developer-guide/wework-e2e-automation.md
  • docs/en/developer-guide/wework-embedded-browser.md
  • docs/zh/developer-guide/wework-e2e-automation.md
  • docs/zh/developer-guide/wework-embedded-browser.md
  • wework/e2e/desktop/scenario-loader.mjs
  • wework/e2e/desktop/task-flow.e2e.mjs
  • wework/src/components/layout/workspace-panels/WorkspacePanelCards.test.tsx
  • wework/src/components/layout/workspace-panels/WorkspacePanelCards.tsx
  • wework/src/e2e/automation.ts
  • wework/src/e2e/desktop-scenario-loader.test.ts
  • wework/src/extensions/cloud-desktop-contract.ts
  • wework/src/extensions/cloud-desktop.test.tsx
  • wework/src/extensions/cloud-desktop.tsx
  • wework/src/extensions/desktop-control-contract.ts
  • wework/src/extensions/desktop-control.test.ts
  • wework/src/extensions/desktop-control.ts

async close() {
for (const response of this.blockedCloudResponses) response.destroy()
this.blockedCloudResponses.clear()
this.desktopScenario?.close?.()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unawaited optional scenario hooks can mask failures or drop diagnostics.

this.desktopScenario?.close?.() (Line 1079) isn't awaited, unlike handleHttp which is (Line 1258). If close() rejects, control.close()'s returned promise rejects too; since this runs in the finally block of main(), that would replace the original test failure that was about to propagate via throw error — the real cause becomes invisible in CI.

Separately, desktopScenario?.diagnostics?.() (Line 2580) is invoked without awaiting inside the failure diagnostics payload. If a scenario author implements diagnostics() asynchronously, the stored value will be a Promise object, which JSON.stringify serializes as {}, silently losing scenario diagnostics exactly when they're most needed (on failure).

🔧 Proposed fix
   async close() {
     for (const response of this.blockedCloudResponses) response.destroy()
     this.blockedCloudResponses.clear()
-    this.desktopScenario?.close?.()
+    try {
+      await this.desktopScenario?.close?.()
+    } catch (error) {
+      console.error('Desktop scenario close() failed:', error)
+    }
     this.server.closeAllConnections?.()
-          desktopScenario: desktopScenario?.diagnostics?.() ?? null,
+          desktopScenario: (await desktopScenario?.diagnostics?.()) ?? null,

Also applies to: 2580-2580

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wework/e2e/desktop/task-flow.e2e.mjs` at line 1079, Await both optional
desktop scenario hooks: update the cleanup call around desktopScenario.close and
the failure diagnostics construction around desktopScenario.diagnostics to use
optional awaiting. Ensure asynchronous diagnostics are resolved before
serialization, and preserve the original test error when cleanup in main’s
finally block also fails by preventing the cleanup rejection from replacing the
already-propagating failure.

yunpeng7 and others added 2 commits July 20, 2026 21:35
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
wework/src/features/workbench/useStableEvent.ts (1)

3-13: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider using the native useEffectEvent hook.

Since this project uses React 19.2, you might be able to utilize the newly added native useEffectEvent hook instead of maintaining a custom useStableEvent implementation. This built-in hook is specifically designed to provide stable event handler identities natively. Based on learnings, React 19.2 adds useEffectEvent() for stable event handler identities.

♻️ Proposed refactor to use native API
-import { useCallback, useLayoutEffect, useRef } from 'react'
+import { useEffectEvent } from 'react'

-export function useStableEvent<TArgs extends unknown[], TResult>(
-  handler: (...args: TArgs) => TResult
-): (...args: TArgs) => TResult {
-  const handlerRef = useRef(handler)
-
-  useLayoutEffect(() => {
-    handlerRef.current = handler
-  }, [handler])
-
-  return useCallback((...args: TArgs) => handlerRef.current(...args), [])
-}
+export const useStableEvent = useEffectEvent
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wework/src/features/workbench/useStableEvent.ts` around lines 3 - 13, Replace
the custom useStableEvent implementation’s useRef, useLayoutEffect, and
useCallback logic with React 19.2’s native useEffectEvent hook, while preserving
the existing handler signature and stable callback behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@wework/e2e/desktop/process-lifecycle.mjs`:
- Around line 11-29: Update waitForProcessExit to return immediately when the
ChildProcess has no pid, alongside its existing exited-process checks. Preserve
the current exit-event waiting and timeout behavior for processes with a valid
pid.

In `@wework/src/features/workbench/WorkbenchProvider.test.tsx`:
- Line 3849: Wrap the final native click on the “send pane input” element in
flushSync, matching the surrounding interactions, so React state updates
triggered by the click are completed within the test’s act boundary.

---

Nitpick comments:
In `@wework/src/features/workbench/useStableEvent.ts`:
- Around line 3-13: Replace the custom useStableEvent implementation’s useRef,
useLayoutEffect, and useCallback logic with React 19.2’s native useEffectEvent
hook, while preserving the existing handler signature and stable callback
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 00541a14-314d-4c14-892d-4f0e62216122

📥 Commits

Reviewing files that changed from the base of the PR and between 5c04980 and 6c91edf.

📒 Files selected for processing (7)
  • wework/e2e/desktop/process-lifecycle.mjs
  • wework/e2e/desktop/task-flow.e2e.mjs
  • wework/src/e2e/desktop-process-lifecycle.test.ts
  • wework/src/features/workbench/WorkbenchProvider.test.tsx
  • wework/src/features/workbench/WorkbenchProvider.tsx
  • wework/src/features/workbench/useStableEvent.test.tsx
  • wework/src/features/workbench/useStableEvent.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • wework/e2e/desktop/task-flow.e2e.mjs

Comment on lines +11 to +29
function waitForProcessExit(child, timeoutMs) {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
return withTimeout(
new Promise(resolvePromise => child.once('exit', resolvePromise)),
timeoutMs,
`Timed out waiting for process ${child.pid ?? 'unknown'} to exit`
)
}

export async function stopProcess(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) return
child.kill('SIGTERM')
try {
await waitForProcessExit(child, PROCESS_STOP_TIMEOUT_MS)
} catch {
child.kill('SIGKILL')
await waitForProcessExit(child, PROCESS_STOP_TIMEOUT_MS)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Avoid 20-second teardown hangs for processes that failed to spawn.

If a ChildProcess fails to spawn, child.pid will be undefined, and the process will never emit an exit event. Currently, waitForProcessExit will wait for this non-existent event until the timeout expires twice (once for SIGTERM, once for SIGKILL fallback), causing an unnecessary 20-second delay per failed process during test teardown.

Add an early return for missing pid to prevent these hangs and speed up teardown on failure.

⚡ Proposed fix
 function waitForProcessExit(child, timeoutMs) {
-  if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
+  if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
   return withTimeout(
     new Promise(resolvePromise => child.once('exit', resolvePromise)),
     timeoutMs,
     `Timed out waiting for process ${child.pid ?? 'unknown'} to exit`
   )
 }
 
 export async function stopProcess(child) {
-  if (!child || child.exitCode !== null || child.signalCode !== null) return
+  if (!child || child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return
   child.kill('SIGTERM')
   try {
     await waitForProcessExit(child, PROCESS_STOP_TIMEOUT_MS)
   } catch {
     child.kill('SIGKILL')
     await waitForProcessExit(child, PROCESS_STOP_TIMEOUT_MS)
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function waitForProcessExit(child, timeoutMs) {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
return withTimeout(
new Promise(resolvePromise => child.once('exit', resolvePromise)),
timeoutMs,
`Timed out waiting for process ${child.pid ?? 'unknown'} to exit`
)
}
export async function stopProcess(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) return
child.kill('SIGTERM')
try {
await waitForProcessExit(child, PROCESS_STOP_TIMEOUT_MS)
} catch {
child.kill('SIGKILL')
await waitForProcessExit(child, PROCESS_STOP_TIMEOUT_MS)
}
}
function waitForProcessExit(child, timeoutMs) {
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
return withTimeout(
new Promise(resolvePromise => child.once('exit', resolvePromise)),
timeoutMs,
`Timed out waiting for process ${child.pid ?? 'unknown'} to exit`
)
}
export async function stopProcess(child) {
if (!child || child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return
child.kill('SIGTERM')
try {
await waitForProcessExit(child, PROCESS_STOP_TIMEOUT_MS)
} catch {
child.kill('SIGKILL')
await waitForProcessExit(child, PROCESS_STOP_TIMEOUT_MS)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wework/e2e/desktop/process-lifecycle.mjs` around lines 11 - 29, Update
waitForProcessExit to return immediately when the ChildProcess has no pid,
alongside its existing exited-process checks. Preserve the current exit-event
waiting and timeout behavior for processes with a valid pid.

await waitFor(() => expect(screen.getByTestId('runtime-project-count')).toHaveTextContent('1'))
flushSync(() => screen.getByText('start new project task').click())
flushSync(() => screen.getByText('set pane input').click())
screen.getByText('send pane input').click()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Wrap the final click in flushSync to prevent act(...) warnings.

The native .click() on this line is not wrapped in flushSync like the previous interactions. Because native DOM clicks are not automatically wrapped in act() (unlike @testing-library/user-event or fireEvent), any React state updates triggered by this click will result in a "not wrapped in act(...)" console warning and could cause async timing issues in the test.

🐛 Proposed fix
-    screen.getByText('send pane input').click()
+    flushSync(() => screen.getByText('send pane input').click())
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
screen.getByText('send pane input').click()
flushSync(() => screen.getByText('send pane input').click())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wework/src/features/workbench/WorkbenchProvider.test.tsx` at line 3849, Wrap
the final native click on the “send pane input” element in flushSync, matching
the surrounding interactions, so React state updates triggered by the click are
completed within the test’s act boundary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants